420 lines
16 KiB
JavaScript
420 lines
16 KiB
JavaScript
// 采购需求清单管理
|
||
(() => {
|
||
let demandList = [];
|
||
let productList = [];
|
||
let currentPage = 1;
|
||
const pageSize = 20;
|
||
|
||
const statusMap = {
|
||
'pending': { text: '待处理', color: 'var(--warning)' },
|
||
'ordered': { text: '已下单', color: 'var(--info)' },
|
||
'received': { text: '已收货', color: 'var(--primary)' },
|
||
'completed': { text: '已完成', color: 'var(--success)' },
|
||
'cancelled': { text: '已取消', color: 'var(--text-2)' }
|
||
};
|
||
|
||
Router.register('/plan-mgmt/purchase-demand', async () => {
|
||
const html = `
|
||
<div class="page-header">
|
||
<h1>采购需求清单</h1>
|
||
<div class="page-actions">
|
||
<button id="calc-demand-btn" class="btn btn-primary">计算采购需求</button>
|
||
<button id="calc-from-orders-btn" class="btn btn-secondary">从订单生成</button>
|
||
<button id="batch-delete-btn" class="btn btn-danger">批量删除</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 计算说明卡片 -->
|
||
<div class="card" style="margin-bottom: 16px; background: var(--info-bg); border: 1px solid var(--info);">
|
||
<div class="card-body" style="padding: 12px 16px;">
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span style="font-size: 20px;">💡</span>
|
||
<div>
|
||
<strong style="color: var(--text);">采购需求计算公式:</strong>
|
||
<span style="color: var(--text);">客户订单数量 × BOM单机用量 - 期初库存 = 净需求 → 按最小包装向上取整 = 实际采购数量</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="filter-section" style="margin-bottom: 16px;">
|
||
<div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center;">
|
||
<input type="text" id="search-keyword" class="input" placeholder="搜索物料编码/名称/需求编号..." style="width: 280px;" />
|
||
<select id="filter-status" class="input" style="width: 120px;">
|
||
<option value="">全部状态</option>
|
||
<option value="pending">待处理</option>
|
||
<option value="ordered">已下单</option>
|
||
<option value="received">已收货</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="cancelled">已取消</option>
|
||
</select>
|
||
<button class="btn btn-primary" onclick="PurchaseDemand.search()">搜索</button>
|
||
<button class="btn btn-secondary" onclick="PurchaseDemand.resetSearch()">重置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-container" style="overflow-x: auto;">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 40px;"><label class="custom-checkbox"><input type="checkbox" id="select-all" onchange="PurchaseDemand.toggleSelectAll(this)" /><span class="checkmark"></span></label></th>
|
||
<th>需求编号</th>
|
||
<th>物料编码</th>
|
||
<th>物料名称</th>
|
||
<th>订单数量</th>
|
||
<th>BOM用量</th>
|
||
<th>总需求</th>
|
||
<th>期初库存</th>
|
||
<th>净需求</th>
|
||
<th>最小包装</th>
|
||
<th style="background: var(--success-bg); color: var(--text);">实际采购</th>
|
||
<th>单位</th>
|
||
<th>供应商</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="demand-list">
|
||
<tr><td colspan="15" class="text-center">加载中...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="pagination" id="pagination" style="margin-top: 16px; display: flex; justify-content: center; gap: 8px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 计算采购需求弹窗 -->
|
||
<div id="calc-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="PurchaseDemand.closeCalcModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field">
|
||
<label>选择产品 <span style="color: var(--danger);">*</span></label>
|
||
<select id="product-select" class="input">
|
||
<option value="">请选择产品</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>订单数量 <span style="color: var(--danger);">*</span></label>
|
||
<input type="number" id="order-qty" class="input" min="1" value="1" placeholder="输入客户订单数量" />
|
||
</div>
|
||
<div style="margin-top: 16px; padding: 12px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border);">
|
||
<div style="font-size: 14px; color: var(--text-2);">
|
||
<p style="margin: 0 0 8px 0;">📋 计算将:</p>
|
||
<ul style="margin: 0; padding-left: 20px;">
|
||
<li>根据产品的BOM展开所有物料</li>
|
||
<li>计算每种物料的总需求量</li>
|
||
<li>扣减期初库存得出净需求</li>
|
||
<li>按最小包装取整得出实际采购量</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="PurchaseDemand.closeCalcModal()">取消</button>
|
||
<button class="btn btn-primary" onclick="PurchaseDemand.doCalculate()">开始计算</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 更新状态弹窗 -->
|
||
<div id="status-modal" class="modal" style="display:none;">
|
||
<div class="modal-content" style="max-width: 400px;">
|
||
<div class="modal-header">
|
||
<h2>更新状态</h2>
|
||
<button class="modal-close" onclick="PurchaseDemand.closeStatusModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="field">
|
||
<label>状态</label>
|
||
<select id="update-status" class="input">
|
||
<option value="pending">待处理</option>
|
||
<option value="ordered">已下单</option>
|
||
<option value="received">已收货</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="cancelled">已取消</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>备注</label>
|
||
<textarea id="update-remark" class="input" rows="3" placeholder="可选填写备注信息"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="PurchaseDemand.closeStatusModal()">取消</button>
|
||
<button class="btn btn-primary" onclick="PurchaseDemand.doUpdateStatus()">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
setTimeout(() => {
|
||
document.getElementById('calc-demand-btn')?.addEventListener('click', () => openCalcModal());
|
||
document.getElementById('calc-from-orders-btn')?.addEventListener('click', () => calcFromOrders());
|
||
document.getElementById('batch-delete-btn')?.addEventListener('click', () => batchDelete());
|
||
document.getElementById('search-keyword')?.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') PurchaseDemand.search();
|
||
});
|
||
loadList();
|
||
loadProducts();
|
||
}, 100);
|
||
|
||
return html;
|
||
});
|
||
|
||
async function loadList() {
|
||
try {
|
||
const res = await API.get('/api/purchase-demand');
|
||
demandList = res.list || [];
|
||
renderList();
|
||
} catch (e) {
|
||
console.error('加载采购需求失败:', e);
|
||
document.getElementById('demand-list').innerHTML = '<tr><td colspan="15" class="text-center" style="color: var(--danger);">加载失败</td></tr>';
|
||
}
|
||
}
|
||
|
||
async function loadProducts() {
|
||
try {
|
||
const res = await API.get('/api/bom/products');
|
||
productList = res.list || [];
|
||
const select = document.getElementById('product-select');
|
||
if (select) {
|
||
select.innerHTML = '<option value="">请选择产品</option>' +
|
||
productList.map(p => `<option value="${escapeHtml(p.product_code)}">${escapeHtml(p.product_code)} - ${escapeHtml(p.product_name)}</option>`).join('');
|
||
}
|
||
} catch (e) {
|
||
console.error('加载产品列表失败:', e);
|
||
}
|
||
}
|
||
|
||
function renderList() {
|
||
const tbody = document.getElementById('demand-list');
|
||
const keyword = (document.getElementById('search-keyword')?.value || '').toLowerCase();
|
||
const filterStatus = document.getElementById('filter-status')?.value || '';
|
||
|
||
let filtered = demandList;
|
||
if (keyword) {
|
||
filtered = filtered.filter(item =>
|
||
(item.demand_no || '').toLowerCase().includes(keyword) ||
|
||
(item.material_code || '').toLowerCase().includes(keyword) ||
|
||
(item.material_name || '').toLowerCase().includes(keyword)
|
||
);
|
||
}
|
||
if (filterStatus) {
|
||
filtered = filtered.filter(item => item.status === filterStatus);
|
||
}
|
||
|
||
const totalPages = Math.ceil(filtered.length / pageSize);
|
||
const start = (currentPage - 1) * pageSize;
|
||
const pageData = filtered.slice(start, start + pageSize);
|
||
|
||
if (pageData.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="15" class="text-center">暂无数据</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = pageData.map(item => {
|
||
const status = statusMap[item.status] || { text: item.status, color: 'var(--text-2)' };
|
||
return `
|
||
<tr>
|
||
<td><label class="custom-checkbox"><input type="checkbox" class="row-checkbox" data-id="${item.id}" /><span class="checkmark"></span></label></td>
|
||
<td><span style="font-family: monospace; font-size: 12px;">${escapeHtml(item.demand_no || '')}</span></td>
|
||
<td>${escapeHtml(item.material_code || '')}</td>
|
||
<td>${escapeHtml(item.material_name || '')}</td>
|
||
<td>${item.order_qty || 0}</td>
|
||
<td>${item.bom_unit_qty || 1}</td>
|
||
<td>${item.total_demand || 0}</td>
|
||
<td style="color: var(--info);">${item.initial_stock || 0}</td>
|
||
<td style="color: ${item.net_demand > 0 ? 'var(--warning)' : 'var(--success)'};">${item.net_demand || 0}</td>
|
||
<td>${item.min_package || 1}</td>
|
||
<td style="font-weight: 700; color: var(--text); background: var(--success-bg);">${item.actual_purchase_qty || 0}</td>
|
||
<td>${escapeHtml(item.unit || 'pcs')}</td>
|
||
<td>${escapeHtml(item.supplier || '-')}</td>
|
||
<td><span style="padding: 2px 8px; border-radius: 4px; background: ${status.color}20; color: ${status.color}; font-size: 12px;">${status.text}</span></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-secondary" onclick="PurchaseDemand.updateStatus(${item.id})">状态</button>
|
||
<button class="btn btn-sm btn-danger" onclick="PurchaseDemand.delete(${item.id})">删除</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
renderPagination(totalPages);
|
||
}
|
||
|
||
function renderPagination(totalPages) {
|
||
const container = document.getElementById('pagination');
|
||
if (totalPages <= 1) {
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
html += `<button class="btn btn-sm btn-secondary" ${currentPage <= 1 ? 'disabled' : ''} onclick="PurchaseDemand.goPage(${currentPage - 1})">上一页</button>`;
|
||
html += `<span style="padding: 0 12px; line-height: 32px;">第 ${currentPage} / ${totalPages} 页</span>`;
|
||
html += `<button class="btn btn-sm btn-secondary" ${currentPage >= totalPages ? 'disabled' : ''} onclick="PurchaseDemand.goPage(${currentPage + 1})">下一页</button>`;
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function openCalcModal() {
|
||
document.getElementById('product-select').value = '';
|
||
document.getElementById('order-qty').value = 1;
|
||
document.getElementById('calc-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeCalcModal() {
|
||
document.getElementById('calc-modal').style.display = 'none';
|
||
}
|
||
|
||
async function doCalculate() {
|
||
const productCode = document.getElementById('product-select').value;
|
||
const orderQty = parseInt(document.getElementById('order-qty').value) || 0;
|
||
|
||
if (!productCode) {
|
||
alert('请选择产品');
|
||
return;
|
||
}
|
||
if (orderQty <= 0) {
|
||
alert('订单数量必须大于0');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await API.post('/api/purchase-demand/calculate', {
|
||
product_code: productCode,
|
||
order_qty: orderQty
|
||
});
|
||
|
||
closeCalcModal();
|
||
alert(res.message || '计算完成');
|
||
loadList();
|
||
|
||
// 显示计算结果摘要
|
||
if (res.list && res.list.length > 0) {
|
||
const totalPurchase = res.list.reduce((sum, item) => sum + (item.actual_purchase_qty || 0), 0);
|
||
console.log(`采购需求计算完成,需求编号: ${res.demand_no},共 ${res.list.length} 种物料,总采购数量: ${totalPurchase}`);
|
||
}
|
||
} catch (e) {
|
||
alert(e.message || '计算失败');
|
||
}
|
||
}
|
||
|
||
async function calcFromOrders() {
|
||
if (!confirm('将根据所有客户订单自动计算采购需求,确定继续吗?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await API.post('/api/purchase-demand/calculate-from-orders', {});
|
||
alert(res.message || '计算完成');
|
||
loadList();
|
||
} catch (e) {
|
||
alert(e.message || '计算失败');
|
||
}
|
||
}
|
||
|
||
let updatingId = null;
|
||
|
||
function updateStatus(id) {
|
||
updatingId = id;
|
||
const item = demandList.find(x => x.id === id);
|
||
document.getElementById('update-status').value = item?.status || 'pending';
|
||
document.getElementById('update-remark').value = item?.remark || '';
|
||
document.getElementById('status-modal').style.display = 'flex';
|
||
}
|
||
|
||
function closeStatusModal() {
|
||
document.getElementById('status-modal').style.display = 'none';
|
||
updatingId = null;
|
||
}
|
||
|
||
async function doUpdateStatus() {
|
||
if (!updatingId) return;
|
||
|
||
const status = document.getElementById('update-status').value;
|
||
const remark = document.getElementById('update-remark').value.trim();
|
||
|
||
try {
|
||
await API.put(`/api/purchase-demand/${updatingId}`, { status, remark });
|
||
closeStatusModal();
|
||
alert('更新成功');
|
||
loadList();
|
||
} catch (e) {
|
||
alert(e.message || '更新失败');
|
||
}
|
||
}
|
||
|
||
async function deleteDemand(id) {
|
||
if (!confirm('确定要删除这条采购需求吗?')) return;
|
||
try {
|
||
await API.delete(`/api/purchase-demand/${id}`);
|
||
alert('删除成功');
|
||
loadList();
|
||
} catch (e) {
|
||
alert(e.message || '删除失败');
|
||
}
|
||
}
|
||
|
||
async function batchDelete() {
|
||
const checked = document.querySelectorAll('.row-checkbox:checked');
|
||
if (checked.length === 0) {
|
||
alert('请先选择要删除的项');
|
||
return;
|
||
}
|
||
if (!confirm(`确定要删除选中的 ${checked.length} 条采购需求吗?`)) return;
|
||
|
||
const ids = Array.from(checked).map(cb => parseInt(cb.dataset.id));
|
||
try {
|
||
await API.post('/api/purchase-demand/batch-delete', { ids });
|
||
alert('批量删除成功');
|
||
loadList();
|
||
} catch (e) {
|
||
alert(e.message || '批量删除失败');
|
||
}
|
||
}
|
||
|
||
function toggleSelectAll(checkbox) {
|
||
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = checkbox.checked);
|
||
}
|
||
|
||
function search() {
|
||
currentPage = 1;
|
||
renderList();
|
||
}
|
||
|
||
function resetSearch() {
|
||
document.getElementById('search-keyword').value = '';
|
||
document.getElementById('filter-status').value = '';
|
||
currentPage = 1;
|
||
renderList();
|
||
}
|
||
|
||
function goPage(page) {
|
||
currentPage = page;
|
||
renderList();
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
window.PurchaseDemand = {
|
||
search,
|
||
resetSearch,
|
||
delete: deleteDemand,
|
||
updateStatus,
|
||
closeCalcModal,
|
||
closeStatusModal,
|
||
doCalculate,
|
||
doUpdateStatus,
|
||
toggleSelectAll,
|
||
goPage
|
||
};
|
||
})();
|