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:
@@ -493,4 +493,36 @@ export const aiSuggestionApi = {
|
||||
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
|
||||
|
||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
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'],
|
||||
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'],
|
||||
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', '/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'],
|
||||
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[]> = {
|
||||
@@ -36,6 +36,8 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ 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: '/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: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ 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: '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: '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: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
// /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