ERP/frontend/js/components/reconciliation.js
2025-12-30 14:35:09 +08:00

744 lines
27 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.

// 对账单管理
(() => {
Router.register('/plan-mgmt/reconciliation', async () => {
const html = `
<style>
/* 完全重新设计页面布局 */
.view:has(#reconciliation-page) {
padding: 0 !important;
display: flex;
flex-direction: column;
height: 100vh;
}
#reconciliation-page {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
}
#reconciliation-page .page-header {
padding: 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#reconciliation-page .content-area {
flex: 1;
padding: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
#reconciliation-page .table-wrapper {
flex: 1;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
#reconciliation-page table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
#reconciliation-page thead {
background: var(--border);
position: sticky;
top: 0;
z-index: 10;
}
#reconciliation-page th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 2px solid var(--border);
font-size: 14px;
}
#reconciliation-page td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text);
font-size: 14px;
}
#reconciliation-page tbody tr:last-child td {
border-bottom: none;
}
#reconciliation-page tbody tr:hover {
background: var(--hover);
}
#reconciliation-page .text-center {
text-align: center;
color: var(--text-2);
}
#reconciliation-page .btn-text {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 4px 8px;
margin-right: 8px;
}
#reconciliation-page .btn-text:hover {
text-decoration: underline;
}
#reconciliation-page .btn-danger {
color: var(--danger);
}
</style>
<div id="reconciliation-page">
<div class="page-header">
<h1 style="margin: 0; font-size: 24px;">对账单管理</h1>
<div class="page-actions" style="margin-top: 16px;">
<button id="batch-delete-btn" class="btn btn-danger" style="margin-right: 10px; display: none;">批量删除</button>
<button id="export-reconciliation-btn" class="btn btn-secondary" style="margin-right: 10px;">导出对账单</button>
<button id="upload-shipment-btn" class="btn btn-secondary" style="margin-right: 10px;">上传发货单</button>
<button id="add-reconciliation-btn" class="btn btn-primary">新增对账单</button>
<input type="file" id="shipment-file-input" accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" style="display: none;" />
</div>
</div>
<div class="content-area">
<div style="padding: 0 20px 20px 20px; display: flex; gap: 15px; align-items: center;">
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th style="width: 40px; padding: 8px 10px; font-size: 14px;">
<label class="custom-checkbox"><input type="checkbox" id="select-all-reconciliation" /><span class="checkmark"></span></label>
</th>
<th>序号</th>
<th>下单时间</th>
<th>合同编号</th>
<th>物料名称</th>
<th>规格型号</th>
<th>运输单号</th>
<th>数量</th>
<th>单位</th>
<th>含税单价</th>
<th>含税金额</th>
<th>交货日期</th>
<th>出货日期</th>
<th>操作</th>
</tr>
</thead>
<tbody id="reconciliation-list">
<tr>
<td colspan="14" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="export-date-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>选择导出日期范围</h2>
<button class="modal-close" onclick="window.Reconciliation.closeExportDateModal()">&times;</button>
</div>
<div class="modal-body">
<div style="padding: 20px 0;">
<div class="field" style="margin-bottom: 15px;">
<label>交货开始日期</label>
<input type="date" id="export-start-date" class="input" placeholder="留空表示不限" />
</div>
<div class="field" style="margin-bottom: 15px;">
<label>交货结束日期</label>
<input type="date" id="export-end-date" class="input" placeholder="留空表示不限" />
</div>
<div style="padding: 10px; background: #f0f8ff; border-radius: 4px; font-size: 14px; color: #666;">
<strong>提示:</strong>留空表示不限制该日期,可以导出全部数据
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="window.Reconciliation.closeExportDateModal()">取消</button>
<button class="btn btn-primary" onclick="window.Reconciliation.confirmExport()">确定导出</button>
</div>
</div>
</div>
<div id="reconciliation-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2 id="modal-title">新增对账单</h2>
<button class="modal-close" onclick="window.Reconciliation.closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="reconciliation-form">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div class="field">
<label>下单时间 <span style="color: var(--danger);">*</span></label>
<input type="date" id="order-date" class="input" required />
</div>
<div class="field">
<label>合同编号 <span style="color: var(--danger);">*</span></label>
<input type="text" id="contract-no" class="input" placeholder="如CGDD002876" required />
</div>
<div class="field">
<label>物料名称 <span style="color: var(--danger);">*</span></label>
<input type="text" id="material-name" class="input" placeholder="如:扩产-9988 红黑线" required />
</div>
<div class="field">
<label>规格型号 <span style="color: var(--danger);">*</span></label>
<input type="text" id="spec-model" class="input" placeholder="如PCXK0P0NSNT_1_2.54*1C14TE" required />
</div>
<div class="field">
<label>运输单号</label>
<input type="text" id="transport-no" class="input" placeholder="如:快递上门" />
</div>
<div class="field">
<label>数量 <span style="color: var(--danger);">*</span></label>
<input type="number" id="quantity" class="input" placeholder="数量" min="1" required />
</div>
<div class="field">
<label>单位 <span style="color: var(--danger);">*</span></label>
<input type="text" id="unit" class="input" placeholder="如pcs" value="pcs" required />
</div>
<div class="field">
<label>含税单价 <span style="color: var(--danger);">*</span></label>
<input type="number" id="unit-price" class="input" placeholder="单价" step="0.1" min="0" required />
</div>
<div class="field">
<label>含税金额</label>
<input type="number" id="total-amount" class="input" placeholder="自动计算" step="0.1" readonly />
</div>
<div class="field">
<label>交货日期</label>
<input type="date" id="delivery-date" class="input" />
</div>
<div class="field">
<label>出货日期</label>
<input type="date" id="shipment-date" class="input" />
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="window.Reconciliation.closeModal()">取消</button>
<button class="btn btn-primary" onclick="window.Reconciliation.saveReconciliation()">保存</button>
</div>
</div>
</div>
`;
// DOM 渲染后初始化
setTimeout(() => {
const addBtn = document.getElementById('add-reconciliation-btn');
if (addBtn) {
addBtn.addEventListener('click', () => {
openModal();
});
}
// 不需要筛选功能
// 全选/取消全选
const selectAllCheckbox = document.getElementById('select-all-reconciliation');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.reconciliation-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked);
updateBatchDeleteButton();
});
}
// 批量删除按钮
const batchDeleteBtn = document.getElementById('batch-delete-btn');
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', async () => {
const selectedIds = getSelectedReconciliationIds();
if (selectedIds.length === 0) {
API.toast('请选择要删除的对账单', 'warning');
return;
}
if (!confirm(`确定要删除选中的 ${selectedIds.length} 条对账单吗?`)) {
return;
}
await batchDeleteReconciliations(selectedIds);
});
}
const exportBtn = document.getElementById('export-reconciliation-btn');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
openExportDateModal();
});
}
const uploadBtn = document.getElementById('upload-shipment-btn');
const fileInput = document.getElementById('shipment-file-input');
if (uploadBtn && fileInput) {
uploadBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// 验证文件类型
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) {
API.toast('请上传 XLS 或 XLSX 格式的发货单', 'error');
fileInput.value = '';
return;
}
// 显示上传中提示
API.toast('正在解析发货单...', 'info');
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/reconciliations/upload-shipment', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok && data.ok) {
let message = data.message;
if (data.errors && data.errors.length > 0) {
message += '\n\n错误详情\n' + data.errors.join('\n');
API.toast(message, 'warning');
} else {
API.toast(message, 'success');
}
await loadReconciliations();
} else {
API.toast(data.error || '上传失败', 'error');
}
} catch (err) {
console.error('上传发货单失败:', err);
API.toast('上传失败', 'error');
} finally {
fileInput.value = '';
}
});
}
const quantityInput = document.getElementById('quantity');
const unitPriceInput = document.getElementById('unit-price');
const totalAmountInput = document.getElementById('total-amount');
const calculateTotal = () => {
const qty = parseFloat(quantityInput.value) || 0;
const price = parseFloat(unitPriceInput.value) || 0;
const total = qty * price;
totalAmountInput.value = total.toFixed(1);
};
if (quantityInput) quantityInput.addEventListener('input', calculateTotal);
if (unitPriceInput) unitPriceInput.addEventListener('input', calculateTotal);
loadReconciliations();
}, 100);
return html;
});
async function loadReconciliations() {
console.log('开始加载对账单数据...');
try {
const res = await fetch('/api/reconciliations');
const data = await res.json();
console.log('对账单数据:', data); // 调试信息
const tbody = document.getElementById('reconciliation-list');
if (!tbody) {
console.error('找不到 reconciliation-list 元素');
return;
}
if (!data.list || data.list.length === 0) {
tbody.innerHTML = '<tr><td colspan="14" class="text-center">暂无数据</td></tr>';
return;
}
// 不需要筛选数据
let filteredData = data.list;
tbody.innerHTML = filteredData.map((item, index) => {
// 格式化数字:去掉不必要的小数点和尾随零
const formatNumber = (num) => {
if (!num && num !== 0) return '—';
const n = parseFloat(num);
// 先四舍五入到2位小数避免浮点数精度问题
const rounded = Math.round(n * 100) / 100;
// 如果是整数,直接返回整数
if (Number.isInteger(rounded)) return rounded.toString();
// 否则返回字符串自动去掉尾随的0
return rounded.toString();
};
return `
<tr>
<td>
<label class="custom-checkbox"><input type="checkbox" class="reconciliation-checkbox" data-id="${item.id}" onchange="window.Reconciliation.updateBatchDeleteButton()" /><span class="checkmark"></span></label>
</td>
<td>${index + 1}</td>
<td>${item.order_date || '—'}</td>
<td>${item.contract_no || '—'}</td>
<td>${item.material_name || '—'}</td>
<td>${item.spec_model || '—'}</td>
<td>${item.transport_no || '—'}</td>
<td>${item.quantity || 0}</td>
<td>${item.unit || '—'}</td>
<td>${formatNumber(item.unit_price)}</td>
<td>${formatNumber(item.total_amount)}</td>
<td>${item.delivery_date || '—'}</td>
<td>${item.shipment_date || '—'}</td>
<td>
<button class="btn-text" onclick="window.Reconciliation.editReconciliation(${item.id})">编辑</button>
<button class="btn-text btn-danger" onclick="window.Reconciliation.deleteReconciliation(${item.id})">删除</button>
</td>
</tr>
`;
}).join('');
// 重置全选状态
const selectAllCheckbox = document.getElementById('select-all-reconciliation');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
}
updateBatchDeleteButton();
} catch (err) {
console.error('加载对账单失败:', err);
const tbody = document.getElementById('reconciliation-list');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="14" class="text-center" style="color: red;">加载失败,请刷新重试</td></tr>';
}
API.toast('加载对账单失败', 'error');
}
}
let currentEditId = null;
// 日期格式转换YYYY/MM/DD -> YYYY-MM-DD (用于input[type=date])
function formatDateForInput(dateStr) {
if (!dateStr) return '';
return dateStr.replace(/\//g, '-');
}
function openModal(item = null) {
const modal = document.getElementById('reconciliation-modal');
const title = document.getElementById('modal-title');
if (item) {
// 编辑模式
title.textContent = '编辑对账单';
currentEditId = item.id;
document.getElementById('order-date').value = formatDateForInput(item.order_date) || '';
document.getElementById('contract-no').value = item.contract_no || '';
document.getElementById('material-name').value = item.material_name || '';
document.getElementById('spec-model').value = item.spec_model || '';
document.getElementById('transport-no').value = item.transport_no || '';
document.getElementById('quantity').value = item.quantity || '';
document.getElementById('unit').value = item.unit || 'pcs';
document.getElementById('unit-price').value = item.unit_price || '';
document.getElementById('total-amount').value = item.total_amount || '';
document.getElementById('delivery-date').value = formatDateForInput(item.delivery_date) || '';
document.getElementById('shipment-date').value = formatDateForInput(item.shipment_date) || '';
} else {
// 新增模式
title.textContent = '新增对账单';
currentEditId = null;
document.getElementById('reconciliation-form').reset();
const today = new Date().toISOString().split('T')[0];
document.getElementById('order-date').value = today;
document.getElementById('unit').value = 'pcs';
}
modal.style.display = 'flex';
}
function closeModal() {
const modal = document.getElementById('reconciliation-modal');
modal.style.display = 'none';
document.getElementById('reconciliation-form').reset();
currentEditId = null;
}
async function saveReconciliation() {
const orderDate = document.getElementById('order-date').value.trim();
const contractNo = document.getElementById('contract-no').value.trim();
const materialName = document.getElementById('material-name').value.trim();
const specModel = document.getElementById('spec-model').value.trim();
const transportNo = document.getElementById('transport-no').value.trim();
const quantity = parseInt(document.getElementById('quantity').value);
const unit = document.getElementById('unit').value.trim();
const unitPrice = parseFloat(document.getElementById('unit-price').value);
const totalAmount = parseFloat(document.getElementById('total-amount').value);
const deliveryDate = document.getElementById('delivery-date').value.trim();
const shipmentDate = document.getElementById('shipment-date').value.trim();
if (!orderDate || !contractNo || !materialName || !specModel || !quantity || !unit || isNaN(unitPrice)) {
API.toast('请填写必填项', 'error');
return;
}
if (quantity <= 0) {
API.toast('数量必须大于0', 'error');
return;
}
if (unitPrice < 0) {
API.toast('单价不能为负数', 'error');
return;
}
const payload = {
order_date: orderDate,
contract_no: contractNo,
material_name: materialName,
spec_model: specModel,
transport_no: transportNo,
quantity: quantity,
unit: unit,
unit_price: unitPrice,
total_amount: totalAmount,
delivery_date: deliveryDate || null,
shipment_date: shipmentDate || null
};
try {
const url = currentEditId ? `/api/reconciliations/${currentEditId}` : '/api/reconciliations';
const method = currentEditId ? 'PUT' : 'POST';
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(currentEditId ? '更新成功' : '保存成功', 'success');
closeModal();
await loadReconciliations();
} else {
API.toast(data.error || '保存失败', 'error');
}
} catch (err) {
console.error('保存对账单失败:', err);
API.toast('保存失败', 'error');
}
}
async function editReconciliation(id) {
try {
const res = await fetch('/api/reconciliations');
const data = await res.json();
const item = data.list.find(r => r.id === id);
if (!item) {
API.toast('对账单不存在', 'error');
return;
}
openModal(item);
} catch (err) {
console.error('加载对账单失败:', err);
API.toast('加载失败', 'error');
}
}
async function deleteReconciliation(id) {
if (!confirm('确定要删除这条对账单吗?')) {
return;
}
try {
const res = await fetch(`/api/reconciliations/${id}`, {
method: 'DELETE'
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('删除成功', 'success');
await loadReconciliations();
} else {
API.toast(data.error || '删除失败', 'error');
}
} catch (err) {
console.error('删除对账单失败:', err);
API.toast('删除失败', 'error');
}
}
// 获取选中的对账单ID列表
function getSelectedReconciliationIds() {
const checkboxes = document.querySelectorAll('.reconciliation-checkbox:checked');
return Array.from(checkboxes).map(cb => parseInt(cb.dataset.id));
}
// 更新批量删除按钮的显示状态
function updateBatchDeleteButton() {
const selectedIds = getSelectedReconciliationIds();
const batchDeleteBtn = document.getElementById('batch-delete-btn');
if (batchDeleteBtn) {
if (selectedIds.length > 0) {
batchDeleteBtn.style.display = 'inline-block';
batchDeleteBtn.textContent = `批量删除 (${selectedIds.length})`;
} else {
batchDeleteBtn.style.display = 'none';
}
}
// 更新全选框状态
const selectAllCheckbox = document.getElementById('select-all-reconciliation');
const allCheckboxes = document.querySelectorAll('.reconciliation-checkbox');
if (selectAllCheckbox && allCheckboxes.length > 0) {
const allChecked = Array.from(allCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(allCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = someChecked && !allChecked;
}
}
// 批量删除对账单
async function batchDeleteReconciliations(ids) {
try {
API.toast('正在删除...', 'info');
const res = await fetch('/api/reconciliations/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(`成功删除 ${ids.length} 条对账单`, 'success');
await loadReconciliations();
} else {
API.toast(data.error || '批量删除失败', 'error');
}
} catch (err) {
console.error('批量删除对账单失败:', err);
API.toast('批量删除失败', 'error');
}
}
// 打开导出日期选择弹窗
function openExportDateModal() {
const modal = document.getElementById('export-date-modal');
document.getElementById('export-start-date').value = '';
document.getElementById('export-end-date').value = '';
modal.style.display = 'flex';
}
// 关闭导出日期选择弹窗
function closeExportDateModal() {
const modal = document.getElementById('export-date-modal');
modal.style.display = 'none';
}
// 确认导出
async function confirmExport() {
try {
const startDate = document.getElementById('export-start-date').value;
const endDate = document.getElementById('export-end-date').value;
// 验证日期范围
if (startDate && endDate && startDate > endDate) {
API.toast('开始日期不能晚于结束日期', 'error');
return;
}
// 关闭弹窗
closeExportDateModal();
API.toast('正在导出对账单...', 'info');
// 构建URL参数
let url = '/api/reconciliations/export';
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (params.toString()) {
url += '?' + params.toString();
}
const res = await fetch(url);
if (!res.ok) {
const data = await res.json();
API.toast(data.error || '导出失败', 'error');
return;
}
// 下载文件
const blob = await res.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
// 从响应头获取文件名,如果没有则使用默认名称
const contentDisposition = res.headers.get('Content-Disposition');
let filename = '对账单.xlsx';
if (contentDisposition) {
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
// 解码 URL 编码的文件名
filename = decodeURIComponent(filename);
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(blobUrl);
document.body.removeChild(a);
API.toast('导出成功', 'success');
} catch (err) {
console.error('导出对账单失败:', err);
API.toast('导出失败', 'error');
}
}
window.Reconciliation = {
openModal,
closeModal,
saveReconciliation,
editReconciliation,
deleteReconciliation,
updateBatchDeleteButton,
openExportDateModal,
closeExportDateModal,
confirmExport
};
})();