560 lines
22 KiB
JavaScript
560 lines
22 KiB
JavaScript
// 对账单管理
|
||
(() => {
|
||
Router.register('/plan-mgmt/reconciliation', async () => {
|
||
const html = `
|
||
<div class="page-header">
|
||
<h1>对账单管理</h1>
|
||
<div class="page-actions">
|
||
<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="card">
|
||
<div class="card-body">
|
||
<div class="table-container" style="max-height: calc(100vh - 200px); overflow-y: auto; padding-bottom: 5px;">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 40px;">
|
||
<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="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()">×</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>
|
||
`;
|
||
|
||
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', async () => {
|
||
try {
|
||
API.toast('正在导出对账单...', 'info');
|
||
|
||
const res = await fetch('/api/reconciliations/export');
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
API.toast(data.error || '导出失败', 'error');
|
||
return;
|
||
}
|
||
|
||
// 下载文件
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
|
||
// 从响应头获取文件名,如果没有则使用默认名称
|
||
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(url);
|
||
document.body.removeChild(a);
|
||
|
||
API.toast('导出成功', 'success');
|
||
} catch (err) {
|
||
console.error('导出对账单失败:', err);
|
||
API.toast('导出失败', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
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() {
|
||
try {
|
||
const res = await fetch('/api/reconciliations');
|
||
const data = await res.json();
|
||
|
||
const tbody = document.getElementById('reconciliation-list');
|
||
if (!tbody) return;
|
||
|
||
if (!data.list || data.list.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="14" class="text-center">暂无数据</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.list.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');
|
||
}
|
||
}
|
||
|
||
window.Reconciliation = {
|
||
openModal,
|
||
closeModal,
|
||
saveReconciliation,
|
||
editReconciliation,
|
||
deleteReconciliation,
|
||
updateBatchDeleteButton
|
||
};
|
||
})();
|