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:
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db, get_db
|
from app.database import init_db, get_db
|
||||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers
|
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury
|
||||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||||
from scripts.erp_sync import run_sync as run_erp_sync
|
from scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth, require_auth_401
|
from app.auth_middleware import require_auth, require_auth_401
|
||||||
@@ -99,6 +99,7 @@ app.include_router(data_classification.router)
|
|||||||
app.include_router(tax_compliance.router)
|
app.include_router(tax_compliance.router)
|
||||||
app.include_router(verify.router)
|
app.include_router(verify.router)
|
||||||
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
||||||
|
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
|
|||||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||||
from app.models.product_sales import ProductSales
|
from app.models.product_sales import ProductSales
|
||||||
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
||||||
|
from app.models.treasury import (
|
||||||
|
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
||||||
|
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Entity(Base):
|
class Entity(Base):
|
||||||
|
|||||||
@@ -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] 审计必需")
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CMA 司库(资金)体系 · 阶段一 MVP —— DDL(阶段 1 落盘版)
|
||||||
|
-- ============================================================================
|
||||||
|
-- 状态 : 落地版(2026-09-15 阶段 1)。G1 = 放行(有条件),见
|
||||||
|
-- wecom-project/pending-tasks/_verification/fullstack-202609151735-cma-treasury-g1-review.md
|
||||||
|
-- 评审稿出处 : wecom-fullstack/pending-tasks/_reports/treasury-stage1-ddl-draft-20260915.sql
|
||||||
|
-- (md5 3aa4d944691b49cfda9b1f08f6bea549 —— 归档件,不回改)
|
||||||
|
-- 本版修正 : P2-6 data_status 默认 fail-safe / D-8 submitter 语义 / 调整2 detail 结构化
|
||||||
|
-- / P2-8 金额纪律注释 / 无物理外键的应用层覆盖声明(§F)
|
||||||
|
-- 依据 : PRD v1.1 §3.2/§3.3(md5 cc9ca839b7fd6f633f4066ba385819be)
|
||||||
|
-- 财务口径签认 v1(md5 74b43cfdf25abf5b7654eba54e82d099)
|
||||||
|
-- 财务侧前置交付 F1/F2/F3(md5 a49ae4340c05dfa20aba7daad25cc25f)
|
||||||
|
-- 目标环境 : MySQL 8.0(InnoDB),charset/collation 以只读探针实测为准
|
||||||
|
-- 范围 : 6 张新表 + cash_plans 加 4 列(向后兼容,只加列不改列)
|
||||||
|
-- 阶段三的 4 张表(pools/pool_members/credits/internal_transfers)不建
|
||||||
|
-- 幂等契约 : 同一脚本重复执行结果不变(见 §E)
|
||||||
|
-- 命名约定 : 约束名与 PRD §3.2 字面一致(唯一键 uq_* / 普通索引 ix_*)
|
||||||
|
-- 执行方式 : 仅由 backend/scripts/treasury_migrate.py --apply 执行(本文件不含裸 ALTER 执行逻辑)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §A. 建表(6 张,CREATE TABLE IF NOT EXISTS 天然幂等)
|
||||||
|
-- 应用层软链(不建物理外键,G1 §3-2 裁定)覆盖声明见 §F
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- A1. 资金账户主数据(统一管理:台账唯一入口)
|
||||||
|
-- tier 口径(财务签认 口径1 / PRD M1):按【账户性质】判定,禁止按 org_nodes.level 派生
|
||||||
|
-- hq : 法人银行户 / 收款通道(1002xx、微信、支付宝、友赞商户)
|
||||||
|
-- grassroots: 现金 / 备用金 / 备用卡 / 中间账户(100101/100104/100105/100106/100107)
|
||||||
|
-- platform : 分公司独立法人账户(当前两实体均无,禁止造虚层级)
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_accounts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL COMMENT '企业ID(多租户铁律,必带)',
|
||||||
|
account_no VARCHAR(64) NOT NULL COMMENT '账号/虚拟账户号(掩码)',
|
||||||
|
account_name VARCHAR(100) NOT NULL COMMENT '账户名(如 建设银行(货款账户))',
|
||||||
|
account_type VARCHAR(20) NOT NULL COMMENT 'cash现金/bank银行/e_wallet微信支付宝/other',
|
||||||
|
bank_name VARCHAR(100) NULL,
|
||||||
|
subject_code VARCHAR(20) NULL COMMENT '对应科目(1001xx/1002xx)— 流水/头寸归集用',
|
||||||
|
currency VARCHAR(10) DEFAULT 'CNY',
|
||||||
|
tier VARCHAR(20) NOT NULL COMMENT 'hq总部/platform平台/grassroots基层(按账户性质判定)',
|
||||||
|
tier_basis VARCHAR(20) DEFAULT 'nature' COMMENT '[D-1] tier 判定依据:nature账户性质/level派生/manual人工;口径1 要求 accounts 一律 nature',
|
||||||
|
owner_node_id INT NULL COMMENT '使用/保管部门标注 org_nodes.id(不参与 tier 派生、不作授权依据)— 软链,应用层校验见 §F',
|
||||||
|
open_balance FLOAT NULL COMMENT '期初余额(万元);F2 到位前留 NULL。金额纪律见 §F-1',
|
||||||
|
open_period VARCHAR(20) NULL COMMENT '期初期间 YYYY-MM',
|
||||||
|
is_pool_member TINYINT DEFAULT 0 COMMENT '阶段三资金池用',
|
||||||
|
status VARCHAR(20) DEFAULT 'active' COMMENT 'active/inactive/closed',
|
||||||
|
remark VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tacct_entity_no (entity_id, account_no),
|
||||||
|
KEY ix_tacct_entity (entity_id, status),
|
||||||
|
KEY ix_tacct_subject (entity_id, subject_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·资金账户主数据';
|
||||||
|
|
||||||
|
|
||||||
|
-- A2. 账户头寸(日/期末)
|
||||||
|
-- 铁律:余额 = 期初 + 流水(期初缺失时 data_status='estimated');
|
||||||
|
-- 禁止把 voucher_details 的"流水净额累计"当余额(PRD §2.2-1 实证)
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_balances (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
account_id INT NOT NULL COMMENT 'treasury_accounts.id(软链,应用层校验见 §F)',
|
||||||
|
period VARCHAR(20) NOT NULL COMMENT 'YYYY-MM-DD 日头寸 / YYYY-MM 月末头寸',
|
||||||
|
balance FLOAT NOT NULL COMMENT '账面余额(万元)。金额纪律见 §F-1',
|
||||||
|
available FLOAT NULL COMMENT '可用余额(万元)(NULL=未采集,勿以 balance 冒充)',
|
||||||
|
frozen FLOAT DEFAULT 0 COMMENT '冻结/在途(万元)',
|
||||||
|
source VARCHAR(30) NOT NULL COMMENT 'ledger期初+流水/import导入/manual手工/pool归集',
|
||||||
|
data_status VARCHAR(20) DEFAULT 'estimated' COMMENT 'verified/estimated —— [P2-6] 默认 estimated(fail-safe):verified 必须显式写入(仅期初已落定/对账通过);estimated 头寸禁止 block 规则生效',
|
||||||
|
batch VARCHAR(100) NULL COMMENT '导入批次指纹(幂等用,对齐 cash_vouchers_json._content_fingerprint 范式)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tbal_acct_period (account_id, period),
|
||||||
|
KEY ix_tbal_entity_period (entity_id, period),
|
||||||
|
KEY ix_tbal_entity_status (entity_id, data_status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·账户头寸(日/期末)';
|
||||||
|
|
||||||
|
|
||||||
|
-- A3. 资金内控规则库(P0 核心)
|
||||||
|
-- params JSON 结构与口径映射:
|
||||||
|
-- NP-001 budget_required {"judge":"budget_plans","entity1":"block","entity2":"warn",
|
||||||
|
-- "occupancy":"paid+approved+pending_approval+this",
|
||||||
|
-- "unmatched":"specify_budget_flow"}
|
||||||
|
-- AU-001 authorization {"tiers":[{"from":0,"to":3,"required_approvals":0},...],
|
||||||
|
-- "daily_cum_limit":9,"max_strict":true,"bounds":"[from,to)"}
|
||||||
|
-- BL-001 dual_sign {"min_amount":30,"distinct_natural_person":true}
|
||||||
|
-- LQ-001 liquidity {"target":26,"warn":12,"block":6,"block_enabled":false,
|
||||||
|
-- "skip_when":"data_status=estimated"}
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_control_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
rule_code VARCHAR(20) NOT NULL COMMENT 'NP-001/AU-001/BL-001/LQ-001/LQ-002/TF-001/AC-001',
|
||||||
|
rule_name VARCHAR(100) NOT NULL,
|
||||||
|
rule_type VARCHAR(30) NOT NULL COMMENT 'budget_required/authorization/dual_sign/liquidity/two_funds/account_valid',
|
||||||
|
severity VARCHAR(10) NOT NULL COMMENT 'block拦截/warn预警',
|
||||||
|
params JSON NULL COMMENT '阈值参数(金额段/下限/比例等,不硬编码)',
|
||||||
|
rule_version VARCHAR(20) DEFAULT 'v1' COMMENT '[调整2] 规则版本,写入 control_hits.detail.rule_version(审计可自证)',
|
||||||
|
enabled TINYINT DEFAULT 1,
|
||||||
|
remark VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tcr_entity_code (entity_id, rule_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·资金内控规则库';
|
||||||
|
|
||||||
|
|
||||||
|
-- A4. 支付单(内控闸门载体)
|
||||||
|
-- 拆单累计穿透(口径2/M4)所需索引:ix_tpay_daysplit(同提交人×同收款方×同自然日累计)
|
||||||
|
-- [D-8] submitter / created_by 语义(G1 复算发现的未披露增补,追认采纳):
|
||||||
|
-- submitter = 支付单提交人 —— AU-001 拆单累计穿透的"同一提交人"判据(业务语义字段)
|
||||||
|
-- created_by = 建单人 —— 历史兼容列(与既有表 created_by 命名一致)
|
||||||
|
-- 首版两者同值(提交即建单);保留双列以便后续"审批链与建单分离"时语义不破坏
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_payments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
pay_no VARCHAR(50) NOT NULL COMMENT '支付单号',
|
||||||
|
from_account_id INT NOT NULL COMMENT '付款账户(必须已登记,禁未入台账账户)——软链,应用层校验见 §F',
|
||||||
|
to_name VARCHAR(200) NOT NULL COMMENT '收款方',
|
||||||
|
to_account_no VARCHAR(64) NULL,
|
||||||
|
amount FLOAT NOT NULL COMMENT '金额(万元)。金额纪律见 §F-1',
|
||||||
|
currency VARCHAR(10) DEFAULT 'CNY',
|
||||||
|
purpose VARCHAR(500) NOT NULL COMMENT '用途(必填,内控留痕)',
|
||||||
|
pay_type VARCHAR(30) NULL COMMENT '货款/费用/税费/薪酬/融资/内部划转',
|
||||||
|
pay_date DATE NULL COMMENT '业务付款日(拆单累计穿透按此自然日归集;口径2)',
|
||||||
|
budget_plan_id INT NULL COMMENT '关联预算计划(NP-001 授权性判据首选)——软链',
|
||||||
|
kpi_id INT NULL,
|
||||||
|
related_plan_id INT NULL COMMENT '关联 cash_plans.id(不新建表,复用收付计划)——软链',
|
||||||
|
status VARCHAR(20) DEFAULT 'draft' COMMENT 'draft/precheck/pending_approval/approved/rejected/paid/cancelled',
|
||||||
|
check_result VARCHAR(20) NULL COMMENT 'pass/blocked/warn',
|
||||||
|
check_detail JSON NULL COMMENT '[{rule_code,verdict,detail,matched_scope}] 命中档位写在 matched_scope(口径2-③)',
|
||||||
|
required_approvals TINYINT NULL COMMENT '[D-2] 本单实际所需审批级数(取最严档结果,落库便于审计复现)',
|
||||||
|
day_cum_amount FLOAT NULL COMMENT '[D-3] 判定时 同提交人×同收款方×同自然日 累计金额(万元,含本单)',
|
||||||
|
approvals JSON NULL COMMENT '[{account,role,person_name,at,comment}] — 双签须记账号+角色+自然人姓名(口径4)',
|
||||||
|
dual_sign_effective TINYINT NULL COMMENT 'BL-001:两账号是否不同自然人;0=形式双签(放行无效,审计标红)',
|
||||||
|
is_waived TINYINT DEFAULT 0 COMMENT '[D-4] 是否经豁免通道放行(豁免人/事由落 treasury_control_hits)',
|
||||||
|
submitter VARCHAR(100) NOT NULL COMMENT '[D-8] 提交人(AU-001 拆单累计穿透"同一提交人"判据)',
|
||||||
|
submitted_at DATETIME NULL COMMENT '[D-5] 提交时间(口径2 日累计/占用口径需要审批链时间锚)',
|
||||||
|
approved_at DATETIME NULL COMMENT '[D-5] 放行时间',
|
||||||
|
paid_at DATETIME NULL,
|
||||||
|
created_by VARCHAR(100) NOT NULL COMMENT '[D-8] 建单人(历史兼容列;首版与 submitter 同值)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tpay_entity_no (entity_id, pay_no),
|
||||||
|
KEY ix_tpay_entity_status (entity_id, status),
|
||||||
|
KEY ix_tpay_daysplit (entity_id, pay_date, to_name, submitter),
|
||||||
|
KEY ix_tpay_acct (entity_id, from_account_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·支付单(资金内控闸门)';
|
||||||
|
|
||||||
|
|
||||||
|
-- A5. 规则命中留痕(审计,append-only,不可省)
|
||||||
|
-- [调整2 / D-6] detail 固定结构化字段(规则引擎按此写入,审计追问要能自证):
|
||||||
|
-- {"rule_code":..,"rule_version":..,"matched_scope":..,"threshold":..,
|
||||||
|
-- "cumulative":..,"verdict_basis":".."}
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_control_hits (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
target_type VARCHAR(20) NOT NULL COMMENT 'payment/plan/internal_transfer',
|
||||||
|
target_id INT NOT NULL,
|
||||||
|
rule_code VARCHAR(20) NOT NULL,
|
||||||
|
verdict VARCHAR(20) NOT NULL COMMENT 'pass/blocked/waived/warn',
|
||||||
|
detail JSON NULL COMMENT '[调整2] 结构化判据快照,固定字段 {rule_code, rule_version, matched_scope, threshold, cumulative, verdict_basis}',
|
||||||
|
waived_by VARCHAR(100) NULL COMMENT '豁免审批人(豁免级别不得低于该笔所需审批级别,口径4)',
|
||||||
|
waived_reason VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY ix_tch_target (target_type, target_id),
|
||||||
|
KEY ix_tch_entity_rule (entity_id, rule_code, created_at),
|
||||||
|
KEY ix_tch_waived (entity_id, verdict, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·内控规则命中留痕(append-only 审计)';
|
||||||
|
|
||||||
|
|
||||||
|
-- A6. 分级授权矩阵(不硬编码;A/B 数值生效前需任总过目)
|
||||||
|
-- 边界口径(口径2-④ 左闭右开):amount_from 含、amount_to 不含;amount_to IS NULL = 无上限
|
||||||
|
CREATE TABLE IF NOT EXISTS treasury_limits (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
entity_id INT NOT NULL,
|
||||||
|
scope_type VARCHAR(20) NOT NULL COMMENT 'role/account/tier',
|
||||||
|
scope_value VARCHAR(50) NOT NULL COMMENT 'finance/ceo/…/account_id/tier',
|
||||||
|
amount_from FLOAT NOT NULL COMMENT '段下限(万元,含)。金额纪律见 §F-1',
|
||||||
|
amount_to FLOAT NULL COMMENT '段上限(万元,不含),NULL=无上限',
|
||||||
|
required_approvals TINYINT DEFAULT 1 COMMENT '所需审批级数(≥2=双签)',
|
||||||
|
approver_roles JSON NOT NULL COMMENT '["ceo","finance"](顺序=先复核后批准,口径4)',
|
||||||
|
require_budget TINYINT DEFAULT 1,
|
||||||
|
daily_cum_limit FLOAT NULL COMMENT '自主档日累计上限(=3×A;超限升一级审批,口径2-②)',
|
||||||
|
status VARCHAR(20) DEFAULT 'active',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '[D-9] 审计必需(PRD 该表无此列,G1 追认采纳)',
|
||||||
|
UNIQUE KEY uq_tlim_scope (entity_id, scope_type, scope_value, amount_from),
|
||||||
|
KEY ix_tlim_entity (entity_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司库·分级授权矩阵';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §B. 扩展既有表(只加列、不改列、默认 NULL —— 存量数据零影响)
|
||||||
|
-- 注意:MySQL 8.0 不支持 ADD COLUMN IF NOT EXISTS,
|
||||||
|
-- 裸 ALTER 重复执行会报 1060 Duplicate column name。
|
||||||
|
-- 故 §B 由 migrate 脚本按 information_schema 预检结果条件执行(见 §E-②)。
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- B0. 幂等预检(migrate 脚本执行前必跑;输出缺失列清单,全 0 即跳过)
|
||||||
|
-- SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
||||||
|
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cash_plans'
|
||||||
|
-- AND COLUMN_NAME IN ('account_id','tier','pool_id','is_locked');
|
||||||
|
|
||||||
|
ALTER TABLE cash_plans
|
||||||
|
ADD COLUMN account_id INT NULL COMMENT '资金来源/去向账户 treasury_accounts.id(软链,无物理外键)',
|
||||||
|
ADD COLUMN tier VARCHAR(20) NULL COMMENT 'hq/platform/grassroots(三位一体视角;可由 org_nodes.level 派生)',
|
||||||
|
ADD COLUMN pool_id INT NULL COMMENT '归属资金池(阶段三启用,本阶段恒 NULL)',
|
||||||
|
ADD COLUMN is_locked TINYINT DEFAULT 0 COMMENT '是否已被预算/内控锁定';
|
||||||
|
|
||||||
|
-- B1. 可选索引 [D-7](G1 已随 D-1~D-7 全部采纳;与 §B 同批执行)
|
||||||
|
ALTER TABLE cash_plans ADD KEY ix_cashplans_entity_acct (entity_id, account_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §C. 数据种子(首版数值 = 财务建议值;任总拍板后只改数据行、不改代码)
|
||||||
|
-- 幂等:INSERT ... ON DUPLICATE KEY UPDATE(唯一键 = uq_tcr_entity_code / uq_tlim_scope)
|
||||||
|
-- 行数契约:规则 8 行(e1 4 + e2 4)+ 授权矩阵 6 行
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- C1. 三条 block 规则 + LQ-001(entity1 / entity2 各一套)
|
||||||
|
-- NP-001:entity1=block,entity2=warn(分期上线,过渡期 1 个月后切 block)
|
||||||
|
-- AU-001:tiers 采用签认首版 A/B(酣客 3/30 万;博海 5/50 万)—— 待任总过目
|
||||||
|
-- BL-001:distinct_natural_person=true(两账号须属不同自然人)
|
||||||
|
-- LQ-001:首版一律 warn、block_enabled=false(阶段二接预警)
|
||||||
|
INSERT INTO treasury_control_rules (entity_id, rule_code, rule_name, rule_type, severity, params, rule_version, enabled)
|
||||||
|
VALUES
|
||||||
|
(1,'NP-001','无预算不开支(授权性判据)','budget_required','block',
|
||||||
|
JSON_OBJECT('judge','budget_plans','severity','block',
|
||||||
|
'occupancy','paid+approved+pending_approval+this',
|
||||||
|
'unmatched','specify_budget_flow','transition_until','2026-10-15'),'v1',1),
|
||||||
|
(1,'AU-001','超授权拦截(含拆单累计穿透+日累计升级)','authorization','block',
|
||||||
|
JSON_OBJECT('tiers',JSON_ARRAY(JSON_OBJECT('from',0,'to',3,'required_approvals',0),
|
||||||
|
JSON_OBJECT('from',3,'to',30,'required_approvals',1),
|
||||||
|
JSON_OBJECT('from',30,'to',NULL,'required_approvals',2)),
|
||||||
|
'daily_cum_limit',9,'merge_same_payee_same_day',true,'max_strict',true,'bounds','[from,to)'),'v1',1),
|
||||||
|
(1,'BL-001','大额支付双签(须不同自然人)','dual_sign','block',
|
||||||
|
JSON_OBJECT('min_amount',30,'required_approvals',2,'distinct_natural_person',true,
|
||||||
|
'record_fields',JSON_ARRAY('account','role','person_name'),'order','review_then_approve'),'v1',1),
|
||||||
|
(1,'LQ-001','流动性下限(首版 warn-only 页面并列对照)','liquidity','warn',
|
||||||
|
JSON_OBJECT('target',26,'warn',12,'block',6,'block_enabled',false,
|
||||||
|
'skip_when','data_status=estimated'),'v1',1),
|
||||||
|
(2,'NP-001','无预算不开支(博海首版 warn+强制补录)','budget_required','warn',
|
||||||
|
JSON_OBJECT('judge','budget_plans','severity','warn','occupancy','paid+approved+pending_approval+this',
|
||||||
|
'unmatched','specify_budget_flow','transition_until','2026-10-15'),'v1',1),
|
||||||
|
(2,'AU-001','超授权拦截(含拆单累计穿透+日累计升级)','authorization','block',
|
||||||
|
JSON_OBJECT('tiers',JSON_ARRAY(JSON_OBJECT('from',0,'to',5,'required_approvals',0),
|
||||||
|
JSON_OBJECT('from',5,'to',50,'required_approvals',1),
|
||||||
|
JSON_OBJECT('from',50,'to',NULL,'required_approvals',2)),
|
||||||
|
'daily_cum_limit',15,'merge_same_payee_same_day',true,'max_strict',true,'bounds','[from,to)'),'v1',1),
|
||||||
|
(2,'BL-001','大额支付双签(须不同自然人)','dual_sign','block',
|
||||||
|
JSON_OBJECT('min_amount',50,'required_approvals',2,'distinct_natural_person',true,
|
||||||
|
'record_fields',JSON_ARRAY('account','role','person_name'),'order','review_then_approve'),'v1',1),
|
||||||
|
(2,'LQ-001','流动性下限(博海首版默认 warn/关闭)','liquidity','warn',
|
||||||
|
JSON_OBJECT('target',50,'warn',10,'block',5,'block_enabled',false,
|
||||||
|
'skip_when','data_status=estimated'),'v1',0)
|
||||||
|
ON DUPLICATE KEY UPDATE rule_name=VALUES(rule_name), rule_type=VALUES(rule_type),
|
||||||
|
severity=VALUES(severity), params=VALUES(params), rule_version=VALUES(rule_version);
|
||||||
|
|
||||||
|
-- C2. 授权矩阵(scope_type='role',按 entity 生效;双签档 approver_roles 顺序=先复核后批准)
|
||||||
|
INSERT INTO treasury_limits (entity_id, scope_type, scope_value, amount_from, amount_to,
|
||||||
|
required_approvals, approver_roles, require_budget, daily_cum_limit)
|
||||||
|
VALUES
|
||||||
|
(1,'role','finance',0, 3, 0, JSON_ARRAY(), 1, 9),
|
||||||
|
(1,'role','ceo', 3, 30, 1, JSON_ARRAY('ceo'), 1, NULL),
|
||||||
|
(1,'role','ceo', 30, NULL,2, JSON_ARRAY('finance','ceo'),1, NULL),
|
||||||
|
(2,'role','finance',0, 5, 0, JSON_ARRAY(), 1, 15),
|
||||||
|
(2,'role','ceo', 5, 50, 1, JSON_ARRAY('ceo'), 1, NULL),
|
||||||
|
(2,'role','ceo', 50, NULL,2, JSON_ARRAY('finance','ceo'),1, NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE amount_to=VALUES(amount_to), required_approvals=VALUES(required_approvals),
|
||||||
|
approver_roles=VALUES(approver_roles), daily_cum_limit=VALUES(daily_cum_limit);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §D. 回滚 / DOWN(由 treasury_migrate.py --down 执行;顺序与 UP 相反)
|
||||||
|
-- ============================================================================
|
||||||
|
-- D1. 回滚 cash_plans 扩展列:[P1-3] 按列存在性**逐列** DROP(部分态不再 1091)
|
||||||
|
-- 回滚前 [P1-4] 必先 mysqldump cash_plans(写路径账号 + 临时 defaults-file 注入口令,
|
||||||
|
-- 不硬编码、不回显;dump 失败 fail-closed —— 先 dump 成功才允许删列)
|
||||||
|
-- D2. 回滚新表(先删留痕/引用方,再删主数据):DROP TABLE IF EXISTS 逐张
|
||||||
|
-- D3. 回滚安全性:6 张新表首版为空表(DROP 无损);一旦有业务数据,
|
||||||
|
-- 回滚须经人工确认(--yes),不提供 --force 免确认开关
|
||||||
|
-- D4. 回滚不触碰 cash.py 既有表结构,11 端点契约不受影响
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §E. 幂等与验证(由 treasury_migrate.py 逐条实现)
|
||||||
|
-- ============================================================================
|
||||||
|
-- E1. 幂等三件套 + 计数口径([P0-2] 修正)
|
||||||
|
-- ① CREATE TABLE IF NOT EXISTS(表级幂等)
|
||||||
|
-- ② §B 列扩展按 information_schema 预检(列级幂等)
|
||||||
|
-- ③ §C 种子走 ON DUPLICATE KEY UPDATE(数据级幂等)
|
||||||
|
-- ⇒ 计数口径 = **新增对象数**(apply 前后 information_schema diff),不是"执行语句数":
|
||||||
|
-- 首跑:created_new=6 / added_cols=4 / 种子 8 规则行 + 6 授权行
|
||||||
|
-- 二跑:created_new=0 / added_cols=0 / 种子行数不变
|
||||||
|
-- ④ up() 结尾断言 check()["missing_tables"] == [],否则非零退出(禁静默成功)
|
||||||
|
-- ⑤ 指纹行写入 import_logs(batch=treasury-ddl-<sha1[:10]>),[P2-7] entity_id=1 为约定:
|
||||||
|
-- 迁移指纹属实体无关元数据,import_logs.entity_id NOT NULL 约束下以 1 占位;
|
||||||
|
-- 幂等按 (import_type,batch) 先查后写,不依赖表上是否有唯一键
|
||||||
|
-- E2. 表级自检(落库后)
|
||||||
|
-- SHOW CREATE TABLE treasury_accounts; -- 逐表核对 entity_id/唯一键/引擎字符集
|
||||||
|
-- SELECT TABLE_NAME FROM information_schema.TABLES
|
||||||
|
-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'treasury%'; -- 期望 6 行
|
||||||
|
-- SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE FROM information_schema.COLUMNS
|
||||||
|
-- WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'treasury%'
|
||||||
|
-- AND COLUMN_NAME='entity_id'; -- 期望 6 行且全 NOT NULL
|
||||||
|
-- E3. 存量零影响证明
|
||||||
|
-- SELECT COUNT(*) FROM cash_plans; -- 迁移前后一致(基线以探针实测为准)
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- §F. 应用层约束声明(G1 裁定"不建物理外键"的附条件覆盖说明 + 金额纪律)
|
||||||
|
-- ============================================================================
|
||||||
|
-- F-1 金额纪律([P2-8] / G1 §3-3 附条件):
|
||||||
|
-- * 全部金额列为 FLOAT(万元)—— 与 cash_plans.amount / budget_plans.budget_value 同口径;
|
||||||
|
-- FLOAT 非精确货币类型(技术债登记:迁 DECIMAL(18,4) 须与 cash_plans/budget_plans 同批)
|
||||||
|
-- * 写库前一律 round(x, 4);比较一律用容差 TOL=1e-4,禁止浮点等值比较(引擎实现见 treasury.py)
|
||||||
|
-- F-2 软链的应用层校验覆盖(逐表说明,G1 §3-2 附条件):
|
||||||
|
-- ① treasury_balances.account_id → 写入/查询前校验 account 存在 且 account.entity_id == 传入 entity_id(防跨实体误挂)
|
||||||
|
-- ② treasury_payments.from_account_id → AC-001:必须命中 treasury_accounts 且 status='active'(账外账户拦截)
|
||||||
|
-- ③ treasury_payments.budget_plan_id → NP-001:命中 budget_plans 且 entity_id 一致;未命中走"指定/补录预算"流程
|
||||||
|
-- ④ treasury_payments.related_plan_id → 校验 cash_plans 同 entity_id(只读复用,不改 cash.py 契约)
|
||||||
|
-- ⑤ treasury_payments.kpi_id → 校验 kpis 同 entity_id(NP-001 兜底匹配用)
|
||||||
|
-- ⑥ treasury_control_hits.target_id → target_type 限定取值域(payment/plan/internal_transfer),
|
||||||
|
-- 写入前校验目标单据存在且同 entity_id
|
||||||
|
-- ⑦ treasury_accounts.owner_node_id → 校验 org_nodes 同 entity_id(不存在则留空,禁造虚层级)
|
||||||
|
-- * 唯一键 + 应用层校验 + AC-001 覆盖主要一致性风险;跨实体写入在应用层一律 403/400 拒绝
|
||||||
|
-- ============================================================================
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CMA 司库(资金)阶段一 —— 迁移脚本(阶段 1 落地版)
|
||||||
|
|
||||||
|
落地路径:/root/cma-management/backend/scripts/treasury_migrate.py
|
||||||
|
评审稿 :wecom-fullstack/pending-tasks/_reports/treasury-stage1-migration-draft-20260915.py(md5 ceaa3b3bf563b8d664ddd7224f01a5bf,归档不回改)
|
||||||
|
G1 修正项 :P0-1 up() 语句过滤器 / P0-2 计数口径 / P1-3 down() 部分态 / P1-4 down() dump 凭据 /
|
||||||
|
P1-5 种子展开 / P2-6 data_status fail-safe(DDL 侧)/ P2-7 import_logs 指纹行 / P2-8 金额纪律
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 treasury_migrate.py --parse-only # 零 DB:只解析 DDL,打印命中语句与期望计数(P0-1 复算证据)
|
||||||
|
python3 treasury_migrate.py --check # 只读预检:缺表/缺列/缺索引/种子行数(零写入,默认动作)
|
||||||
|
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子(幂等)
|
||||||
|
python3 treasury_migrate.py --down # 回滚(先 dump 后删;有数据时要求 --yes)
|
||||||
|
|
||||||
|
幂等契约(P0-2 计数口径 = 新增对象数,不是执行语句数):
|
||||||
|
首跑 --apply 期望:created_new=6 / added_cols=4 / added_indexes=1 / rules=8 行 / limits=6 行
|
||||||
|
二跑 --apply 期望:created_new=0 / added_cols=0 / added_indexes=0 / 种子行数不变
|
||||||
|
指纹行:import_logs(batch=treasury-ddl-<sha1[:10]>),按 (import_type,batch) 先查后写,
|
||||||
|
不依赖表上是否有唯一键(P2-7:entity_id=1 为约定——迁移指纹属实体无关元数据)
|
||||||
|
边界:只动 6 张 treasury_* 新表 + cash_plans 加列/加索引;不碰 cash.py / 既有端点契约 / 其他模块表结构。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
from app.database import get_engine # noqa: E402
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BACKUP_DIR = os.path.join(HERE, "backups")
|
||||||
|
DDL_FILE = os.path.join(HERE, "treasury_ddl.sql")
|
||||||
|
|
||||||
|
NEW_TABLES = [
|
||||||
|
"treasury_accounts",
|
||||||
|
"treasury_balances",
|
||||||
|
"treasury_control_rules",
|
||||||
|
"treasury_payments",
|
||||||
|
"treasury_control_hits",
|
||||||
|
"treasury_limits",
|
||||||
|
]
|
||||||
|
CASH_PLAN_NEW_COLS = ["account_id", "tier", "pool_id", "is_locked"]
|
||||||
|
CASH_PLAN_NEW_INDEX = "ix_cashplans_entity_acct"
|
||||||
|
EXPECTED_RULE_ROWS = 8 # e1 4 + e2 4
|
||||||
|
EXPECTED_LIMIT_ROWS = 6 # e1 3 + e2 3
|
||||||
|
CASH_PLAN_ALTER = """ALTER TABLE cash_plans
|
||||||
|
ADD COLUMN account_id INT NULL COMMENT '资金来源/去向账户 treasury_accounts.id(软链,无物理外键)',
|
||||||
|
ADD COLUMN tier VARCHAR(20) NULL COMMENT 'hq/platform/grassroots(三位一体视角;可由 org_nodes.level 派生)',
|
||||||
|
ADD COLUMN pool_id INT NULL COMMENT '归属资金池(阶段三启用,本阶段恒 NULL)',
|
||||||
|
ADD COLUMN is_locked TINYINT DEFAULT 0 COMMENT '是否已被预算/内控锁定'"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# DDL 解析(P0-1:先剔除注释行再判定语句类型,不再用 startswith 裸判)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _strip_comment_lines(sql_text: str) -> str:
|
||||||
|
"""剔除整行注释(-- 开头)+ 行尾注释,避免注释行带动导致语句被跳过/误判。"""
|
||||||
|
out = []
|
||||||
|
for raw in sql_text.splitlines():
|
||||||
|
line = raw
|
||||||
|
idx = line.find("--")
|
||||||
|
if idx >= 0:
|
||||||
|
line = line[:idx]
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_statements(sql_text: str) -> list:
|
||||||
|
return [s.strip() for s in _strip_comment_lines(sql_text).split(";") if s.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _stmt_table_name(stmt: str, keyword: str):
|
||||||
|
"""取 `KEYWORD TABLE [IF NOT EXISTS] <name>` 中的表名。"""
|
||||||
|
toks = stmt.replace("(", " ").replace("\n", " ").split()
|
||||||
|
up = [t.upper() for t in toks]
|
||||||
|
try:
|
||||||
|
i = up.index(keyword)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
j = i + 1
|
||||||
|
while j < len(up) and up[j] in ("IF", "NOT", "EXISTS"):
|
||||||
|
j += 1
|
||||||
|
return toks[j].strip("`") if j < len(toks) else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ddl(path=DDL_FILE):
|
||||||
|
"""解析 DDL → {creates: [(table, stmt)], seeds: [stmt], alter: str|None, index: str|None}"""
|
||||||
|
sql = open(path, encoding="utf-8").read()
|
||||||
|
creates, seeds, alter, index_stmt = [], [], None, None
|
||||||
|
for stmt in _split_statements(sql):
|
||||||
|
head = stmt.upper()
|
||||||
|
if head.startswith("CREATE TABLE"):
|
||||||
|
creates.append((_stmt_table_name(stmt, "TABLE"), stmt))
|
||||||
|
elif head.startswith("INSERT INTO"):
|
||||||
|
seeds.append(stmt)
|
||||||
|
elif head.startswith("ALTER TABLE CASH_PLANS ADD KEY"):
|
||||||
|
index_stmt = stmt
|
||||||
|
elif head.startswith("ALTER TABLE CASH_PLANS"):
|
||||||
|
alter = stmt
|
||||||
|
return {"creates": creates, "seeds": seeds, "alter": alter, "index": index_stmt,
|
||||||
|
"ddl_text": sql}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_ddl_parseable(p):
|
||||||
|
"""P0-1 断言:6 条 CREATE 全命中且表名与 NEW_TABLES 一致;种子 2 条;ALTER/B1 各 1 条。"""
|
||||||
|
names = [t for t, _ in p["creates"]]
|
||||||
|
errs = []
|
||||||
|
if len(p["creates"]) != len(NEW_TABLES):
|
||||||
|
errs.append(f"CREATE TABLE 命中数 {len(p['creates'])} != {len(NEW_TABLES)}({names})")
|
||||||
|
if set(names) != set(NEW_TABLES):
|
||||||
|
errs.append(f"CREATE TABLE 表名集合不一致: {sorted(names)} vs {sorted(NEW_TABLES)}")
|
||||||
|
if len(p["seeds"]) != 2:
|
||||||
|
errs.append(f"种子 INSERT 语句数 {len(p['seeds'])} != 2")
|
||||||
|
if not p["alter"]:
|
||||||
|
errs.append("DDL §B 未解析到 ALTER TABLE cash_plans 加列语句")
|
||||||
|
if not p["index"]:
|
||||||
|
errs.append("DDL §B1 未解析到 ALTER TABLE cash_plans ADD KEY 语句")
|
||||||
|
if p["alter"] and p["alter"].strip() not in CASH_PLAN_ALTER.strip():
|
||||||
|
errs.append("DDL §B ALTER 文本与脚本 CASH_PLAN_ALTER 常量漂移(指纹口径会不一致)")
|
||||||
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(texts):
|
||||||
|
canon = json.dumps(sorted(texts), ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return hashlib.sha1(canon.encode("utf-8")).hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# information_schema 内省(只读)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _introspect(conn):
|
||||||
|
rows = conn.execute(text(
|
||||||
|
"SELECT TABLE_NAME FROM information_schema.TABLES "
|
||||||
|
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'treasury%'"
|
||||||
|
)).fetchall()
|
||||||
|
tables = {r[0] for r in rows}
|
||||||
|
cols = conn.execute(text(
|
||||||
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cash_plans'"
|
||||||
|
)).fetchall()
|
||||||
|
colset = {r[0] for r in cols}
|
||||||
|
idx = conn.execute(text(
|
||||||
|
"SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS "
|
||||||
|
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cash_plans'"
|
||||||
|
)).fetchall()
|
||||||
|
return tables, colset, {r[0] for r in idx}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_counts(conn):
|
||||||
|
counts = {}
|
||||||
|
for tbl, key in (("treasury_control_rules", "rules_rows"), ("treasury_limits", "limits_rows")):
|
||||||
|
try:
|
||||||
|
counts[key] = conn.execute(text(f"SELECT COUNT(*) FROM {tbl}")).scalar()
|
||||||
|
except Exception:
|
||||||
|
counts[key] = None
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def check(conn):
|
||||||
|
"""只读预检 —— 缺表 / 缺列 / 缺索引 / 存量行数 / 种子行数;零写入。"""
|
||||||
|
tables, cols, idx = _introspect(conn)
|
||||||
|
cash_rows = conn.execute(text("SELECT COUNT(*) FROM cash_plans")).scalar()
|
||||||
|
report = {
|
||||||
|
"missing_tables": [t for t in NEW_TABLES if t not in tables],
|
||||||
|
"existing_tables": sorted(tables),
|
||||||
|
"missing_cash_plan_cols": [c for c in CASH_PLAN_NEW_COLS if c not in cols],
|
||||||
|
"missing_cash_plan_index": [] if CASH_PLAN_NEW_INDEX in idx else [CASH_PLAN_NEW_INDEX],
|
||||||
|
"cash_plans_rows": cash_rows,
|
||||||
|
}
|
||||||
|
report.update(_seed_counts(conn))
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# UP / DOWN
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _write_fingerprint(conn, batch, tables_created):
|
||||||
|
"""P2-7:指纹行按 (import_type,batch) 先查后写(不依赖唯一键;entity_id=1 为约定)。"""
|
||||||
|
cols = {r[0] for r in conn.execute(text(
|
||||||
|
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='import_logs'"
|
||||||
|
)).fetchall()}
|
||||||
|
need = {"entity_id", "filename", "batch", "total_rows", "success_rows", "import_type"}
|
||||||
|
if not need <= cols:
|
||||||
|
raise RuntimeError(f"import_logs 结构不符,缺列 {sorted(need - cols)}(fail-closed,不静默跳过)")
|
||||||
|
row = conn.execute(text(
|
||||||
|
"SELECT id FROM import_logs WHERE import_type='treasury-ddl' AND batch=:b ORDER BY id LIMIT 1"
|
||||||
|
), {"b": batch}).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE import_logs SET total_rows=:t, success_rows=:s WHERE id=:i"
|
||||||
|
), {"t": tables_created, "s": tables_created, "i": row[0]})
|
||||||
|
return "updated"
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO import_logs (entity_id, filename, batch, total_rows, success_rows, import_type, created_by) "
|
||||||
|
"VALUES (1, 'treasury_ddl.sql', :b, :t, :s, 'treasury-ddl', 'treasury_migrate')"
|
||||||
|
), {"b": batch, "t": tables_created, "s": tables_created})
|
||||||
|
return "inserted"
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn):
|
||||||
|
p = parse_ddl()
|
||||||
|
errs = assert_ddl_parseable(p)
|
||||||
|
if errs:
|
||||||
|
for e in errs:
|
||||||
|
print("❌ DDL 解析失败:", e, file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
before_tables, before_cols, before_idx = _introspect(conn)
|
||||||
|
|
||||||
|
# ① 建表(6 条 CREATE,IF NOT EXISTS 幂等)
|
||||||
|
for _table, stmt in p["creates"]:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
|
||||||
|
# ② 条件加列(列级幂等)
|
||||||
|
missing_cols = [c for c in CASH_PLAN_NEW_COLS if c not in before_cols]
|
||||||
|
if missing_cols:
|
||||||
|
conn.execute(text(p["alter"]))
|
||||||
|
|
||||||
|
# ③ 条件加索引(D-7)
|
||||||
|
if CASH_PLAN_NEW_INDEX not in before_idx:
|
||||||
|
conn.execute(text(p["index"]))
|
||||||
|
|
||||||
|
# ④ 种子(ON DUPLICATE KEY UPDATE,数据级幂等)
|
||||||
|
for stmt in p["seeds"]:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
after_tables, after_cols, after_idx = _introspect(conn)
|
||||||
|
created_new = len(after_tables - before_tables)
|
||||||
|
added_cols = len(after_cols - before_cols)
|
||||||
|
added_indexes = len(after_idx - before_idx)
|
||||||
|
counts = _seed_counts(conn)
|
||||||
|
|
||||||
|
# ⑤ 自检断言:缺表即非零退出(P0-1,禁静默成功)
|
||||||
|
post = check(conn)
|
||||||
|
if post["missing_tables"]:
|
||||||
|
print(f"❌ up() 后仍有缺表 {post['missing_tables']}(非零退出)", file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
# ⑥ 指纹行
|
||||||
|
batch = "treasury-ddl-" + _fingerprint([p["ddl_text"], CASH_PLAN_ALTER])
|
||||||
|
action = _write_fingerprint(conn, batch, created_new or len(NEW_TABLES))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"phase": "up",
|
||||||
|
"created_new": created_new,
|
||||||
|
"added_cols": added_cols,
|
||||||
|
"added_indexes": added_indexes,
|
||||||
|
"seeded_statements": len(p["seeds"]),
|
||||||
|
"rules_rows": counts["rules_rows"],
|
||||||
|
"limits_rows": counts["limits_rows"],
|
||||||
|
"import_logs_row": action,
|
||||||
|
"batch": batch,
|
||||||
|
"missing_tables": post["missing_tables"],
|
||||||
|
"cash_plans_rows": post["cash_plans_rows"],
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_cash_plans(out_path):
|
||||||
|
"""P1-4:写路径账号 + 临时 defaults-file 注入口令(不硬编码、不回显);失败即抛异常(fail-closed)。"""
|
||||||
|
from app import database as db
|
||||||
|
|
||||||
|
fd, cnf = tempfile.mkstemp(prefix=".cma-dump-", suffix=".cnf")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write("[client]\nuser=%s\npassword=%s\nhost=%s\nport=%s\n"
|
||||||
|
% (db.DB_USER, db.DB_PASS, db.DB_HOST, db.DB_PORT))
|
||||||
|
os.chmod(cnf, 0o600)
|
||||||
|
with open(out_path, "w") as fh:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["mysqldump", f"--defaults-extra-file={cnf}", "--single-transaction",
|
||||||
|
"--skip-comments", db.DB_NAME, "cash_plans"],
|
||||||
|
stdout=fh, stderr=subprocess.PIPE, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError("mysqldump 失败(rc=%s): %s" % (proc.returncode, (proc.stderr or "").strip()[:300]))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(cnf)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def down(conn):
|
||||||
|
"""回滚:先 dump(fail-closed)→ 逐列删扩展列(P1-3)→ 删索引 → 删表(IF EXISTS)。"""
|
||||||
|
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
dump = os.path.join(BACKUP_DIR, f"cash_plans-before-rollback-{stamp}.sql")
|
||||||
|
try:
|
||||||
|
_dump_cash_plans(dump)
|
||||||
|
except Exception as exc: # fail-closed:dump 不成功就不允许删列
|
||||||
|
print(f"❌ 备份失败,回滚中止(未做任何结构变更): {exc}", file=sys.stderr)
|
||||||
|
return 5
|
||||||
|
print("backup:", dump, os.path.getsize(dump), "bytes")
|
||||||
|
|
||||||
|
tables, cols, idx = _introspect(conn)
|
||||||
|
dropped = []
|
||||||
|
for col in CASH_PLAN_NEW_COLS: # P1-3:按列存在性逐列 DROP(部分态不再 1091)
|
||||||
|
if col in cols:
|
||||||
|
conn.execute(text(f"ALTER TABLE cash_plans DROP COLUMN {col}"))
|
||||||
|
dropped.append(col)
|
||||||
|
if CASH_PLAN_NEW_INDEX in idx:
|
||||||
|
conn.execute(text(f"ALTER TABLE cash_plans DROP INDEX {CASH_PLAN_NEW_INDEX}"))
|
||||||
|
dropped.append(CASH_PLAN_NEW_INDEX)
|
||||||
|
for t in reversed(NEW_TABLES):
|
||||||
|
conn.execute(text(f"DROP TABLE IF EXISTS {t}"))
|
||||||
|
conn.commit()
|
||||||
|
print(json.dumps({"phase": "down", "dropped_cash_plan_objects": dropped,
|
||||||
|
"dropped_tables": list(reversed(NEW_TABLES)), "backup": dump},
|
||||||
|
ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 入口
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_only():
|
||||||
|
"""零 DB 的 DDL 解析复算(P0-1 证据):命中 6 条 CREATE + 表名清单 + 种子语句。"""
|
||||||
|
p = parse_ddl()
|
||||||
|
errs = assert_ddl_parseable(p)
|
||||||
|
out = {
|
||||||
|
"ddl_file": DDL_FILE,
|
||||||
|
"ddl_md5": hashlib.md5(open(DDL_FILE, "rb").read()).hexdigest(),
|
||||||
|
"statements_total": len(_split_statements(p["ddl_text"])),
|
||||||
|
"create_table_hits": len(p["creates"]),
|
||||||
|
"create_table_names": [t for t, _ in p["creates"]],
|
||||||
|
"seed_statements": len(p["seeds"]),
|
||||||
|
"has_cash_plan_alter": bool(p["alter"]),
|
||||||
|
"has_cash_plan_index": bool(p["index"]),
|
||||||
|
"expected_first_run": {"created_new": 6, "added_cols": 4, "added_indexes": 1,
|
||||||
|
"rules_rows": EXPECTED_RULE_ROWS, "limits_rows": EXPECTED_LIMIT_ROWS},
|
||||||
|
"expected_second_run": {"created_new": 0, "added_cols": 0, "added_indexes": 0,
|
||||||
|
"rules_rows": EXPECTED_RULE_ROWS, "limits_rows": EXPECTED_LIMIT_ROWS},
|
||||||
|
"errors": errs,
|
||||||
|
}
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
|
return 1 if errs else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
g = ap.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--parse-only", action="store_true", help="零 DB:只解析 DDL(P0-1 复算证据)")
|
||||||
|
g.add_argument("--check", action="store_true", help="只读预检(默认)")
|
||||||
|
g.add_argument("--apply", action="store_true")
|
||||||
|
g.add_argument("--down", action="store_true")
|
||||||
|
ap.add_argument("--yes", action="store_true", help="回滚确认(表内有数据时必需)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.parse_only:
|
||||||
|
return parse_only()
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if args.apply:
|
||||||
|
return up(conn)
|
||||||
|
if args.down:
|
||||||
|
tables, _c, _i = _introspect(conn)
|
||||||
|
payload = 0
|
||||||
|
if {"treasury_payments", "treasury_balances"} <= tables:
|
||||||
|
payload = conn.execute(text(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM treasury_payments) + "
|
||||||
|
"(SELECT COUNT(*) FROM treasury_balances)")).scalar()
|
||||||
|
if payload and not args.yes:
|
||||||
|
print(f"REFUSE: 已有 {payload} 行业务数据,回滚需显式 --yes", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return down(conn)
|
||||||
|
return 0 if check(conn) is not None else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CMA 司库 · 阶段一 —— 账户池初始化(F1 两份 CSV 导入)
|
||||||
|
|
||||||
|
数据源(财务侧前置交付 F1,md5 a49ae4340c05dfa20aba7daad25cc25f 的 F1F2F3 交付件附表):
|
||||||
|
/root/.hermes/profiles/wecom-finance/reports/CMA_司库P0_F1_账户主数据清单_entity1.csv (12 行,酣客)
|
||||||
|
/root/.hermes/profiles/wecom-finance/reports/CMA_司库P0_F1_账户主数据清单_entity2.csv (12 行,博海)
|
||||||
|
表头 14 列与 PRD §3.2 treasury_accounts 逐列一致(tier_basis 不在 CSV → 走 DDL 默认 'nature')。
|
||||||
|
|
||||||
|
口径(任务单步骤 6 + G1 §5 + F1 执行建议):
|
||||||
|
* e1(entity_id=1 酣客)12 个货币资金科目;e2(entity_id=2 博海)12 个账户
|
||||||
|
* `100107 现金备用卡(唐)` → status=inactive
|
||||||
|
* `owner_node_id` 首版留空(部门写在 remark;e2 无 org_nodes,禁造虚层级)
|
||||||
|
* **排除** `1002007 榆林酣客工行存款`(属酣客系,不得入 e2;也不并入 e1——科目码体系不同)
|
||||||
|
* 期初:e2 用 F1/F2 实测值(treasury_balances,data_status='verified');
|
||||||
|
e1 分账户 open_balance 留 NULL + 头寸 estimated(F2 分账户未交付,LQ-001 skip_when=estimated)
|
||||||
|
* 金额纪律:写库前 round(x,4)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 treasury_seed_accounts.py --dry-run # 解析 CSV + 打印将写入的行(零 DB)
|
||||||
|
python3 treasury_seed_accounts.py --apply # 幂等 upsert(按 (entity_id, account_no) / (account_id, period))
|
||||||
|
python3 treasury_seed_accounts.py --verify # 只读回查:账户数/排除项/双实体交集
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import text # noqa: E402
|
||||||
|
from app.database import get_engine # noqa: E402
|
||||||
|
|
||||||
|
CSV_DIR = "/root/.hermes/profiles/wecom-finance/reports"
|
||||||
|
CSV_FILES = {
|
||||||
|
1: "CMA_司库P0_F1_账户主数据清单_entity1.csv",
|
||||||
|
2: "CMA_司库P0_F1_账户主数据清单_entity2.csv",
|
||||||
|
}
|
||||||
|
EXCLUDED_ACCOUNT_NO = "1002007" # 榆林酣客工行存款(酣客系,不得入 e2)
|
||||||
|
INACTIVE_ACCOUNT_NOS = {"100107"} # 现金备用卡(唐):首版停用
|
||||||
|
ACCOUNT_COLS = ["entity_id", "account_no", "account_name", "account_type", "bank_name",
|
||||||
|
"subject_code", "currency", "tier", "owner_node_id", "open_balance",
|
||||||
|
"open_period", "is_pool_member", "status", "remark"]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_csv(entity_id: int):
|
||||||
|
path = os.path.join(CSV_DIR, CSV_FILES[entity_id])
|
||||||
|
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||||
|
rows = list(csv.DictReader(f))
|
||||||
|
return path, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(row: dict, entity_id: int) -> dict:
|
||||||
|
def s(k):
|
||||||
|
v = (row.get(k) or "").strip()
|
||||||
|
return v or None
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"account_no": s("account_no"),
|
||||||
|
"account_name": s("account_name"),
|
||||||
|
"account_type": s("account_type") or "other",
|
||||||
|
"bank_name": s("bank_name"),
|
||||||
|
"subject_code": s("subject_code"),
|
||||||
|
"currency": s("currency") or "CNY",
|
||||||
|
"tier": s("tier") or "grassroots",
|
||||||
|
"owner_node_id": None, # 首版留空(部门写在 remark)
|
||||||
|
"open_balance": None,
|
||||||
|
"open_period": s("open_period"),
|
||||||
|
"is_pool_member": int(s("is_pool_member") or 0),
|
||||||
|
"status": s("status") or "active",
|
||||||
|
"remark": s("remark"),
|
||||||
|
}
|
||||||
|
if entity_id == 1:
|
||||||
|
out["open_balance"] = None # e1 分账户期初留 NULL(F2 分账户未交付)
|
||||||
|
else:
|
||||||
|
raw = s("open_balance")
|
||||||
|
out["open_balance"] = None if raw is None else round(float(raw), 4)
|
||||||
|
if out["account_no"] in INACTIVE_ACCOUNT_NOS:
|
||||||
|
out["status"] = "inactive"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def plan():
|
||||||
|
"""解析两份 CSV → {entity_id: [rows]},并做清单级断言。"""
|
||||||
|
data, problems = {}, []
|
||||||
|
for eid in (1, 2):
|
||||||
|
path, rows = _load_csv(eid)
|
||||||
|
norms = [_norm(r, eid) for r in rows]
|
||||||
|
nos = [r["account_no"] for r in norms]
|
||||||
|
if EXCLUDED_ACCOUNT_NO in nos:
|
||||||
|
problems.append(f"entity{eid} CSV 含被排除账户 {EXCLUDED_ACCOUNT_NO}")
|
||||||
|
if len(set(nos)) != len(nos):
|
||||||
|
problems.append(f"entity{eid} CSV 账号重复")
|
||||||
|
nos_set = set(nos)
|
||||||
|
data[eid] = {"csv": path, "rows": norms, "account_nos": sorted(nos_set)}
|
||||||
|
# 双实体账号集合必须零交集(G1 §5)
|
||||||
|
inter = set(data[1]["account_nos"]) & set(data[2]["account_nos"])
|
||||||
|
if inter:
|
||||||
|
problems.append(f"双实体账号集合交集非空: {sorted(inter)}")
|
||||||
|
if len(data[1]["rows"]) != 12 or len(data[2]["rows"]) != 12:
|
||||||
|
problems.append(f"行数异常: e1={len(data[1]['rows'])} e2={len(data[2]['rows'])}(期望 12/12)")
|
||||||
|
return data, problems
|
||||||
|
|
||||||
|
|
||||||
|
def apply(data):
|
||||||
|
engine = get_engine()
|
||||||
|
summary = {"accounts": {}, "balances": {}, "batches": {}}
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for eid, payload in data.items():
|
||||||
|
created = updated = 0
|
||||||
|
for row in payload["rows"]:
|
||||||
|
cols = ", ".join(ACCOUNT_COLS)
|
||||||
|
ph = ", ".join(":" + c for c in ACCOUNT_COLS)
|
||||||
|
upd = ", ".join(f"{c}=VALUES({c})" for c in ACCOUNT_COLS
|
||||||
|
if c not in ("entity_id", "account_no"))
|
||||||
|
found = conn.execute(text(
|
||||||
|
"SELECT id FROM treasury_accounts WHERE entity_id=:e AND account_no=:a"
|
||||||
|
), {"e": eid, "a": row["account_no"]}).fetchone()
|
||||||
|
params = dict(row)
|
||||||
|
conn.execute(text(
|
||||||
|
f"INSERT INTO treasury_accounts ({cols}) VALUES ({ph}) "
|
||||||
|
f"ON DUPLICATE KEY UPDATE {upd}"), params)
|
||||||
|
created += found is None
|
||||||
|
updated += found is not None
|
||||||
|
|
||||||
|
# e2 期初头寸(F1/F2 实测值 → verified);e1 无分账户期初 → 不写头寸(estimated)
|
||||||
|
bal_created = 0
|
||||||
|
if eid == 2:
|
||||||
|
batch = "f1-f2-e2-" + hashlib.md5(
|
||||||
|
json.dumps([(r["account_no"], r["open_balance"], r["open_period"])
|
||||||
|
for r in payload["rows"]], ensure_ascii=False).encode()).hexdigest()[:10]
|
||||||
|
for row in payload["rows"]:
|
||||||
|
if row["open_balance"] is None:
|
||||||
|
continue
|
||||||
|
acc = conn.execute(text(
|
||||||
|
"SELECT id FROM treasury_accounts WHERE entity_id=:e AND account_no=:a"
|
||||||
|
), {"e": eid, "a": row["account_no"]}).fetchone()
|
||||||
|
if not acc:
|
||||||
|
continue
|
||||||
|
exists = conn.execute(text(
|
||||||
|
"SELECT id FROM treasury_balances WHERE entity_id=:e AND account_id=:i AND period=:p"
|
||||||
|
), {"e": eid, "i": acc[0], "p": row["open_period"]}).fetchone()
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO treasury_balances (entity_id, account_id, period, balance, "
|
||||||
|
"available, frozen, source, data_status, batch) VALUES (:e,:i,:p,:b,NULL,0,"
|
||||||
|
"'import:F1-F2账户主数据清单','verified',:bt) "
|
||||||
|
"ON DUPLICATE KEY UPDATE balance=VALUES(balance), source=VALUES(source), "
|
||||||
|
"data_status=VALUES(data_status), batch=VALUES(batch)"),
|
||||||
|
{"e": eid, "i": acc[0], "p": row["open_period"],
|
||||||
|
"b": row["open_balance"], "bt": batch})
|
||||||
|
bal_created += exists is None
|
||||||
|
summary["batches"][f"entity{eid}"] = batch
|
||||||
|
summary["accounts"][f"entity{eid}"] = {"rows_in": len(payload["rows"]),
|
||||||
|
"created": created, "updated": updated}
|
||||||
|
summary["balances"][f"entity{eid}"] = {"created": bal_created}
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def verify():
|
||||||
|
"""只读回查:账户数 / 排除项 / 双实体零交集 / 100107 inactive。"""
|
||||||
|
engine = get_engine()
|
||||||
|
with engine.connect() as conn:
|
||||||
|
out = {}
|
||||||
|
rows = conn.execute(text(
|
||||||
|
"SELECT entity_id, COUNT(*) FROM treasury_accounts GROUP BY entity_id ORDER BY entity_id"
|
||||||
|
)).fetchall()
|
||||||
|
out["accounts_by_entity"] = {str(r[0]): int(r[1]) for r in rows}
|
||||||
|
e1 = {r[0] for r in conn.execute(text(
|
||||||
|
"SELECT account_no FROM treasury_accounts WHERE entity_id=1")).fetchall()}
|
||||||
|
e2 = {r[0] for r in conn.execute(text(
|
||||||
|
"SELECT account_no FROM treasury_accounts WHERE entity_id=2")).fetchall()}
|
||||||
|
out["entity1_accounts"] = sorted(e1)
|
||||||
|
out["entity2_accounts"] = sorted(e2)
|
||||||
|
out["intersection"] = sorted(e1 & e2)
|
||||||
|
out["excluded_present"] = sorted({EXCLUDED_ACCOUNT_NO} & (e1 | e2))
|
||||||
|
out["inactive_100107_status"] = conn.execute(text(
|
||||||
|
"SELECT status FROM treasury_accounts WHERE entity_id=1 AND account_no='100107'"
|
||||||
|
)).scalar()
|
||||||
|
out["open_balance_null_e1"] = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM treasury_accounts WHERE entity_id=1 AND open_balance IS NULL"
|
||||||
|
)).scalar()
|
||||||
|
out["balances_e2"] = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM treasury_balances WHERE entity_id=2")).scalar()
|
||||||
|
out["balances_e1"] = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM treasury_balances WHERE entity_id=1")).scalar()
|
||||||
|
out["balances_e2_bad_status"] = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM treasury_balances WHERE entity_id=2 AND data_status<>'verified'"
|
||||||
|
)).scalar()
|
||||||
|
out["owner_node_id_nonnull"] = conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM treasury_accounts WHERE owner_node_id IS NOT NULL")).scalar()
|
||||||
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
g = ap.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--dry-run", action="store_true")
|
||||||
|
g.add_argument("--apply", action="store_true")
|
||||||
|
g.add_argument("--verify", action="store_true", help="只读回查(默认)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.verify or not (args.dry_run or args.apply):
|
||||||
|
verify()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
data, problems = plan()
|
||||||
|
plan_out = {f"entity{e}": {"csv": data[e]["csv"], "rows": len(data[e]["rows"]),
|
||||||
|
"account_nos": data[e]["account_nos"]} for e in (1, 2)}
|
||||||
|
if problems:
|
||||||
|
print(json.dumps({"problems": problems, "plan": plan_out}, ensure_ascii=False, indent=2))
|
||||||
|
return 3
|
||||||
|
if args.dry_run:
|
||||||
|
print(json.dumps({"plan": plan_out,
|
||||||
|
"sample_entity1": data[1]["rows"][:2],
|
||||||
|
"sample_entity2": data[2]["rows"][:2]}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
summary = apply(data)
|
||||||
|
print(json.dumps({"applied": summary, "plan": plan_out}, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -493,4 +493,36 @@ export const aiSuggestionApi = {
|
|||||||
preview: (id: number) => api.get(`/ai/suggestions/${id}/preview`),
|
preview: (id: number) => api.get(`/ai/suggestions/${id}/preview`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 资金管理(账户池/头寸/支付内控 2026-09-15)──
|
||||||
|
export const treasuryApi = {
|
||||||
|
// 账户池
|
||||||
|
listAccounts: (params?: any) => api.get('/treasury/accounts', { params }),
|
||||||
|
createAccount: (data: any) => api.post('/treasury/accounts', data),
|
||||||
|
updateAccount: (id: number, data: any) => api.put(`/treasury/accounts/${id}`, data),
|
||||||
|
deleteAccount: (id: number) => api.delete(`/treasury/accounts/${id}`),
|
||||||
|
importAccounts: (rows: any[]) => api.post('/treasury/accounts/import', rows),
|
||||||
|
// 头寸
|
||||||
|
listBalances: (params?: any) => api.get('/treasury/balances', { params }),
|
||||||
|
upsertBalance: (data: any) => api.post('/treasury/balances', data),
|
||||||
|
importBalances: (rows: any[]) => api.post('/treasury/balances/import', rows),
|
||||||
|
positionsSummary: (params?: any) => api.get('/treasury/positions/summary', { params }),
|
||||||
|
dashboard: () => api.get('/treasury/dashboard'),
|
||||||
|
// 规则库 / 授权矩阵
|
||||||
|
listRules: (params?: any) => api.get('/treasury/control-rules', { params }),
|
||||||
|
updateRule: (id: number, data: any) => api.put(`/treasury/control-rules/${id}`, data),
|
||||||
|
testRules: (data: any) => api.post('/treasury/control-rules/test', data),
|
||||||
|
listLimits: (params?: any) => api.get('/treasury/limits', { params }),
|
||||||
|
updateLimit: (id: number, data: any) => api.put(`/treasury/limits/${id}`, data),
|
||||||
|
// 支付单
|
||||||
|
listPayments: (params?: any) => api.get('/treasury/payments', { params }),
|
||||||
|
createPayment: (data: any) => api.post('/treasury/payments', data),
|
||||||
|
updatePayment: (id: number, data: any) => api.put(`/treasury/payments/${id}`, data),
|
||||||
|
precheckPayment: (id: number) => api.post(`/treasury/payments/${id}/precheck`),
|
||||||
|
approvePayment: (id: number, data: any) => api.post(`/treasury/payments/${id}/approve`, data),
|
||||||
|
payPayment: (id: number) => api.post(`/treasury/payments/${id}/pay`),
|
||||||
|
waivePayment: (id: number, data: any) => api.post(`/treasury/payments/${id}/waive`, data),
|
||||||
|
// 规则命中留痕
|
||||||
|
listHits: (params?: any) => api.get('/treasury/control-hits', { params }),
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
|||||||
|
|
||||||
// ── 角色路由映射 ──
|
// ── 角色路由映射 ──
|
||||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||||
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure'],
|
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||||
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure'],
|
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||||
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
|
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||||
@@ -36,6 +36,8 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||||
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||||
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
|
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
|
||||||
|
{ path: '/treasury-dashboard', label: '资金总览', icon: 'Money', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||||
|
{ path: '/treasury-payment', label: '资金内控', icon: 'Lock', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||||
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const routes = [
|
|||||||
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||||
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||||
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } },
|
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } },
|
||||||
|
{ path: 'treasury-dashboard', name: 'TreasuryDashboard', component: () => import('@/views/TreasuryDashboard.vue'), meta: { title: '资金总览', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||||
|
{ path: 'treasury-payment', name: 'TreasuryPayment', component: () => import('@/views/TreasuryPayment.vue'), meta: { title: '资金内控', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||||
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||||
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
|
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<template>
|
||||||
|
<div class="treasury-dashboard-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>💰 资金总览</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-button type="primary" size="small" @click="loadAll">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 汇总卡片 -->
|
||||||
|
<el-row :gutter="16" class="stat-row">
|
||||||
|
<el-card shadow="never" class="stat-card blue">
|
||||||
|
<div class="stat-val">{{ dash.accounts?.total ?? 0 }}</div>
|
||||||
|
<div class="stat-label">账户数(启用 {{ dash.accounts?.active ?? 0 }})</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="stat-card green">
|
||||||
|
<div class="stat-val">¥{{ (dash.balance_total ?? 0).toFixed(2) }}万</div>
|
||||||
|
<div class="stat-label">头寸合计</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="stat-card orange">
|
||||||
|
<div class="stat-val">{{ dash.positions?.verified_accounts ?? 0 }} / {{ dash.positions?.estimated_accounts ?? 0 }}</div>
|
||||||
|
<div class="stat-label">已核 / 估算账户</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="stat-card blue">
|
||||||
|
<div class="stat-val">{{ dash.payments?.pending_approval ?? 0 }}</div>
|
||||||
|
<div class="stat-label">待批支付单</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" class="stat-card red">
|
||||||
|
<div class="stat-val">{{ dash.hits?.blocked ?? 0 }}</div>
|
||||||
|
<div class="stat-label">Block 命中数</div>
|
||||||
|
</el-card>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" style="margin-top:16px">
|
||||||
|
<!-- ══ Tab 1 账户池 ══ -->
|
||||||
|
<el-tab-pane label="账户池" name="accounts">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="accFilter.status" size="small" clearable placeholder="状态" style="width:110px">
|
||||||
|
<el-option value="active" label="启用" />
|
||||||
|
<el-option value="inactive" label="停用" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="accFilter.tier" size="small" clearable placeholder="层级" style="width:110px">
|
||||||
|
<el-option value="hq" label="总部" />
|
||||||
|
<el-option value="platform" label="平台" />
|
||||||
|
<el-option value="grassroots" label="基层" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="accFilter.account_type" size="small" clearable placeholder="账户类型" style="width:120px" />
|
||||||
|
<el-input v-model="accFilter.keyword" size="small" clearable placeholder="账号/名称关键字" style="width:160px" />
|
||||||
|
<el-button type="primary" size="small" @click="loadAccounts">查询</el-button>
|
||||||
|
<el-button size="small" @click="resetAccFilter">重置</el-button>
|
||||||
|
<el-button type="success" size="small" @click="openAccountDialog(null)">新增账户</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="accounts" size="small" v-loading="accountsLoading" style="margin-top:8px">
|
||||||
|
<el-table-column prop="account_no" label="账号" min-width="130" />
|
||||||
|
<el-table-column prop="account_name" label="账户名" min-width="140" />
|
||||||
|
<el-table-column prop="account_type" label="类型" width="100" />
|
||||||
|
<el-table-column prop="bank_name" label="银行" min-width="120" />
|
||||||
|
<el-table-column prop="subject_code" label="科目" width="90" />
|
||||||
|
<el-table-column prop="tier" label="层级" width="80">
|
||||||
|
<template #default="{ row }">{{ tierLabel(row.tier) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.status === 'active' ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="期初(万)" width="110">
|
||||||
|
<template #default="{ row }">¥{{ (row.open_balance ?? 0).toFixed(2) }}({{ row.open_period || '-' }})</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" size="small" @click="openAccountDialog(row)">编辑</el-button>
|
||||||
|
<el-button :type="row.status === 'active' ? 'warning' : 'success'" size="small" @click="toggleAccount(row)">
|
||||||
|
{{ row.status === 'active' ? '停用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="removeAccount(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ══ Tab 2 头寸 ══ -->
|
||||||
|
<el-tab-pane label="头寸" name="balances">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="balFilter.account_id" size="small" clearable filterable placeholder="选择账户" style="width:220px">
|
||||||
|
<el-option v-for="a in accounts" :key="a.id" :value="a.id" :label="`${a.account_no} ${a.account_name}`" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker v-model="balFilter.period_from" size="small" type="month" value-format="YYYY-MM" placeholder="期间起" style="width:130px" />
|
||||||
|
<el-date-picker v-model="balFilter.period_to" size="small" type="month" value-format="YYYY-MM" placeholder="期间止" style="width:130px" />
|
||||||
|
<el-select v-model="balFilter.data_status" size="small" clearable placeholder="核验状态" style="width:110px">
|
||||||
|
<el-option value="verified" label="已核" />
|
||||||
|
<el-option value="estimated" label="估算" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" @click="loadBalances">查询</el-button>
|
||||||
|
<el-button type="success" size="small" @click="openBalanceDialog">录入头寸</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="balances" size="small" v-loading="balancesLoading" style="margin-top:8px">
|
||||||
|
<el-table-column label="账户" min-width="180">
|
||||||
|
<template #default="{ row }">{{ accountLabel(row.account_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="period" label="期间" width="90" />
|
||||||
|
<el-table-column label="余额(万)" width="110">
|
||||||
|
<template #default="{ row }"><span style="font-weight:600">¥{{ (row.balance ?? 0).toFixed(2) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="可用(万)" width="110">
|
||||||
|
<template #default="{ row }">¥{{ (row.available ?? 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="冻结(万)" width="110">
|
||||||
|
<template #default="{ row }">¥{{ (row.frozen ?? 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="source" label="来源" width="90" />
|
||||||
|
<el-table-column prop="data_status" label="核验状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.data_status === 'verified'" type="success" size="small">已核</el-tag>
|
||||||
|
<el-tag v-else-if="row.data_status === 'estimated'" type="warning" size="small">估算</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">{{ row.data_status || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="batch" label="批次" width="90" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ══ Tab 3 待办与命中 ══ -->
|
||||||
|
<el-tab-pane label="待办与命中" name="todos">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>待审批支付单({{ pendingPayments.length }})</template>
|
||||||
|
<el-table :data="pendingPayments" size="small" v-loading="todosLoading">
|
||||||
|
<el-table-column prop="pay_no" label="单号" width="130" />
|
||||||
|
<el-table-column prop="to_name" label="收款方" min-width="110" />
|
||||||
|
<el-table-column label="金额(万)" width="90">
|
||||||
|
<template #default="{ row }">¥{{ (row.amount ?? 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="110">
|
||||||
|
<template #default>
|
||||||
|
<el-button type="primary" size="small" @click="goPayment">去处理</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>拦截支付单({{ blockedPayments.length }})</template>
|
||||||
|
<el-table :data="blockedPayments" size="small" v-loading="todosLoading">
|
||||||
|
<el-table-column prop="pay_no" label="单号" width="130" />
|
||||||
|
<el-table-column prop="to_name" label="收款方" min-width="110" />
|
||||||
|
<el-table-column label="金额(万)" width="90">
|
||||||
|
<template #default="{ row }">¥{{ (row.amount ?? 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="110">
|
||||||
|
<template #default>
|
||||||
|
<el-button type="danger" size="small" @click="goPayment">去豁免</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-card shadow="never" style="margin-top:16px">
|
||||||
|
<template #header>规则命中留痕(最近 20 条)</template>
|
||||||
|
<el-table :data="hits" size="small" v-loading="todosLoading">
|
||||||
|
<el-table-column prop="rule_code" label="规则码" width="100" />
|
||||||
|
<el-table-column prop="verdict" label="判定" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="verdictType(row.verdict)" size="small">{{ verdictLabel(row.verdict) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="对象" width="110">
|
||||||
|
<template #default="{ row }">{{ row.target_type }}#{{ row.target_id }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="150">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="摘要" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.waived_reason || row.detail?.verdict_basis || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 账户编辑弹窗 -->
|
||||||
|
<el-dialog v-model="accountDialogVisible" :title="accountForm.id ? '编辑账户' : '新增账户'" width="520px" append-to-body>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="账号" required>
|
||||||
|
<el-input v-model="accountForm.account_no" placeholder="银行账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="账户名" required>
|
||||||
|
<el-input v-model="accountForm.account_name" placeholder="账户名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" required>
|
||||||
|
<el-input v-model="accountForm.account_type" placeholder="如 基本户/一般户/专户" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="层级" required>
|
||||||
|
<el-select v-model="accountForm.tier" style="width:100%">
|
||||||
|
<el-option value="hq" label="总部" />
|
||||||
|
<el-option value="platform" label="平台" />
|
||||||
|
<el-option value="grassroots" label="基层" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="银行">
|
||||||
|
<el-input v-model="accountForm.bank_name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="科目">
|
||||||
|
<el-input v-model="accountForm.subject_code" placeholder="会计科目编码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="币种">
|
||||||
|
<el-input v-model="accountForm.currency" placeholder="CNY" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="期初余额">
|
||||||
|
<el-input-number v-model="accountForm.open_balance" :min="0" :precision="2" style="width:160px" />
|
||||||
|
<span style="margin-left:6px;color:#888">万元</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="期初期间">
|
||||||
|
<el-date-picker v-model="accountForm.open_period" type="month" value-format="YYYY-MM" placeholder="期初所属期间" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="accountForm.status" style="width:100%">
|
||||||
|
<el-option value="active" label="启用" />
|
||||||
|
<el-option value="inactive" label="停用" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="accountForm.remark" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="accountDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="accountSaving" @click="saveAccount">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 头寸录入弹窗 -->
|
||||||
|
<el-dialog v-model="balanceDialogVisible" title="录入头寸" width="480px" append-to-body>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="账户" required>
|
||||||
|
<el-select v-model="balanceForm.account_id" filterable style="width:100%" placeholder="选择账户">
|
||||||
|
<el-option v-for="a in accounts" :key="a.id" :value="a.id" :label="`${a.account_no} ${a.account_name}`" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="期间" required>
|
||||||
|
<el-date-picker v-model="balanceForm.period" type="month" value-format="YYYY-MM" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="余额" required>
|
||||||
|
<el-input-number v-model="balanceForm.balance" :min="0" :precision="2" style="width:160px" />
|
||||||
|
<span style="margin-left:6px;color:#888">万元</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="可用余额">
|
||||||
|
<el-input-number v-model="balanceForm.available" :min="0" :precision="2" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="冻结金额">
|
||||||
|
<el-input-number v-model="balanceForm.frozen" :min="0" :precision="2" style="width:160px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="来源">
|
||||||
|
<el-input v-model="balanceForm.source" placeholder="manual/import/网银流水" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="核验状态">
|
||||||
|
<el-select v-model="balanceForm.data_status" style="width:100%">
|
||||||
|
<el-option value="estimated" label="估算(期初未落定)" />
|
||||||
|
<el-option value="verified" label="已核(有流水佐证)" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="balanceDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="balanceSaving" @click="saveBalance">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import api from '@/api'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const activeTab = ref('accounts')
|
||||||
|
|
||||||
|
const dash = ref<any>({})
|
||||||
|
const accounts = ref<any[]>([])
|
||||||
|
const accountsLoading = ref(false)
|
||||||
|
const balances = ref<any[]>([])
|
||||||
|
const balancesLoading = ref(false)
|
||||||
|
const pendingPayments = ref<any[]>([])
|
||||||
|
const blockedPayments = ref<any[]>([])
|
||||||
|
const hits = ref<any[]>([])
|
||||||
|
const todosLoading = ref(false)
|
||||||
|
|
||||||
|
const accFilter = ref<any>({ status: '', tier: '', account_type: '', keyword: '' })
|
||||||
|
const balFilter = ref<any>({ account_id: null, period_from: '', period_to: '', data_status: '' })
|
||||||
|
|
||||||
|
const accountDialogVisible = ref(false)
|
||||||
|
const accountSaving = ref(false)
|
||||||
|
const accountForm = ref<any>({})
|
||||||
|
const balanceDialogVisible = ref(false)
|
||||||
|
const balanceSaving = ref(false)
|
||||||
|
const balanceForm = ref<any>({})
|
||||||
|
|
||||||
|
function errDetail(e: any, fallback: string): string {
|
||||||
|
return e?.response?.data?.detail || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierLabel(v: string): string {
|
||||||
|
return ({ hq: '总部', platform: '平台', grassroots: '基层' } as any)[v] || v || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdictLabel(v: string): string {
|
||||||
|
return ({ pass: '通过', warn: '警告', blocked: '拦截', waived: '豁免' } as any)[v] || v || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdictType(v: string): any {
|
||||||
|
return ({ pass: 'success', warn: 'warning', blocked: 'danger', waived: 'info' } as any)[v] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v: any): string {
|
||||||
|
if (!v) return '-'
|
||||||
|
return String(v).replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(id: number): string {
|
||||||
|
const a = accounts.value.find((x: any) => x.id === id)
|
||||||
|
return a ? `${a.account_no} ${a.account_name}` : `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
dash.value = await api.get('/treasury/dashboard')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载资金总览失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
accountsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (accFilter.value.status) params.status = accFilter.value.status
|
||||||
|
if (accFilter.value.tier) params.tier = accFilter.value.tier
|
||||||
|
if (accFilter.value.account_type) params.account_type = accFilter.value.account_type
|
||||||
|
if (accFilter.value.keyword) params.keyword = accFilter.value.keyword
|
||||||
|
const r: any = await api.get('/treasury/accounts', { params })
|
||||||
|
accounts.value = r.items || r || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载账户池失败'))
|
||||||
|
} finally {
|
||||||
|
accountsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAccFilter() {
|
||||||
|
accFilter.value = { status: '', tier: '', account_type: '', keyword: '' }
|
||||||
|
loadAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBalances() {
|
||||||
|
balancesLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (balFilter.value.account_id) params.account_id = balFilter.value.account_id
|
||||||
|
if (balFilter.value.period_from) params.period_from = balFilter.value.period_from
|
||||||
|
if (balFilter.value.period_to) params.period_to = balFilter.value.period_to
|
||||||
|
if (balFilter.value.data_status) params.data_status = balFilter.value.data_status
|
||||||
|
const r: any = await api.get('/treasury/balances', { params })
|
||||||
|
balances.value = r.items || r || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载头寸失败'))
|
||||||
|
} finally {
|
||||||
|
balancesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTodos() {
|
||||||
|
todosLoading.value = true
|
||||||
|
try {
|
||||||
|
const [p1, p2, p3]: any[] = await Promise.all([
|
||||||
|
api.get('/treasury/payments', { params: { status: 'pending_approval' } }),
|
||||||
|
api.get('/treasury/payments', { params: { check_result: 'blocked' } }),
|
||||||
|
api.get('/treasury/control-hits', { params: { limit: 20 } }),
|
||||||
|
])
|
||||||
|
pendingPayments.value = p1?.items || p1 || []
|
||||||
|
blockedPayments.value = (p2?.items || p2 || []).filter((x: any) => !x.is_waived)
|
||||||
|
hits.value = p3?.items || p3 || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载待办失败'))
|
||||||
|
} finally {
|
||||||
|
todosLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
loadDashboard()
|
||||||
|
loadAccounts()
|
||||||
|
if (activeTab.value === 'balances') loadBalances()
|
||||||
|
if (activeTab.value === 'todos') loadTodos()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPayment() {
|
||||||
|
router.push('/treasury-payment')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 账户 CRUD ──
|
||||||
|
function openAccountDialog(row: any) {
|
||||||
|
accountForm.value = row
|
||||||
|
? {
|
||||||
|
id: row.id, account_no: row.account_no, account_name: row.account_name,
|
||||||
|
account_type: row.account_type, tier: row.tier, bank_name: row.bank_name,
|
||||||
|
subject_code: row.subject_code, currency: row.currency || 'CNY',
|
||||||
|
open_balance: row.open_balance, open_period: row.open_period,
|
||||||
|
status: row.status || 'active', remark: row.remark,
|
||||||
|
}
|
||||||
|
: { id: null, account_no: '', account_name: '', account_type: '', tier: 'hq', bank_name: '', subject_code: '', currency: 'CNY', open_balance: 0, open_period: '', status: 'active', remark: '' }
|
||||||
|
accountDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
const f = accountForm.value
|
||||||
|
if (!f.account_no || !f.account_name || !f.account_type || !f.tier) {
|
||||||
|
ElMessage.warning('账号/账户名/类型/层级为必填项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
account_no: f.account_no, account_name: f.account_name, account_type: f.account_type,
|
||||||
|
tier: f.tier, bank_name: f.bank_name, subject_code: f.subject_code, currency: f.currency || 'CNY',
|
||||||
|
open_balance: f.open_balance, open_period: f.open_period, status: f.status || 'active', remark: f.remark,
|
||||||
|
}
|
||||||
|
if (f.id) {
|
||||||
|
await api.put(`/treasury/accounts/${f.id}`, payload)
|
||||||
|
ElMessage.success('账户已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/treasury/accounts', payload)
|
||||||
|
ElMessage.success('账户已创建')
|
||||||
|
}
|
||||||
|
accountDialogVisible.value = false
|
||||||
|
loadAccounts()
|
||||||
|
loadDashboard()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '保存账户失败'))
|
||||||
|
} finally {
|
||||||
|
accountSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAccount(row: any) {
|
||||||
|
const next = row.status === 'active' ? 'inactive' : 'active'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认将账户「${row.account_no} ${row.account_name}」${next === 'active' ? '启用' : '停用'}?`, '提示', { type: 'warning' })
|
||||||
|
await api.put(`/treasury/accounts/${row.id}`, {
|
||||||
|
account_no: row.account_no, account_name: row.account_name, account_type: row.account_type,
|
||||||
|
tier: row.tier, bank_name: row.bank_name, subject_code: row.subject_code, currency: row.currency || 'CNY',
|
||||||
|
open_balance: row.open_balance, open_period: row.open_period, status: next, remark: row.remark,
|
||||||
|
})
|
||||||
|
ElMessage.success(next === 'active' ? '账户已启用' : '账户已停用')
|
||||||
|
loadAccounts()
|
||||||
|
loadDashboard()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e === 'cancel' || e === 'close') return
|
||||||
|
ElMessage.error(errDetail(e, '操作失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAccount(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除账户「${row.account_no} ${row.account_name}」?该操作不可恢复。`, '警告', { type: 'error' })
|
||||||
|
await api.delete(`/treasury/accounts/${row.id}`)
|
||||||
|
ElMessage.success('账户已删除')
|
||||||
|
loadAccounts()
|
||||||
|
loadDashboard()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e === 'cancel' || e === 'close') return
|
||||||
|
ElMessage.error(errDetail(e, '删除账户失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 头寸录入 ──
|
||||||
|
function openBalanceDialog() {
|
||||||
|
balanceForm.value = { account_id: null, period: '', balance: 0, available: undefined, frozen: undefined, source: 'manual', data_status: 'estimated' }
|
||||||
|
balanceDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBalance() {
|
||||||
|
const f = balanceForm.value
|
||||||
|
if (!f.account_id || !f.period) {
|
||||||
|
ElMessage.warning('账户与期间为必填项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
balanceSaving.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/treasury/balances', {
|
||||||
|
account_id: f.account_id, period: f.period, balance: f.balance,
|
||||||
|
available: f.available, frozen: f.frozen, source: f.source || 'manual',
|
||||||
|
data_status: f.data_status || 'estimated',
|
||||||
|
})
|
||||||
|
ElMessage.success('头寸已录入')
|
||||||
|
balanceDialogVisible.value = false
|
||||||
|
loadBalances()
|
||||||
|
loadDashboard()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '录入头寸失败'))
|
||||||
|
} finally {
|
||||||
|
balanceSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDashboard()
|
||||||
|
loadAccounts()
|
||||||
|
loadBalances()
|
||||||
|
loadTodos()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.treasury-dashboard-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.header-right { display: flex; gap: 8px; }
|
||||||
|
.stat-row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.stat-card { flex: 1; min-width: 180px; text-align: center; }
|
||||||
|
.stat-val { font-size: 24px; font-weight: 700; }
|
||||||
|
.stat-label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||||
|
.red .stat-val { color: #f56c6c; }
|
||||||
|
.orange .stat-val { color: #e6a23c; }
|
||||||
|
.green .stat-val { color: #67c23a; }
|
||||||
|
.blue .stat-val { color: #409eff; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
<template>
|
||||||
|
<div class="treasury-payment-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>🔒 资金内控</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-button type="primary" size="small" @click="loadAll">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<!-- ══ Tab 1 支付单 ══ -->
|
||||||
|
<el-tab-pane label="支付单" name="payments">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="payFilter.status" size="small" clearable placeholder="状态" style="width:120px">
|
||||||
|
<el-option value="draft" label="草稿" />
|
||||||
|
<el-option value="precheck" label="已拦截" />
|
||||||
|
<el-option value="pending_approval" label="待审批" />
|
||||||
|
<el-option value="approved" label="已放行" />
|
||||||
|
<el-option value="paid" label="已支付" />
|
||||||
|
<el-option value="rejected" label="已驳回" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="payFilter.check_result" size="small" clearable placeholder="预检结果" style="width:120px">
|
||||||
|
<el-option value="pass" label="通过" />
|
||||||
|
<el-option value="warn" label="警告" />
|
||||||
|
<el-option value="blocked" label="拦截" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" @click="loadPayments">查询</el-button>
|
||||||
|
<el-button size="small" @click="payFilter = { status: '', check_result: '' }">重置</el-button>
|
||||||
|
<el-button type="success" size="small" @click="openPayDialog(null)">新建支付单</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="payments" size="small" v-loading="paymentsLoading" style="margin-top:8px">
|
||||||
|
<el-table-column prop="pay_no" label="单号" width="140" />
|
||||||
|
<el-table-column label="付款账户" min-width="170">
|
||||||
|
<template #default="{ row }">{{ accountLabel(row.from_account_id) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="to_name" label="收款方" min-width="120" />
|
||||||
|
<el-table-column label="金额(万)" width="100">
|
||||||
|
<template #default="{ row }"><span style="font-weight:600">¥{{ (row.amount ?? 0).toFixed(2) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="purpose" label="用途" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="pay_type" label="类型" width="80" />
|
||||||
|
<el-table-column prop="pay_date" label="付款日期" width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="check_result" label="预检" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.check_result" :type="checkType(row.check_result)" size="small">{{ checkLabel(row.check_result) }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="双签" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.dual_sign_effective === 1" type="success" size="small">双签有效</el-tag>
|
||||||
|
<el-tag v-else-if="row.dual_sign_effective === 0" type="danger" size="small">形式双签</el-tag>
|
||||||
|
<el-tag v-else-if="(row.required_approvals ?? 0) >= 2" type="info" size="small">待双签</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="submitter" label="提交人" width="90" />
|
||||||
|
<el-table-column label="操作" width="210" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.status === 'draft'">
|
||||||
|
<el-button type="primary" size="small" @click="doPrecheck(row)">预检</el-button>
|
||||||
|
<el-button size="small" @click="openPayDialog(row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.status === 'precheck'">
|
||||||
|
<el-button type="primary" size="small" @click="doPrecheck(row)">预检</el-button>
|
||||||
|
<el-button v-if="row.check_result === 'blocked'" type="warning" size="small" @click="openWaive(row)">豁免</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.status === 'pending_approval'">
|
||||||
|
<el-button type="primary" size="small" @click="openApprove(row)">审批</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.status === 'approved'">
|
||||||
|
<el-button type="success" size="small" @click="doPay(row)">登记支付</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="row.status === 'paid'" style="color:#bbb;font-size:12px">只读</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ══ Tab 2 规则命中留痕 ══ -->
|
||||||
|
<el-tab-pane label="规则命中留痕" name="hits">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input v-model="hitsFilter.rule_code" size="small" clearable placeholder="规则码" style="width:120px" />
|
||||||
|
<el-select v-model="hitsFilter.verdict" size="small" clearable placeholder="判定" style="width:110px">
|
||||||
|
<el-option value="blocked" label="拦截" />
|
||||||
|
<el-option value="warn" label="警告" />
|
||||||
|
<el-option value="waived" label="豁免" />
|
||||||
|
<el-option value="pass" label="通过" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" @click="loadHits">查询</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="hits" size="small" v-loading="hitsLoading" style="margin-top:8px">
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="hit-detail">
|
||||||
|
<div v-for="(v, k) in row.detail || {}" :key="k" class="hit-detail-row">
|
||||||
|
<span class="hit-detail-key">{{ k }}</span>
|
||||||
|
<span class="hit-detail-val">{{ typeof v === 'object' ? JSON.stringify(v) : v }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!row.detail || !Object.keys(row.detail).length">无结构化明细</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="150">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="对象" width="110">
|
||||||
|
<template #default="{ row }">{{ row.target_type }}#{{ row.target_id }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="rule_code" label="规则码" width="100" />
|
||||||
|
<el-table-column prop="verdict" label="判定" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.verdict === 'blocked'" type="danger" size="small">拦截</el-tag>
|
||||||
|
<el-tag v-else-if="row.verdict === 'warn'" type="warning" size="small">警告</el-tag>
|
||||||
|
<el-tag v-else-if="row.verdict === 'waived'" size="small" style="background:#f9f0ff;color:#722ed1;border-color:#d3adf7">豁免</el-tag>
|
||||||
|
<el-tag v-else-if="row.verdict === 'pass'" type="success" size="small">通过</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">{{ row.verdict }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="waived_by" label="豁免人" width="90" />
|
||||||
|
<el-table-column label="摘要" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.waived_reason || row.detail?.verdict_basis || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ══ Tab 3 规则库 / 授权矩阵 ══ -->
|
||||||
|
<el-tab-pane label="规则库 / 授权矩阵" name="rules">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>规则库(启用/停用即时生效,params 原样回传)</template>
|
||||||
|
<el-table :data="rules" size="small" v-loading="rulesLoading">
|
||||||
|
<el-table-column prop="rule_code" label="规则码" width="90" />
|
||||||
|
<el-table-column prop="rule_name" label="规则名" min-width="160" />
|
||||||
|
<el-table-column prop="rule_type" label="类型" width="90" />
|
||||||
|
<el-table-column prop="severity" label="严重度" width="80" />
|
||||||
|
<el-table-column prop="rule_version" label="版本" width="70" />
|
||||||
|
<el-table-column label="参数" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="JSON.stringify(row.params || {})" placement="top">
|
||||||
|
<span class="params-preview">{{ JSON.stringify(row.params || {}) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="启用" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch :model-value="row.enabled === 1" @change="(v: any) => toggleRule(row, v)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
<el-card shadow="never" style="margin-top:16px">
|
||||||
|
<template #header>授权矩阵(金额段为左闭右开 [from, to),单位万元)</template>
|
||||||
|
<el-table :data="limits" size="small" v-loading="rulesLoading">
|
||||||
|
<el-table-column prop="scope_type" label="范围类型" width="100" />
|
||||||
|
<el-table-column prop="scope_value" label="范围值" width="100" />
|
||||||
|
<el-table-column label="金额段(万)" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.amount_to != null ? `[${(row.amount_from ?? 0).toFixed(2)}, ${(row.amount_to ?? 0).toFixed(2)})` : `${(row.amount_from ?? 0).toFixed(2)} 及以上` }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="所需审批级数" width="110">
|
||||||
|
<template #default="{ row }">{{ row.required_approvals }} 级</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="审批角色" min-width="130">
|
||||||
|
<template #default="{ row }">{{ (row.approver_roles || []).join('、') || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="需预算依据" width="90">
|
||||||
|
<template #default="{ row }">{{ row.require_budget ? '是' : '否' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="日累计限额(万)" width="120">
|
||||||
|
<template #default="{ row }">{{ row.daily_cum_limit != null ? (row.daily_cum_limit ?? 0).toFixed(2) : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 新建/编辑支付单弹窗 -->
|
||||||
|
<el-dialog v-model="payDialog.visible" :title="payDialog.mode === 'create' ? '新建支付单' : '编辑支付单'" width="520px" append-to-body>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="付款账户" required>
|
||||||
|
<el-select v-model="payForm.from_account_id" filterable style="width:100%" placeholder="仅列出启用账户">
|
||||||
|
<el-option v-for="a in activeAccounts" :key="a.id" :value="a.id" :label="`${a.account_no} ${a.account_name}`" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="收款方" required>
|
||||||
|
<el-input v-model="payForm.to_name" placeholder="收款单位/个人名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="收款账号">
|
||||||
|
<el-input v-model="payForm.to_account_no" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="金额" required>
|
||||||
|
<el-input-number v-model="payForm.amount" :min="0" :precision="2" style="width:160px" />
|
||||||
|
<span style="margin-left:6px;color:#888">万元</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用途" required>
|
||||||
|
<el-input v-model="payForm.purpose" type="textarea" :rows="2" placeholder="支付用途(必填)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="付款类型">
|
||||||
|
<el-input v-model="payForm.pay_type" placeholder="如 货款/服务费/工资社保/税费/报销" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="付款日期">
|
||||||
|
<el-date-picker v-model="payForm.pay_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="提交人">
|
||||||
|
<el-input v-model="payForm.submitter" placeholder="默认当前用户" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="payDialog.visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="payDialog.saving" @click="savePayment">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 审批弹窗 -->
|
||||||
|
<el-dialog v-model="approveVisible" title="审批支付单" width="460px" append-to-body>
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="单号">{{ currentRow?.pay_no }}</el-form-item>
|
||||||
|
<el-form-item label="收款方">{{ currentRow?.to_name }}</el-form-item>
|
||||||
|
<el-form-item label="金额">¥{{ (currentRow?.amount ?? 0).toFixed(2) }}万</el-form-item>
|
||||||
|
<el-form-item label="所需审批">{{ currentRow?.required_approvals ?? 1 }} 级(双签需不同自然人)</el-form-item>
|
||||||
|
<el-form-item label="审批备注">
|
||||||
|
<el-input v-model="approveComment" type="textarea" :rows="3" placeholder="审批意见" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="approveVisible = false">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="approving" @click="submitApprove(false)">驳回</el-button>
|
||||||
|
<el-button type="primary" :loading="approving" @click="submitApprove(true)">通过</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 豁免弹窗 -->
|
||||||
|
<el-dialog v-model="waiveVisible" title="豁免放行" width="460px" append-to-body>
|
||||||
|
<el-alert type="warning" :closable="false" style="margin-bottom:12px"
|
||||||
|
title="豁免通道仅对被 block 规则拦截的支付单开放,全程留痕,豁免级别不得低于该笔所需审批级数。" />
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="单号">{{ currentRow?.pay_no }}</el-form-item>
|
||||||
|
<el-form-item label="金额">¥{{ (currentRow?.amount ?? 0).toFixed(2) }}万</el-form-item>
|
||||||
|
<el-form-item label="豁免审批人" required>
|
||||||
|
<el-input v-model="waivedBy" placeholder="系统用户姓名或账号(≥2 级审批需 ceo)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="豁免事由" required>
|
||||||
|
<el-input v-model="waivedReason" type="textarea" :rows="3" placeholder="必填,不少于 4 字" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="waiveVisible = false">取消</el-button>
|
||||||
|
<el-button type="warning" :loading="waiving" @click="submitWaive">确认豁免</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const activeTab = ref('payments')
|
||||||
|
|
||||||
|
const payments = ref<any[]>([])
|
||||||
|
const paymentsLoading = ref(false)
|
||||||
|
const payFilter = ref<any>({ status: '', check_result: '' })
|
||||||
|
const accounts = ref<any[]>([])
|
||||||
|
const activeAccounts = computed(() => accounts.value.filter((a: any) => a.status === 'active'))
|
||||||
|
|
||||||
|
const hits = ref<any[]>([])
|
||||||
|
const hitsLoading = ref(false)
|
||||||
|
const hitsFilter = ref<any>({ rule_code: '', verdict: '' })
|
||||||
|
|
||||||
|
const rules = ref<any[]>([])
|
||||||
|
const limits = ref<any[]>([])
|
||||||
|
const rulesLoading = ref(false)
|
||||||
|
|
||||||
|
// 支付单弹窗
|
||||||
|
const payDialog = ref<any>({ visible: false, mode: 'create', saving: false })
|
||||||
|
const payForm = ref<any>({})
|
||||||
|
// 审批弹窗
|
||||||
|
const approveVisible = ref(false)
|
||||||
|
const approveComment = ref('')
|
||||||
|
const approving = ref(false)
|
||||||
|
const currentRow = ref<any>(null)
|
||||||
|
// 豁免弹窗
|
||||||
|
const waiveVisible = ref(false)
|
||||||
|
const waivedBy = ref('')
|
||||||
|
const waivedReason = ref('')
|
||||||
|
const waiving = ref(false)
|
||||||
|
|
||||||
|
function errDetail(e: any, fallback: string): string {
|
||||||
|
return e?.response?.data?.detail || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(v: string): string {
|
||||||
|
return ({ draft: '草稿', precheck: '已拦截', pending_approval: '待审批', approved: '已放行', paid: '已支付', rejected: '已驳回', cancelled: '已取消' } as any)[v] || v || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(v: string): any {
|
||||||
|
return ({ draft: 'info', precheck: 'danger', pending_approval: 'warning', approved: 'success', paid: 'success', rejected: 'info', cancelled: 'info' } as any)[v] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLabel(v: string): string {
|
||||||
|
return ({ pass: '通过', warn: '警告', blocked: '拦截' } as any)[v] || v || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkType(v: string): any {
|
||||||
|
return ({ pass: 'success', warn: 'warning', blocked: 'danger' } as any)[v] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v: any): string {
|
||||||
|
if (!v) return '-'
|
||||||
|
return String(v).replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(id: number): string {
|
||||||
|
const a = accounts.value.find((x: any) => x.id === id)
|
||||||
|
return a ? `${a.account_no} ${a.account_name}` : `#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
try {
|
||||||
|
const r: any = await api.get('/treasury/accounts', { params: { status: 'active' } })
|
||||||
|
accounts.value = r.items || r || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载账户池失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPayments() {
|
||||||
|
paymentsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (payFilter.value.status) params.status = payFilter.value.status
|
||||||
|
if (payFilter.value.check_result) params.check_result = payFilter.value.check_result
|
||||||
|
const r: any = await api.get('/treasury/payments', { params })
|
||||||
|
payments.value = r.items || r || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载支付单失败'))
|
||||||
|
} finally {
|
||||||
|
paymentsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHits() {
|
||||||
|
hitsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (hitsFilter.value.rule_code) params.rule_code = hitsFilter.value.rule_code
|
||||||
|
if (hitsFilter.value.verdict) params.verdict = hitsFilter.value.verdict
|
||||||
|
const r: any = await api.get('/treasury/control-hits', { params })
|
||||||
|
hits.value = r.items || r || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载规则命中留痕失败'))
|
||||||
|
} finally {
|
||||||
|
hitsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRulesAndLimits() {
|
||||||
|
rulesLoading.value = true
|
||||||
|
try {
|
||||||
|
const [r1, r2]: any[] = await Promise.all([
|
||||||
|
api.get('/treasury/control-rules'),
|
||||||
|
api.get('/treasury/limits'),
|
||||||
|
])
|
||||||
|
rules.value = r1.items || r1 || []
|
||||||
|
limits.value = r2.items || r2 || []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '加载规则库失败'))
|
||||||
|
} finally {
|
||||||
|
rulesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
loadPayments()
|
||||||
|
if (activeTab.value === 'hits') loadHits()
|
||||||
|
if (activeTab.value === 'rules') loadRulesAndLimits()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 预检 ──
|
||||||
|
async function doPrecheck(row: any) {
|
||||||
|
try {
|
||||||
|
const updated: any = await api.post(`/treasury/payments/${row.id}/precheck`)
|
||||||
|
Object.assign(row, updated)
|
||||||
|
const results = updated.check_detail?.results || []
|
||||||
|
const hitCodes = results.filter((r: any) => r.verdict !== 'pass').map((r: any) => r.rule_code).join('、')
|
||||||
|
if (updated.check_result === 'pass') {
|
||||||
|
ElMessage.success('预检通过:全部规则通过,已进入待审批')
|
||||||
|
} else if (updated.check_result === 'warn') {
|
||||||
|
ElMessage({ message: `预检警告:命中规则 ${hitCodes},请关注后审批`, type: 'warning', duration: 5000 })
|
||||||
|
} else {
|
||||||
|
ElMessage({ message: `预检拦截:命中规则 ${hitCodes},该单被 block,可走豁免通道放行`, type: 'error', duration: 6000 })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '预检失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 审批 ──
|
||||||
|
function openApprove(row: any) {
|
||||||
|
currentRow.value = row
|
||||||
|
approveComment.value = ''
|
||||||
|
approveVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApprove(approve: boolean) {
|
||||||
|
if (!currentRow.value) return
|
||||||
|
approving.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await api.post(`/treasury/payments/${currentRow.value.id}/approve`, {
|
||||||
|
approve, comment: approveComment.value,
|
||||||
|
})
|
||||||
|
if (!approve) {
|
||||||
|
Object.assign(currentRow.value, res)
|
||||||
|
ElMessage.success('已驳回')
|
||||||
|
} else if (res.approved === false) {
|
||||||
|
Object.assign(currentRow.value, res.payment)
|
||||||
|
ElMessage({ message: res.reason || '审批未通过(双签未满足)', type: 'warning', duration: 6000 })
|
||||||
|
} else {
|
||||||
|
Object.assign(currentRow.value, res.payment)
|
||||||
|
ElMessage.success('审批通过,已放行')
|
||||||
|
}
|
||||||
|
approveVisible.value = false
|
||||||
|
loadPayments()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '审批操作失败'))
|
||||||
|
} finally {
|
||||||
|
approving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 豁免 ──
|
||||||
|
function openWaive(row: any) {
|
||||||
|
currentRow.value = row
|
||||||
|
waivedBy.value = ''
|
||||||
|
waivedReason.value = ''
|
||||||
|
waiveVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWaive() {
|
||||||
|
if (!currentRow.value) return
|
||||||
|
if (!waivedBy.value.trim()) {
|
||||||
|
ElMessage.warning('请填写豁免审批人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((waivedReason.value || '').trim().length < 4) {
|
||||||
|
ElMessage.warning('豁免事由必填且不少于 4 字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waiving.value = true
|
||||||
|
try {
|
||||||
|
await api.post(`/treasury/payments/${currentRow.value.id}/waive`, {
|
||||||
|
waived_by: waivedBy.value.trim(),
|
||||||
|
waived_reason: waivedReason.value.trim(),
|
||||||
|
})
|
||||||
|
ElMessage.success('已豁免放行')
|
||||||
|
waiveVisible.value = false
|
||||||
|
loadPayments()
|
||||||
|
loadHits()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '豁免失败'))
|
||||||
|
} finally {
|
||||||
|
waiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 登记支付 ──
|
||||||
|
async function doPay(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认登记支付单 ${row.pay_no}(¥${(row.amount ?? 0).toFixed(2)}万)已实际支付?`, '登记支付', { type: 'warning' })
|
||||||
|
await api.post(`/treasury/payments/${row.id}/pay`)
|
||||||
|
ElMessage.success('已登记支付')
|
||||||
|
loadPayments()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e === 'cancel' || e === 'close') return
|
||||||
|
ElMessage.error(errDetail(e, '登记支付失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 新建/编辑支付单 ──
|
||||||
|
function openPayDialog(row: any) {
|
||||||
|
payDialog.value.mode = row ? 'edit' : 'create'
|
||||||
|
payForm.value = row
|
||||||
|
? {
|
||||||
|
id: row.id, from_account_id: row.from_account_id, to_name: row.to_name,
|
||||||
|
to_account_no: row.to_account_no, amount: row.amount, currency: row.currency || 'CNY',
|
||||||
|
purpose: row.purpose, pay_type: row.pay_type, pay_date: row.pay_date, submitter: row.submitter,
|
||||||
|
}
|
||||||
|
: { id: null, from_account_id: null, to_name: '', to_account_no: '', amount: 0, currency: 'CNY', purpose: '', pay_type: '', pay_date: '', submitter: '' }
|
||||||
|
payDialog.value.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePayment() {
|
||||||
|
const f = payForm.value
|
||||||
|
if (!f.from_account_id) { ElMessage.warning('请选择付款账户'); return }
|
||||||
|
if (!f.to_name.trim()) { ElMessage.warning('请填写收款方'); return }
|
||||||
|
if (!(f.amount > 0)) { ElMessage.warning('金额须大于 0'); return }
|
||||||
|
if (!f.purpose.trim()) { ElMessage.warning('用途为必填项'); return }
|
||||||
|
payDialog.value.saving = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
from_account_id: f.from_account_id, to_name: f.to_name.trim(), to_account_no: f.to_account_no,
|
||||||
|
amount: f.amount, currency: f.currency || 'CNY', purpose: f.purpose.trim(),
|
||||||
|
pay_type: f.pay_type, pay_date: f.pay_date || undefined, submitter: f.submitter || undefined,
|
||||||
|
}
|
||||||
|
if (payDialog.value.mode === 'edit') {
|
||||||
|
await api.put(`/treasury/payments/${f.id}`, payload)
|
||||||
|
ElMessage.success('支付单已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/treasury/payments', payload)
|
||||||
|
ElMessage.success('支付单已创建(草稿状态,请先预检)')
|
||||||
|
}
|
||||||
|
payDialog.value.visible = false
|
||||||
|
loadPayments()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '保存支付单失败'))
|
||||||
|
} finally {
|
||||||
|
payDialog.value.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 规则启用/停用 ──
|
||||||
|
async function toggleRule(row: any, val: any) {
|
||||||
|
try {
|
||||||
|
await api.put(`/treasury/control-rules/${row.id}`, {
|
||||||
|
rule_code: row.rule_code, rule_name: row.rule_name, rule_type: row.rule_type,
|
||||||
|
severity: row.severity, params: row.params, rule_version: row.rule_version,
|
||||||
|
enabled: val ? 1 : 0, remark: row.remark,
|
||||||
|
})
|
||||||
|
row.enabled = val ? 1 : 0
|
||||||
|
ElMessage.success(val ? `规则 ${row.rule_code} 已启用` : `规则 ${row.rule_code} 已停用`)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(errDetail(e, '更新规则失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAccounts()
|
||||||
|
loadPayments()
|
||||||
|
loadHits()
|
||||||
|
loadRulesAndLimits()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.treasury-payment-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.header-right { display: flex; gap: 8px; }
|
||||||
|
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.params-preview { font-family: monospace; font-size: 12px; color: #666; cursor: pointer; }
|
||||||
|
.hit-detail { padding: 8px 16px; background: #fafafa; }
|
||||||
|
.hit-detail-row { display: flex; margin-bottom: 4px; font-size: 12px; }
|
||||||
|
.hit-detail-key { width: 140px; color: #888; flex-shrink: 0; }
|
||||||
|
.hit-detail-val { color: #333; word-break: break-all; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user