ERP/frontend/js/components/finance.js

1285 lines
48 KiB
JavaScript
Raw Normal View History

2026-05-11 07:57:59 +00:00
/**
* 财务管理模块组件
* 包含收支管理客户对账发票管理供应商对账
*/
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;
});