ERP/frontend/js/components/reconciliation.js
2025-12-08 11:20:28 +08:00

560 lines
22 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 = `
<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()">&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>
`;
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
};
})();