ERP/frontend/js/components/finance.js
2026-05-11 15:57:59 +08:00

1285 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 财务管理模块组件
* 包含:收支管理、客户对账、发票管理、供应商对账
*/
window.Finance = (() => {
// 当前子模块
let currentSubModule = 'income-expense';
// 渲染主框架
const render = () => {
return `
<div class="finance-container">
<!-- 子模块标签页 -->
<div class="finance-tabs">
<div class="tab-item ${currentSubModule === 'income-expense' ? 'active' : ''}" data-tab="income-expense">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
收支管理
</div>
<div class="tab-item ${currentSubModule === 'customer-rec' ? 'active' : ''}" data-tab="customer-rec">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-3-3.87M9 21v-2a4 4 0 0 1 3-3.87M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
</svg>
客户对账
</div>
<div class="tab-item ${currentSubModule === 'supplier-rec' ? 'active' : ''}" data-tab="supplier-rec">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
供应商对账
</div>
<div class="tab-item ${currentSubModule === 'invoice' ? 'active' : ''}" data-tab="invoice">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 9h6M9 13h6M9 17h4"/>
</svg>
发票管理
</div>
<div class="tab-item ${currentSubModule === 'stats' ? 'active' : ''}" data-tab="stats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18M18 17V9M13 17V5M8 17v-5"/>
</svg>
统计报表
</div>
</div>
<!-- 内容区域 -->
<div class="finance-content" id="finance-content">
${renderSubModule(currentSubModule)}
</div>
</div>
<style>
.finance-container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.finance-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.tab-item {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
font-size: 14px;
color: var(--text-secondary);
}
.tab-item svg {
width: 18px;
height: 18px;
}
.tab-item:hover {
background: var(--surface);
color: var(--text);
}
.tab-item.active {
background: var(--primary);
color: white;
}
.finance-content {
background: var(--surface);
border-radius: 12px;
padding: 20px;
min-height: 500px;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
color: var(--text);
}
.stat-card .value.income { color: #10B981; }
.stat-card .value.expense { color: #EF4444; }
.stat-card .value.balance { color: #3B82F6; }
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
.filter-bar input, .filter-bar select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.filter-bar button {
padding: 8px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.filter-bar button.secondary {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.data-table th {
background: var(--bg);
font-weight: 600;
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--bg);
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge.income { background: #D1FAE5; color: #059669; }
.badge.expense { background: #FEE2E2; color: #DC2626; }
.badge.confirmed { background: #DBEAFE; color: #2563EB; }
.badge.pending { background: #FEF3C7; color: #D97706; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--surface);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.chart-container {
height: 300px;
margin: 20px 0;
}
</style>
`;
};
// 渲染子模块内容
const renderSubModule = (module) => {
switch(module) {
case 'income-expense':
return renderIncomeExpense();
case 'customer-rec':
return renderCustomerReconciliation();
case 'supplier-rec':
return renderSupplierReconciliation();
case 'invoice':
return renderInvoice();
case 'stats':
return renderStats();
default:
return renderIncomeExpense();
}
};
// 收支管理
const renderIncomeExpense = () => {
return `
<div class="income-expense-module">
<div class="stats-cards">
<div class="stat-card">
<div class="label">本月收入</div>
<div class="value income" id="month-income">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">本月支出</div>
<div class="value expense" id="month-expense">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">本月结余</div>
<div class="value balance" id="month-balance">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">待确认</div>
<div class="value" id="pending-count">0</div>
</div>
</div>
<div class="filter-bar">
<select id="trans-type">
<option value="">全部类型</option>
<option value="income">收入</option>
<option value="expense">支出</option>
</select>
<select id="trans-category">
<option value="">全部类别</option>
<option value="货款">货款</option>
<option value="运费">运费</option>
<option value="工资">工资</option>
<option value="租金">租金</option>
<option value="税费">税费</option>
<option value="其他">其他</option>
</select>
<input type="date" id="start-date" placeholder="开始日期">
<input type="date" id="end-date" placeholder="结束日期">
<input type="text" id="related-party" placeholder="客户/供应商">
<button onclick="Finance.loadTransactions()">查询</button>
<button class="secondary" onclick="Finance.showAddTransactionModal()">+ 新增</button>
</div>
<table class="data-table" id="transactions-table">
<thead>
<tr>
<th>日期</th>
<th>类型</th>
<th>类别</th>
<th>金额</th>
<th>关联方</th>
<th>关联单号</th>
<th>支付方式</th>
<th>状态</th>
<th>备注</th>
</tr>
</thead>
<tbody id="transactions-tbody">
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary)">加载中...</td></tr>
</tbody>
</table>
<div id="transaction-pagination" style="margin-top:16px;display:flex;justify-content:center;gap:8px;"></div>
</div>
`;
};
// 客户对账
const renderCustomerReconciliation = () => {
return `
<div class="customer-rec-module">
<div class="stats-cards" style="margin-bottom:16px;">
<div class="stat-card">
<div class="label">本期客户数</div>
<div class="value" id="rec-customer-count">0</div>
</div>
<div class="stat-card">
<div class="label">总应收金额</div>
<div class="value income" id="rec-total-receivable">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">总已收金额</div>
<div class="value" id="rec-total-paid">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">总未收余额</div>
<div class="value expense" id="rec-total-balance">¥0.00</div>
</div>
</div>
<div class="filter-bar">
<input type="text" id="customer-name" placeholder="合同号/客户名称">
<input type="month" id="rec-period" placeholder="选择月份筛选">
<button onclick="Finance.loadCustomerRec()">查询</button>
<button class="secondary" onclick="Finance.clearPeriodFilter()">显示全部</button>
<button id="merge-btn" class="secondary" onclick="Finance.toggleMerge()" style="background:#3B82F6;color:white;">整合显示</button>
<button class="secondary" onclick="Finance.showGenerateStatementModal()">生成对账单</button>
</div>
<div id="rec-period-label" style="margin-bottom:12px;font-size:13px;color:var(--text-secondary);">显示全部历史数据</div>
<table class="data-table" id="customer-rec-table">
<thead>
<tr>
<th id="rec-th-customer">客户 / 合同号</th>
<th>订单数</th>
<th>物料明细</th>
<th>累计应收</th>
<th>累计已收</th>
<th>未收余额</th>
<th>回款率</th>
<th>最后交易</th>
</tr>
</thead>
<tbody id="customer-rec-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>
</tbody>
</table>
</div>
`;
};
// 供应商对账
const renderSupplierReconciliation = () => {
return `
<div class="supplier-rec-module">
<div class="filter-bar">
<input type="text" id="supplier-name" placeholder="供应商名称">
<input type="month" id="supplier-period" value="${new Date().toISOString().slice(0, 7)}">
<button onclick="Finance.loadSupplierRec()">查询</button>
<button class="secondary" onclick="Finance.showGenerateSupplierStatementModal()">生成对账单</button>
</div>
<table class="data-table" id="supplier-rec-table">
<thead>
<tr>
<th>供应商名称</th>
<th>对账期间</th>
<th>期初应付</th>
<th>本期应付</th>
<th>本期已付</th>
<th>期末应付</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="supplier-rec-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>
</tbody>
</table>
</div>
`;
};
// 发票管理
const renderInvoice = () => {
return `
<div class="invoice-module">
<div class="stats-cards">
<div class="stat-card">
<div class="label">本月进项发票</div>
<div class="value" id="input-invoice-count">0 张</div>
</div>
<div class="stat-card">
<div class="label">进项金额</div>
<div class="value" id="input-invoice-amount">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">本月销项发票</div>
<div class="value" id="output-invoice-count">0 张</div>
</div>
<div class="stat-card">
<div class="label">销项金额</div>
<div class="value" id="output-invoice-amount">¥0.00</div>
</div>
</div>
<div class="filter-bar">
<select id="invoice-type">
<option value="">全部</option>
<option value="input">进项发票</option>
<option value="output">销项发票</option>
</select>
<select id="invoice-status">
<option value="">全部状态</option>
<option value="normal">正常</option>
<option value="voided">作废</option>
</select>
<input type="date" id="invoice-start" placeholder="开始日期">
<input type="date" id="invoice-end" placeholder="结束日期">
<button onclick="Finance.loadInvoices()">查询</button>
<button class="secondary" onclick="Finance.showAddInvoiceModal()">+ 新增发票</button>
</div>
<table class="data-table" id="invoices-table">
<thead>
<tr>
<th>发票类型</th>
<th>发票号码</th>
<th>开票日期</th>
<th>金额</th>
<th>税额</th>
<th>价税合计</th>
<th>关联方</th>
<th>关联单号</th>
<th>状态</th>
</tr>
</thead>
<tbody id="invoices-tbody">
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>
</tbody>
</table>
</div>
`;
};
// 统计报表
const renderStats = () => {
return `
<div class="stats-module">
<div class="filter-bar">
<select id="stats-range">
<option value="7">近7天</option>
<option value="30" selected>近30天</option>
<option value="90">近90天</option>
<option value="365">近一年</option>
</select>
<button onclick="Finance.loadStats()">刷新</button>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="label">总收入</div>
<div class="value income" id="total-income">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">总支出</div>
<div class="value expense" id="total-expense">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">净利润</div>
<div class="value balance" id="net-profit">¥0.00</div>
</div>
<div class="stat-card">
<div class="label">毛利率</div>
<div class="value" id="profit-margin">0%</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
<div class="chart-container">
<h4 style="margin-bottom:12px;">收支趋势</h4>
<canvas id="trend-chart" width="400" height="300"></canvas>
</div>
<div>
<h4 style="margin-bottom:12px;">客户应收TOP10</h4>
<table class="data-table" id="receivables-table">
<thead>
<tr><th>客户</th><th>应收金额</th></tr>
</thead>
<tbody id="receivables-tbody"></tbody>
</table>
</div>
</div>
<div style="margin-top:20px;">
<h4 style="margin-bottom:12px;">供应商应付TOP10</h4>
<table class="data-table" id="payables-table">
<thead>
<tr><th>供应商</th><th>应付金额</th></tr>
</thead>
<tbody id="payables-tbody"></tbody>
</table>
</div>
</div>
`;
};
// 初始化模块
const init = () => {
// 激活当前模块对应的标签
document.querySelectorAll('.finance-tabs .tab-item').forEach(t => t.classList.remove('active'));
const activeTab = document.querySelector(`[data-tab="${currentSubModule}"]`);
if(activeTab) activeTab.classList.add('active');
// 渲染当前模块内容
const contentEl = document.getElementById('finance-content');
if(contentEl) contentEl.innerHTML = renderSubModule(currentSubModule);
// 绑定标签页切换
document.querySelectorAll('.finance-tabs .tab-item').forEach(tab => {
tab.addEventListener('click', () => {
currentSubModule = tab.dataset.tab;
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.getElementById('finance-content').innerHTML = renderSubModule(currentSubModule);
// 加载对应数据
switch(currentSubModule) {
case 'income-expense':
setTimeout(() => {
loadTransactionStats();
loadTransactions();
}, 0);
break;
case 'customer-rec':
setTimeout(() => loadCustomerRec(), 0);
break;
case 'supplier-rec':
setTimeout(() => loadSupplierRec(), 0);
break;
case 'invoice':
setTimeout(() => {
loadInvoiceStats();
loadInvoices();
}, 0);
break;
case 'stats':
setTimeout(() => loadStats(), 0);
break;
}
});
});
// 加载初始数据(只在收支管理标签页加载)
if(currentSubModule === 'income-expense') {
setTimeout(() => {
loadTransactionStats();
loadTransactions();
}, 0);
}
};
// ========== 数据加载函数 ==========
// 加载收支统计
const loadTransactionStats = async () => {
try {
const res = await fetch('/api/finance/transactions/stats', {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
document.getElementById('month-income').textContent =
'¥' + (res.stats.month_income || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('month-expense').textContent =
'¥' + (res.stats.month_expense || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('month-balance').textContent =
'¥' + (res.stats.month_net || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
}
} catch(e) {
console.error('加载统计失败:', e);
}
};
// 加载收支流水
const loadTransactions = async (page = 1) => {
try {
const params = new URLSearchParams();
const type = document.getElementById('trans-type')?.value;
const category = document.getElementById('trans-category')?.value;
const startDate = document.getElementById('start-date')?.value;
const endDate = document.getElementById('end-date')?.value;
const party = document.getElementById('related-party')?.value;
if(type) params.append('type', type);
if(category) params.append('category', category);
if(startDate) params.append('start_date', startDate);
if(endDate) params.append('end_date', endDate);
if(party) params.append('related_party', party);
params.append('page', page);
const res = await fetch('/api/finance/transactions?' + params, {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
const tbody = document.getElementById('transactions-tbody');
if(res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>';
} else {
tbody.innerHTML = res.data.map(row => `
<tr>
<td>${row.date}</td>
<td><span class="badge ${row.type}">${row.type === 'income' ? '收入' : '支出'}</span></td>
<td>${row.category || '-'}</td>
<td>¥${(row.amount || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>${row.related_party || '-'}</td>
<td>${row.order_no || '-'}</td>
<td>${row.payment_method || '-'}</td>
<td><span class="badge ${row.status}">${row.status === 'confirmed' ? '已确认' : '待确认'}</span></td>
<td>${row.description || '-'}</td>
</tr>
`).join('');
}
}
} catch(e) {
console.error('加载流水失败:', e);
}
};
// 整合显示状态
let isMergeMode = false;
// 切换整合/分开显示
const toggleMerge = () => {
isMergeMode = !isMergeMode;
const btn = document.getElementById('merge-btn');
const th = document.getElementById('rec-th-customer');
if(btn) {
if(isMergeMode) {
btn.textContent = '分开显示';
btn.style.background = '#6B7280';
if(th) th.textContent = '客户名称';
} else {
btn.textContent = '整合显示';
btn.style.background = '#3B82F6';
if(th) th.textContent = '客户 / 合同号';
}
}
loadCustomerRec();
};
// 加载客户对账
const loadCustomerRec = async () => {
try {
const params = new URLSearchParams();
const customer = document.getElementById('customer-name')?.value;
const period = document.getElementById('rec-period')?.value;
console.log('DEBUG: period value =', period);
if(customer) params.append('customer', customer);
if(period) {
params.append('period', period);
console.log('DEBUG: adding period param =', period);
}
if(isMergeMode) params.append('merge', 'true');
console.log('DEBUG: final URL =', '/api/finance/customer-reconciliation?' + params.toString());
const res = await fetch('/api/finance/customer-reconciliation?' + params, {
credentials: 'include'
}).then(r => r.json());
console.log('DEBUG: response period_label =', res.period_label);
if(res.ok) {
// 更新周期标签
const periodLabel = document.getElementById('rec-period-label');
if(periodLabel && res.period_label) {
const modeText = isMergeMode ? '(整合显示)' : '(分开显示)';
periodLabel.textContent = (period ? `数据周期:${res.period_label}` : '显示全部历史数据') + modeText;
}
// 更新统计卡片
const totalReceivable = res.data.reduce((sum, r) => sum + (r.total_income || 0), 0);
const totalPaid = res.data.reduce((sum, r) => sum + (r.total_paid || 0), 0);
const totalBalance = res.data.reduce((sum, r) => sum + (r.closing_balance || 0), 0);
// 本期客户数按客户名称去重统计同一客户多个合同号只算1个
const uniqueCustomers = new Set(res.data.map(r => r.customer_name)).size;
document.getElementById('rec-customer-count').textContent = uniqueCustomers;
document.getElementById('rec-total-receivable').textContent =
'¥' + totalReceivable.toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('rec-total-paid').textContent =
'¥' + totalPaid.toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('rec-total-balance').textContent =
'¥' + totalBalance.toLocaleString('zh-CN', {minimumFractionDigits: 2});
const tbody = document.getElementById('customer-rec-tbody');
if(res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>';
} else {
tbody.innerHTML = res.data.map(row => {
// 计算回款率
const payRate = row.total_income > 0
? ((row.total_paid / row.total_income) * 100).toFixed(1) + '%'
: '0%';
// 物料明细截断显示
const materials = row.materials
? (row.materials.length > 30 ? row.materials.substring(0, 30) + '...' : row.materials)
: '-';
// 最后交易日期
const lastDate = row.last_order_date || '-';
// 显示名称:整合模式只显示客户名,分开模式显示客户名+合同号+编辑图标
let displayName;
const editIcon = `<svg onclick="Finance.editCustomerRec('${row.contract_no}', '${row.customer_name}')" style="cursor:pointer;margin-left:8px;color:var(--primary);" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`;
if(isMergeMode) {
displayName = `<strong style="cursor:pointer;" onclick="Finance.editCustomerRec('${row.contract_no}', '${row.customer_name}')">${row.customer_name}</strong>${editIcon}`;
} else {
// 分开显示模式:同时显示客户名称和合同号,可点击编辑
if(row.customer_name && row.customer_name !== row.contract_no) {
displayName = `<strong style="cursor:pointer;" onclick="Finance.editCustomerRec('${row.contract_no}', '${row.customer_name}')">${row.customer_name}</strong>${editIcon}<br><small style="color:var(--text-secondary)">${row.contract_no}</small>`;
} else {
displayName = `<strong style="cursor:pointer;" onclick="Finance.editCustomerRec('${row.contract_no}', '${row.customer_name}')">${row.contract_no}</strong>${editIcon}`;
}
}
return `
<tr>
<td>${displayName}</td>
<td>${row.order_count || 0} 单</td>
<td title="${row.materials || ''}">${materials}</td>
<td class="income">¥${(row.total_income || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.total_paid || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td class="${row.closing_balance > 0 ? 'expense' : ''}">¥${(row.closing_balance || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>${payRate}</td>
<td>${lastDate}</td>
</tr>
`}).join('');
}
}
} catch(e) {
console.error('加载客户对账失败:', e);
}
};
// 加载供应商对账
const loadSupplierRec = async () => {
try {
const params = new URLSearchParams();
const supplier = document.getElementById('supplier-name')?.value;
const period = document.getElementById('supplier-period')?.value;
if(supplier) params.append('supplier', supplier);
if(period) params.append('period', period);
const res = await fetch('/api/finance/supplier-reconciliation?' + params, {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
const tbody = document.getElementById('supplier-rec-tbody');
if(res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>';
} else {
tbody.innerHTML = res.data.map(row => `
<tr>
<td>${row.supplier_name}</td>
<td>${row.period_start} ~ ${row.period_end}</td>
<td>¥${(row.opening_balance || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.total_expense || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.total_paid || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.closing_balance || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td><span class="badge ${row.statement_status}">${row.statement_status === 'confirmed' ? '已确认' : '待确认'}</span></td>
<td><button onclick="Finance.viewSupplierStatement(${row.id})">查看</button></td>
</tr>
`).join('');
}
}
} catch(e) {
console.error('加载供应商对账失败:', e);
}
};
// 加载发票统计
const loadInvoiceStats = async () => {
try {
const res = await fetch('/api/finance/invoices/stats', {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
document.getElementById('input-invoice-count').textContent =
(res.stats.input.count || 0) + ' 张';
document.getElementById('input-invoice-amount').textContent =
'¥' + (res.stats.input.total || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('output-invoice-count').textContent =
(res.stats.output.count || 0) + ' 张';
document.getElementById('output-invoice-amount').textContent =
'¥' + (res.stats.output.total || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
}
} catch(e) {
console.error('加载发票统计失败:', e);
}
};
// 加载发票列表
const loadInvoices = async () => {
try {
const params = new URLSearchParams();
const type = document.getElementById('invoice-type')?.value;
const status = document.getElementById('invoice-status')?.value;
const start = document.getElementById('invoice-start')?.value;
const end = document.getElementById('invoice-end')?.value;
if(type) params.append('type', type);
if(status) params.append('status', status);
if(start) params.append('start_date', start);
if(end) params.append('end_date', end);
const res = await fetch('/api/finance/invoices?' + params, {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
const tbody = document.getElementById('invoices-tbody');
if(res.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary)">暂无数据</td></tr>';
} else {
tbody.innerHTML = res.data.map(row => `
<tr>
<td>${row.invoice_type === 'input' ? '进项' : '销项'}</td>
<td>${row.invoice_no}</td>
<td>${row.issue_date}</td>
<td>¥${(row.amount || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.tax_amount || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>¥${(row.total_amount || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td>
<td>${row.related_party || '-'}</td>
<td>${row.order_no || '-'}</td>
<td><span class="badge ${row.status}">${row.status === 'normal' ? '正常' : '作废'}</span></td>
</tr>
`).join('');
}
}
} catch(e) {
console.error('加载发票失败:', e);
}
};
// 加载统计报表
const loadStats = async () => {
try {
const days = document.getElementById('stats-range')?.value || 30;
const res = await fetch('/api/finance/business-stats?days=' + days, {
credentials: 'include'
}).then(r => r.json());
if(res.ok) {
document.getElementById('total-income').textContent =
'¥' + (res.stats.summary.total_income || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
document.getElementById('total-expense').textContent =
'¥' + (res.stats.summary.total_expense || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2});
const net = (res.stats.summary.total_income || 0) - (res.stats.summary.total_expense || 0);
document.getElementById('net-profit').textContent =
'¥' + net.toLocaleString('zh-CN', {minimumFractionDigits: 2});
const margin = res.stats.summary.total_income > 0
? (net / res.stats.summary.total_income * 100).toFixed(1)
: 0;
document.getElementById('profit-margin').textContent = margin + '%';
// 客户应收
const recTbody = document.getElementById('receivables-tbody');
if(res.stats.customer_receivables?.length > 0) {
recTbody.innerHTML = res.stats.customer_receivables.map(r => `
<tr><td>${r.related_party}</td><td>¥${(r.balance || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td></tr>
`).join('');
} else {
recTbody.innerHTML = '<tr><td colspan="2" style="text-align:center">暂无数据</td></tr>';
}
// 供应商应付
const payTbody = document.getElementById('payables-tbody');
if(res.stats.supplier_payables?.length > 0) {
payTbody.innerHTML = res.stats.supplier_payables.map(r => `
<tr><td>${r.related_party}</td><td>¥${(r.balance || 0).toLocaleString('zh-CN', {minimumFractionDigits: 2})}</td></tr>
`).join('');
} else {
payTbody.innerHTML = '<tr><td colspan="2" style="text-align:center">暂无数据</td></tr>';
}
}
} catch(e) {
console.error('加载统计失败:', e);
}
};
// ========== 模态框函数 ==========
const showAddTransactionModal = () => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'transaction-modal';
modal.innerHTML = `
<div class="modal-content">
<h3 style="margin-bottom:20px;">新增收支记录</h3>
<div class="form-group">
<label>类型</label>
<select id="trans-type-input">
<option value="income">收入</option>
<option value="expense">支出</option>
</select>
</div>
<div class="form-group">
<label>类别</label>
<select id="trans-category-input">
<option value="货款">货款</option>
<option value="运费">运费</option>
<option value="工资">工资</option>
<option value="租金">租金</option>
<option value="税费">税费</option>
<option value="其他">其他</option>
</select>
</div>
<div class="form-group">
<label>金额</label>
<input type="number" id="trans-amount" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label>日期</label>
<input type="date" id="trans-date" value="${new Date().toISOString().slice(0, 10)}">
</div>
<div class="form-group">
<label>关联方</label>
<input type="text" id="trans-party" placeholder="客户/供应商名称">
</div>
<div class="form-group">
<label>关联单号</label>
<input type="text" id="trans-order" placeholder="订单号">
</div>
<div class="form-group">
<label>支付方式</label>
<select id="trans-payment">
<option value="transfer">银行转账</option>
<option value="cash">现金</option>
<option value="alipay">支付宝</option>
<option value="wechat">微信支付</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<textarea id="trans-desc" rows="3"></textarea>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button class="secondary" onclick="document.getElementById('transaction-modal').remove()">取消</button>
<button onclick="Finance.saveTransaction()">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
};
const saveTransaction = async () => {
try {
const data = {
type: document.getElementById('trans-type-input').value,
category: document.getElementById('trans-category-input').value,
amount: parseFloat(document.getElementById('trans-amount').value) || 0,
date: document.getElementById('trans-date').value,
related_party: document.getElementById('trans-party').value,
related_party_type: 'customer',
order_no: document.getElementById('trans-order').value,
payment_method: document.getElementById('trans-payment').value,
description: document.getElementById('trans-desc').value
};
const res = await fetch('/api/finance/transactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data)
}).then(r => r.json());
if(res.ok) {
document.getElementById('transaction-modal').remove();
loadTransactions();
loadTransactionStats();
} else {
alert('保存失败: ' + (res.error || '未知错误'));
}
} catch(e) {
console.error('保存失败:', e);
alert('保存失败');
}
};
const showAddInvoiceModal = () => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'invoice-modal';
modal.innerHTML = `
<div class="modal-content">
<h3 style="margin-bottom:20px;">新增发票</h3>
<div class="form-group">
<label>发票类型</label>
<select id="inv-type-input">
<option value="input">进项发票</option>
<option value="output">销项发票</option>
</select>
</div>
<div class="form-group">
<label>发票号码</label>
<input type="text" id="inv-no" placeholder="发票号码">
</div>
<div class="form-group">
<label>发票代码</label>
<input type="text" id="inv-code" placeholder="发票代码">
</div>
<div class="form-group">
<label>开票日期</label>
<input type="date" id="inv-date" value="${new Date().toISOString().slice(0, 10)}">
</div>
<div class="form-group">
<label>金额(不含税)</label>
<input type="number" id="inv-amount" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label>税额</label>
<input type="number" id="inv-tax" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label>关联方</label>
<input type="text" id="inv-party" placeholder="开票方/收票方">
</div>
<div class="form-group">
<label>关联订单号</label>
<input type="text" id="inv-order" placeholder="订单号">
</div>
<div class="form-group">
<label>备注</label>
<textarea id="inv-notes" rows="2"></textarea>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button class="secondary" onclick="document.getElementById('invoice-modal').remove()">取消</button>
<button onclick="Finance.saveInvoice()">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
};
const saveInvoice = async () => {
try {
const amount = parseFloat(document.getElementById('inv-amount').value) || 0;
const tax = parseFloat(document.getElementById('inv-tax').value) || 0;
const data = {
invoice_type: document.getElementById('inv-type-input').value,
invoice_no: document.getElementById('inv-no').value,
invoice_code: document.getElementById('inv-code').value,
issue_date: document.getElementById('inv-date').value,
amount: amount,
tax_amount: tax,
total_amount: amount + tax,
related_party: document.getElementById('inv-party').value,
order_no: document.getElementById('inv-order').value,
notes: document.getElementById('inv-notes').value
};
const res = await fetch('/api/finance/invoices', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data)
}).then(r => r.json());
if(res.ok) {
document.getElementById('invoice-modal').remove();
loadInvoices();
loadInvoiceStats();
} else {
alert('保存失败: ' + (res.error || '未知错误'));
}
} catch(e) {
console.error('保存失败:', e);
alert('保存失败');
}
};
// 其他占位函数
const showGenerateStatementModal = () => alert('生成客户对账单功能开发中');
const showGenerateSupplierStatementModal = () => alert('生成供应商对账单功能开发中');
const viewStatement = (id) => alert('查看对账单 ' + id);
const viewSupplierStatement = (id) => alert('查看供应商对账单 ' + id);
return {
render,
init,
renderCustomerReconciliation,
renderSupplierReconciliation,
renderInvoice,
renderStats,
loadTransactions,
loadCustomerRec,
loadSupplierRec,
loadInvoices,
loadStats,
loadTransactionStats,
loadInvoiceStats,
// 重置模块状态
resetModule: () => {
currentSubModule = 'income-expense';
isMergeMode = false;
},
showAddTransactionModal,
saveTransaction,
showAddInvoiceModal,
saveInvoice,
showGenerateStatementModal,
showGenerateSupplierStatementModal,
viewStatement,
viewSupplierStatement,
toggleMerge,
editCustomerRec: (contractNo, customerName) => {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'edit-rec-modal';
modal.innerHTML = `
<div class="modal-content">
<h3 style="margin-bottom:20px;">编辑客户信息</h3>
<div class="form-group">
<label>合同号</label>
<input type="text" id="edit-contract-no" value="${contractNo}" disabled style="background:var(--bg);">
</div>
<div class="form-group">
<label>客户名称</label>
<input type="text" id="edit-customer-name" value="${customerName}" placeholder="请输入客户名称">
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button class="secondary" onclick="document.getElementById('edit-rec-modal').remove()">取消</button>
<button onclick="Finance.saveCustomerRecEdit()">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
},
saveCustomerRecEdit: async () => {
const contractNo = document.getElementById('edit-contract-no').value;
const customerName = document.getElementById('edit-customer-name').value;
if(!customerName.trim()) {
alert('请输入客户名称');
return;
}
try {
// 调用后端API保存
const res = await fetch('/api/finance/customer-reconciliation/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({
order_no: contractNo,
customer_name: customerName.trim()
})
}).then(r => r.json());
document.getElementById('edit-rec-modal').remove();
if(res.ok) {
// 刷新列表
if(window.Finance && window.Finance.loadCustomerRec) {
window.Finance.loadCustomerRec();
}
if(window.API && window.API.toast) {
window.API.toast('客户名称已更新');
}
} else {
alert('保存失败:' + (res.error || '未知错误'));
}
} catch(e) {
console.error('保存失败:', e);
alert('保存失败,请检查网络连接');
}
},
clearPeriodFilter: () => {
document.getElementById('rec-period').value = '';
if(window.Finance && window.Finance.loadCustomerRec) {
window.Finance.loadCustomerRec();
}
}
};
})();
// 注册路由
Router.register('/finance', async () => {
// 重置为收支管理标签
if(window.Finance && window.Finance.resetModule) {
window.Finance.resetModule();
}
const html = window.Finance.render();
setTimeout(() => window.Finance.init(), 0);
return html;
});
Router.register('/finance/customer-rec', async () => {
// 设置当前模块为客户对账避免init加载错误的数据
isMergeMode = false;
const html = window.Finance.render();
setTimeout(() => {
window.Finance.init();
// 手动切换到客户对账标签并加载数据
currentSubModule = 'customer-rec';
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelector('[data-tab="customer-rec"]')?.classList.add('active');
document.getElementById('finance-content').innerHTML = window.Finance.renderCustomerReconciliation();
setTimeout(() => window.Finance.loadCustomerRec(), 0);
}, 0);
return html;
});
Router.register('/finance/supplier-rec', async () => {
isMergeMode = false;
const html = window.Finance.render();
setTimeout(() => {
window.Finance.init();
currentSubModule = 'supplier-rec';
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelector('[data-tab="supplier-rec"]')?.classList.add('active');
document.getElementById('finance-content').innerHTML = window.Finance.renderSupplierReconciliation();
setTimeout(() => window.Finance.loadSupplierRec(), 0);
}, 0);
return html;
});
Router.register('/finance/invoice', async () => {
isMergeMode = false;
const html = window.Finance.render();
setTimeout(() => {
window.Finance.init();
currentSubModule = 'invoice';
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelector('[data-tab="invoice"]')?.classList.add('active');
document.getElementById('finance-content').innerHTML = window.Finance.renderInvoice();
setTimeout(() => {
window.Finance.loadInvoiceStats();
window.Finance.loadInvoices();
}, 0);
}, 0);
return html;
});
Router.register('/finance/stats', async () => {
isMergeMode = false;
const html = window.Finance.render();
setTimeout(() => {
window.Finance.init();
currentSubModule = 'stats';
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
document.querySelector('[data-tab="stats"]')?.classList.add('active');
document.getElementById('finance-content').innerHTML = window.Finance.renderStats();
setTimeout(() => window.Finance.loadStats(), 0);
}, 0);
return html;
});