ERP/frontend/js/components/customer-order.js

729 lines
28 KiB
JavaScript
Raw Normal View History

2025-11-25 02:35:02 +00:00
// 客户订单管理
(() => {
Router.register('/plan-mgmt/customer-order', async () => {
// 先返回 HTML
const html = `
2025-12-30 06:35:09 +00:00
<style>
/* 完全重新设计页面布局 */
.view:has(#customer-order-page) {
padding: 0 !important;
display: flex;
flex-direction: column;
height: 100vh;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
2025-12-30 06:35:09 +00:00
#customer-order-page {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg);
}
#customer-order-page .page-header {
padding: 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#customer-order-page .content-area {
flex: 1;
padding: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
}
#customer-order-page .table-wrapper {
flex: 1;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
#customer-order-page table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
#customer-order-page thead {
background: var(--border);
position: sticky;
top: 0;
z-index: 10;
}
#customer-order-page th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 2px solid var(--border);
font-size: 14px;
}
#customer-order-page td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--text);
font-size: 14px;
}
#customer-order-page tbody tr:last-child td {
border-bottom: none;
}
#customer-order-page tbody tr:hover {
background: var(--hover);
}
#customer-order-page .text-center {
text-align: center;
color: var(--text-2);
}
2026-03-26 03:17:19 +00:00
#customer-order-page .filtered-row {
display: none !important;
}
2025-12-30 06:35:09 +00:00
</style>
2025-11-25 02:35:02 +00:00
2025-12-30 06:35:09 +00:00
<div id="customer-order-page">
<div class="page-header">
<h1 style="margin: 0; font-size: 24px;">客户订单管理</h1>
2026-03-26 03:17:19 +00:00
<div class="page-actions" style="margin-top: 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
2025-12-30 06:35:09 +00:00
<button id="add-order-btn" class="btn btn-primary">新增订单</button>
<button id="refresh-undelivered-btn" class="btn btn-secondary" title="刷新未交订单数据">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
刷新未交订单
</button>
2026-03-26 03:17:19 +00:00
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
<label style="font-size: 14px; color: var(--text-2);">物料筛选</label>
<input
type="text"
id="material-filter"
placeholder="输入物料名称筛选..."
class="input"
style="width: 200px; padding: 6px 12px; font-size: 14px;"
/>
<button id="clear-filter-btn" class="btn btn-secondary" style="padding: 6px 12px;" title="清除筛选"></button>
</div>
2025-12-30 06:35:09 +00:00
</div>
</div>
<div class="content-area">
<div class="table-wrapper">
<table>
2025-11-25 02:35:02 +00:00
<thead>
<tr>
<th>下单时间</th>
<th>订单编号</th>
<th>客户名称</th>
<th>物料</th>
<th>订单数量</th>
<th>已发货</th>
<th style="color: #f59e0b; font-weight: 700;">未交订单</th>
2025-11-25 02:35:02 +00:00
<th>单价</th>
<th>操作</th>
</tr>
</thead>
<tbody id="order-list">
<tr>
<td colspan="9" class="text-center">加载中...</td>
2025-11-25 02:35:02 +00:00
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 新增订单弹窗 -->
<div id="order-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header">
<h2 id="modal-title">新增订单</h2>
<button class="modal-close" onclick="window.CustomerOrder.closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="order-form">
<!-- 订单基本信息 -->
<div style="background: var(--surface); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border);">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: var(--text); font-weight: 600;">订单基本信息</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px;">
<div class="field" style="margin: 0;">
<label>下单时间 <span style="color: var(--danger);">*</span></label>
<input type="date" id="order-date" class="input" required />
</div>
<div class="field" style="margin: 0;">
<label>订单编号 <span style="color: var(--danger);">*</span></label>
<input type="text" id="order-no" class="input" placeholder="如CGDD001695" required />
</div>
<div class="field" style="margin: 0;">
<label>客户名称 <span style="color: var(--danger);">*</span></label>
<input type="text" id="customer-name" class="input" placeholder="如:易泰勒" required />
</div>
</div>
</div>
<!-- 物料列表 -->
<div style="background: var(--surface); padding: 20px; border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0; font-size: 16px; color: var(--text); font-weight: 600;">物料清单</h3>
<button type="button" class="btn btn-primary" onclick="window.CustomerOrder.addMaterialRow()">
添加物料
</button>
</div>
<div id="materials-container">
<!-- 物料行将动态添加到这里 -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="window.CustomerOrder.closeModal()">取消</button>
<button class="btn btn-primary" onclick="window.CustomerOrder.saveOrder()">保存订单</button>
</div>
</div>
</div>
`;
// DOM 渲染后初始化
setTimeout(() => {
const addBtn = document.getElementById('add-order-btn');
if (addBtn) {
addBtn.addEventListener('click', () => {
openModal();
});
}
const refreshBtn = document.getElementById('refresh-undelivered-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px; animation: spin 1s linear infinite;"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/></svg>刷新中...';
await loadOrders();
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/></svg>刷新未交订单';
API.toast('未交订单数据已更新', 'success');
});
}
2026-03-26 03:17:19 +00:00
// 物料筛选功能
const filterInput = document.getElementById('material-filter');
const clearFilterBtn = document.getElementById('clear-filter-btn');
if (filterInput) {
filterInput.addEventListener('input', (e) => {
filterOrders(e.target.value);
});
}
if (clearFilterBtn) {
clearFilterBtn.addEventListener('click', () => {
if (filterInput) {
filterInput.value = '';
filterOrders('');
}
});
}
2025-11-25 02:35:02 +00:00
loadOrders();
}, 100);
return html;
});
async function loadOrders() {
try {
console.log('开始加载订单列表...');
const res = await fetch('/api/customer-orders');
console.log('API响应状态:', res.status);
const data = await res.json();
console.log('订单数据:', data);
const tbody = document.getElementById('order-list');
if (!tbody) {
console.error('找不到 order-list 元素');
return;
}
if (!data.list || data.list.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">暂无数据</td></tr>';
2025-11-25 02:35:02 +00:00
return;
}
tbody.innerHTML = data.list.map(order => {
const undeliveredQty = order.undelivered_qty || 0;
const shippedQty = order.shipped_qty || 0;
2026-03-26 03:17:19 +00:00
const isManual = order.manual_undelivered_qty !== null && order.manual_undelivered_qty !== undefined;
const undeliveredStyle = undeliveredQty > 0 ? 'color: #f59e0b; font-weight: 700;' : 'color: #10b981;';
2026-03-26 03:17:19 +00:00
const manualIcon = isManual ? '<span title="手动设置" style="cursor: help; margin-left: 4px;">✏️</span>' : '';
return `
2026-03-26 03:17:19 +00:00
<tr data-material="${(order.material || '').replace(/"/g, '&quot;')}" data-order-id="${order.id}">
2025-11-25 02:35:02 +00:00
<td>${order.order_date || '—'}</td>
<td>${order.order_no || '—'}</td>
<td>${order.customer_name || '—'}</td>
2025-12-10 06:42:48 +00:00
<td style="white-space: pre-line; word-break: break-word;">${order.material || '—'}</td>
2025-11-25 02:35:02 +00:00
<td>${order.quantity || 0}</td>
<td style="color: #3b82f6;">${shippedQty}</td>
2026-03-26 03:17:19 +00:00
<td style="${undeliveredStyle}"
onclick="window.CustomerOrder.editUndelivered(${order.id}, ${undeliveredQty}, ${order.auto_undelivered_qty || 0})"
title="点击编辑未交订单数量(自动计算: ${order.auto_undelivered_qty || 0}"
style="cursor: pointer; ${undeliveredStyle}">
${undeliveredQty}${manualIcon}
</td>
2025-11-25 02:35:02 +00:00
<td>${order.unit_price || 0}</td>
<td>
<button class="btn-text" onclick="window.CustomerOrder.editOrder(${order.id})">编辑</button>
2025-11-25 02:35:02 +00:00
<button class="btn-text btn-danger" onclick="window.CustomerOrder.deleteOrder(${order.id})">删除</button>
</td>
</tr>
`}).join('');
2026-03-26 03:17:19 +00:00
// 应用当前筛选(如果有)
const filterInput = document.getElementById('material-filter');
if (filterInput && filterInput.value) {
filterOrders(filterInput.value);
}
2025-11-25 02:35:02 +00:00
console.log('订单列表加载完成');
} catch (err) {
console.error('加载订单失败:', err);
const tbody = document.getElementById('order-list');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center" style="color: red;">加载失败,请刷新重试</td></tr>';
2025-11-25 02:35:02 +00:00
}
API.toast('加载订单失败', 'error');
}
}
2026-03-26 03:17:19 +00:00
function filterOrders(keyword) {
const tbody = document.getElementById('order-list');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
const filterText = keyword.toLowerCase().trim();
let visibleCount = 0;
rows.forEach(row => {
// 跳过空数据行
if (row.querySelector('.text-center')) {
return;
}
const material = (row.getAttribute('data-material') || '').toLowerCase();
if (!filterText || material.includes(filterText)) {
row.classList.remove('filtered-row');
visibleCount++;
} else {
row.classList.add('filtered-row');
}
});
// 如果没有匹配结果,显示提示
if (visibleCount === 0 && filterText) {
const existingMsg = tbody.querySelector('.no-filter-results');
if (!existingMsg) {
const msgRow = document.createElement('tr');
msgRow.className = 'no-filter-results';
msgRow.innerHTML = '<td colspan="9" class="text-center" style="color: var(--text-2);">没有匹配的物料</td>';
tbody.appendChild(msgRow);
}
} else {
const msgRow = tbody.querySelector('.no-filter-results');
if (msgRow) {
msgRow.remove();
}
}
}
2025-11-25 02:35:02 +00:00
let materialRowIndex = 0;
let productList = []; // 缓存BOM产品列表
async function loadProductList() {
try {
const res = await fetch('/api/bom/products');
const data = await res.json();
productList = (data.list || []).map(p => p.product_name).filter(Boolean);
// 同时从已有订单中提取物料名称(去重)
const orderRes = await fetch('/api/customer-orders');
const orderData = await orderRes.json();
if (orderData.list) {
for (const o of orderData.list) {
if (o.material && !productList.includes(o.material)) {
productList.push(o.material);
}
}
}
} catch (e) {
console.error('加载产品列表失败:', e);
}
}
function buildMaterialOptions() {
return productList.map(name => `<option value="${name}">`).join('');
}
2025-11-25 02:35:02 +00:00
function addMaterialRow(material = '', quantity = '', unitPrice = '') {
const container = document.getElementById('materials-container');
const index = materialRowIndex++;
const datalistId = `material-options-${index}`;
2025-11-25 02:35:02 +00:00
const row = document.createElement('div');
row.className = 'material-row';
row.id = `material-row-${index}`;
row.style.cssText = 'display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 12px; margin-bottom: 12px; padding: 16px; background: var(--surface-2); border-radius: 8px; border: 1px solid var(--border);';
row.innerHTML = `
<div class="field" style="margin: 0;">
<label>物料名称 <span style="color: var(--danger);">*</span></label>
<input type="text" class="input material-name" list="${datalistId}" placeholder="输入或选择物料名称" value="${material}" required autocomplete="off" />
<datalist id="${datalistId}">${buildMaterialOptions()}</datalist>
2025-11-25 02:35:02 +00:00
</div>
<div class="field" style="margin: 0;">
<label>数量 <span style="color: var(--danger);">*</span></label>
<input type="number" class="input material-quantity" placeholder="数量" value="${quantity}" min="1" required />
</div>
<div class="field" style="margin: 0;">
<label>单价 <span style="color: var(--danger);">*</span></label>
<input type="number" class="input material-price" placeholder="单价" value="${unitPrice}" step="0.01" min="0" required />
</div>
<div style="display: flex; align-items: flex-end; padding-bottom: 2px;">
<button type="button" class="btn btn-danger" onclick="window.CustomerOrder.removeMaterialRow(${index})" style="white-space: nowrap;">
🗑 删除
</button>
</div>
`;
container.appendChild(row);
}
function removeMaterialRow(index) {
const row = document.getElementById(`material-row-${index}`);
if (row) {
row.remove();
}
// 如果没有物料行了,至少保留一行
const container = document.getElementById('materials-container');
if (container.children.length === 0) {
addMaterialRow();
}
}
let currentEditId = null;
async function openModal(order = null) {
// 加载产品列表用于下拉选择
await loadProductList();
2025-11-25 02:35:02 +00:00
const modal = document.getElementById('order-modal');
const title = document.getElementById('modal-title');
// 清空物料列表
2025-11-25 02:35:02 +00:00
const container = document.getElementById('materials-container');
container.innerHTML = '';
materialRowIndex = 0;
if (order) {
// 编辑模式
title.textContent = '编辑订单';
currentEditId = order.id;
document.getElementById('order-date').value = order.order_date || '';
document.getElementById('order-no').value = order.order_no || '';
document.getElementById('customer-name').value = order.customer_name || '';
// 添加物料信息
addMaterialRow(order.material || '', order.quantity || '', order.unit_price || '');
} else {
// 新增模式
title.textContent = '新增订单';
currentEditId = null;
document.getElementById('order-form').reset();
// 设置默认日期为今天
const today = new Date().toISOString().split('T')[0];
document.getElementById('order-date').value = today;
// 添加一行空物料
addMaterialRow();
}
2025-11-25 02:35:02 +00:00
modal.style.display = 'flex';
}
function closeModal() {
const modal = document.getElementById('order-modal');
modal.style.display = 'none';
document.getElementById('order-form').reset();
// 清空物料列表
const container = document.getElementById('materials-container');
if (container) {
container.innerHTML = '';
}
materialRowIndex = 0;
}
async function saveOrder() {
const orderDate = document.getElementById('order-date').value.trim();
const orderNo = document.getElementById('order-no').value.trim();
const customerName = document.getElementById('customer-name').value.trim();
if (!orderDate || !orderNo || !customerName) {
API.toast('请填写订单基本信息', 'error');
return;
}
// 收集所有物料信息
const materials = [];
const materialRows = document.querySelectorAll('.material-row');
if (materialRows.length === 0) {
API.toast('请至少添加一个物料', 'error');
return;
}
for (const row of materialRows) {
const materialName = row.querySelector('.material-name').value.trim();
const quantity = parseInt(row.querySelector('.material-quantity').value);
const unitPrice = parseFloat(row.querySelector('.material-price').value);
if (!materialName || !quantity || isNaN(unitPrice)) {
API.toast('请填写所有物料信息', 'error');
return;
}
if (quantity <= 0) {
API.toast('物料数量必须大于0', 'error');
return;
}
if (unitPrice < 0) {
API.toast('单价不能为负数', 'error');
return;
}
materials.push({
material: materialName,
quantity: quantity,
unit_price: unitPrice
});
}
// 保存前确认金额
let totalAmount = 0;
const detailLines = materials.map(m => {
const amount = m.quantity * m.unit_price;
totalAmount += amount;
return `<tr><td style="padding:6px 12px;">${m.material}</td><td style="padding:6px 12px;text-align:right;">${m.quantity}</td><td style="padding:6px 12px;text-align:right;">${m.unit_price}</td><td style="padding:6px 12px;text-align:right;font-weight:600;">${amount.toFixed(2)}</td></tr>`;
}).join('');
const confirmed = await new Promise(resolve => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10000;';
overlay.innerHTML = `
<div style="background:var(--surface,#fff);border-radius:12px;padding:24px;max-width:600px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">
<h3 style="margin:0 0 16px 0;font-size:18px;">确认订单金额</h3>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
<thead><tr style="background:var(--border,#eee);">
<th style="padding:8px 12px;text-align:left;">物料</th>
<th style="padding:8px 12px;text-align:right;">数量</th>
<th style="padding:8px 12px;text-align:right;">单价</th>
<th style="padding:8px 12px;text-align:right;">金额</th>
</tr></thead>
<tbody>${detailLines}</tbody>
<tfoot><tr style="border-top:2px solid var(--border,#ccc);">
<td colspan="3" style="padding:8px 12px;font-weight:700;font-size:16px;">合计</td>
<td style="padding:8px 12px;text-align:right;font-weight:700;font-size:16px;color:var(--primary,#1989FA);">${totalAmount.toFixed(2)}</td>
</tr></tfoot>
</table>
<div style="display:flex;justify-content:flex-end;gap:12px;">
<button id="confirm-cancel" class="btn btn-secondary" style="padding:8px 24px;">取消</button>
<button id="confirm-ok" class="btn btn-primary" style="padding:8px 24px;">确认保存</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-ok').onclick = () => { document.body.removeChild(overlay); resolve(true); };
overlay.querySelector('#confirm-cancel').onclick = () => { document.body.removeChild(overlay); resolve(false); };
});
if (!confirmed) return;
2025-11-25 02:35:02 +00:00
try {
if (currentEditId) {
// 编辑模式:只能编辑单条记录
if (materials.length !== 1) {
API.toast('编辑模式下只能有一个物料', 'error');
return;
}
const mat = materials[0];
const res = await fetch(`/api/customer-orders/${currentEditId}`, {
method: 'PUT',
2025-11-25 02:35:02 +00:00
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_date: orderDate,
order_no: orderNo,
customer_name: customerName,
material: mat.material,
quantity: mat.quantity,
unit_price: mat.unit_price
})
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('更新成功', 'success');
closeModal();
await loadOrders();
2025-11-25 02:35:02 +00:00
} else {
API.toast(data.error || '更新失败', 'error');
2025-11-25 02:35:02 +00:00
}
} else {
// 新增模式:为每个物料创建一条订单记录
let successCount = 0;
for (const mat of materials) {
const res = await fetch('/api/customer-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_date: orderDate,
order_no: orderNo,
customer_name: customerName,
material: mat.material,
quantity: mat.quantity,
unit_price: mat.unit_price
})
});
const data = await res.json();
if (res.ok && data.ok) {
successCount++;
} else {
console.error('保存物料失败:', mat.material, data.error);
}
}
if (successCount === materials.length) {
API.toast(`订单保存成功,共 ${successCount} 个物料`, 'success');
closeModal();
await loadOrders();
} else if (successCount > 0) {
API.toast(`部分保存成功(${successCount}/${materials.length}`, 'warning');
closeModal();
await loadOrders();
} else {
API.toast('保存失败', 'error');
}
2025-11-25 02:35:02 +00:00
}
} catch (err) {
console.error('保存订单失败:', err);
API.toast('保存失败', 'error');
}
}
async function deleteOrder(id) {
if (!confirm('确定要删除这条订单吗?')) {
return;
}
try {
const res = await fetch(`/api/customer-orders/${id}`, {
method: 'DELETE'
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast('删除成功', 'success');
await loadOrders();
} else {
API.toast(data.error || '删除失败', 'error');
}
} catch (err) {
console.error('删除订单失败:', err);
API.toast('删除失败', 'error');
}
}
2026-03-26 03:17:19 +00:00
async function editUndelivered(orderId, currentQty, autoQty) {
const newQty = prompt(
`编辑未交订单数量\n\n当前值: ${currentQty}\n自动计算值: ${autoQty}\n\n请输入新的未交订单数量(留空恢复自动计算):`,
currentQty
);
// 用户取消
if (newQty === null) {
return;
}
// 用户输入空值,恢复自动计算
const finalQty = newQty.trim() === '' ? null : newQty.trim();
try {
const res = await fetch(`/api/customer-orders/${orderId}/undelivered`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ undelivered_qty: finalQty })
});
const data = await res.json();
if (res.ok && data.ok) {
API.toast(finalQty === null ? '已恢复自动计算' : '未交订单数量已更新', 'success');
await loadOrders();
} else {
API.toast(data.error || '更新失败', 'error');
}
} catch (err) {
console.error('更新未交订单数量失败:', err);
API.toast('更新失败', 'error');
}
}
async function editOrder(id) {
try {
const res = await fetch('/api/customer-orders');
const data = await res.json();
const order = data.list.find(o => o.id === id);
if (!order) {
API.toast('订单不存在', 'error');
return;
}
openModal(order);
} catch (err) {
console.error('加载订单失败:', err);
API.toast('加载失败', 'error');
}
}
2025-11-25 02:35:02 +00:00
// 暴露给全局
window.CustomerOrder = {
openModal,
closeModal,
saveOrder,
editOrder,
2025-11-25 02:35:02 +00:00
deleteOrder,
2026-03-26 03:17:19 +00:00
editUndelivered,
2025-11-25 02:35:02 +00:00
addMaterialRow,
removeMaterialRow
};
})();