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

1321 lines
52 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/material-purchase', async () => {
return `
<div class="page-container">
<div class="page-header">
<div>
<h2>物料清单-采购</h2>
<span id="selection-info" class="selection-info" style="display: none;">已选择 0 项</span>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="MaterialPurchase.showAddDialog()">
<span></span> 新增物料需求
</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.showImportDialog()" title="从Excel导入">
<span>📥</span> 导入Excel
</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.exportSelected()" title="导出选中项">
<span>📤</span> 导出选中
</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.exportData()">
<span>📤</span> 全部导出
</button>
<button class="btn btn-danger" onclick="MaterialPurchase.deleteSelected()" title="删除选中项">
<span>🗑️</span> 批量删除
</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.showRecycleBin()" title="查看回收站">
<span>♻️</span> 回收站
</button>
</div>
</div>
<div class="filter-section">
<div class="filter-row">
<input type="text" id="search-keyword" placeholder="搜索标题、编号、物料编码..." class="input-field" />
<select id="filter-status" class="input-field">
<option value="">全部状态</option>
<option value="pending">待处理</option>
<option value="processing">处理中</option>
<option value="completed">已完成</option>
</select>
<select id="filter-demand-status" class="input-field">
<option value="">全部需求状态</option>
<option value="urgent">紧急</option>
<option value="normal">正常</option>
<option value="low">低优先级</option>
</select>
<button class="btn btn-primary" onclick="MaterialPurchase.search()">搜索</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.resetFilter()">重置</button>
</div>
</div>
<div class="table-container" style="overflow-x: auto;">
<table class="data-table" id="material-table">
<thead>
<tr>
<th style="min-width: 50px; text-align: center;">
<label class="custom-checkbox"><input type="checkbox" id="select-all-checkbox" onchange="MaterialPurchase.toggleSelectAll(this)" /><span class="checkmark"></span></label>
</th>
<th style="min-width: 150px;">标题</th>
<th style="min-width: 180px;">生产计划明细物料需求清单编号</th>
<th style="min-width: 150px;">生产计划编号</th>
<th style="min-width: 150px;">产品BOM分析结果</th>
<th style="min-width: 100px;">状态</th>
<th style="min-width: 120px;">需求状态</th>
<th style="min-width: 120px;">物料齐套率</th>
<th style="min-width: 120px;">物料编码</th>
<th style="min-width: 150px;">物料名称</th>
<th style="min-width: 120px;">物料批次号</th>
<th style="min-width: 100px;">物料层级</th>
<th style="min-width: 120px;">所需物料数</th>
<th style="min-width: 140px;">库存现有物料数</th>
<th style="min-width: 100px;">欠缺值</th>
<th style="min-width: 140px;">物料获取方式</th>
<th style="min-width: 120px;">实时库存值</th>
<th style="min-width: 120px;">待入库数量</th>
<th style="min-width: 120px;">派发数量</th>
<th style="min-width: 120px;">入库数量</th>
<th style="min-width: 100px;">提交人</th>
<th style="min-width: 160px;">提交时间</th>
<th style="min-width: 160px;">更新时间</th>
<th style="min-width: 120px;">操作</th>
</tr>
</thead>
<tbody id="material-tbody">
<tr>
<td colspan="24" style="text-align: center; padding: 40px;">
<div style="color: var(--text-2);">暂无数据</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination">
<button class="btn btn-secondary" onclick="MaterialPurchase.prevPage()" id="prev-btn" disabled>上一页</button>
<span id="page-info">第 1 页 / 共 1 页</span>
<button class="btn btn-secondary" onclick="MaterialPurchase.nextPage()" id="next-btn" disabled>下一页</button>
</div>
</div>
<!-- 导出对话框 -->
<div id="export-dialog" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 700px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header">
<h3>选择导出字段</h3>
<button class="modal-close" onclick="MaterialPurchase.closeExportDialog()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<button class="btn btn-secondary" onclick="MaterialPurchase.selectAllFields()">全选</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.deselectAllFields()">取消全选</button>
</div>
<div class="export-fields-grid">
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="title" data-label="标题" /><span class="field-checkmark"></span>
<span>标题</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="listNo" data-label="生产计划明细物料需求清单编号" /><span class="field-checkmark"></span>
<span>生产计划明细物料需求清单编号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="planNo" data-label="生产计划编号" /><span class="field-checkmark"></span>
<span>生产计划编号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="bomResult" data-label="产品BOM分析结果" /><span class="field-checkmark"></span>
<span>产品BOM分析结果</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="status" data-label="状态" /><span class="field-checkmark"></span>
<span>状态</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="demandStatus" data-label="需求状态" /><span class="field-checkmark"></span>
<span>需求状态</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="completeRate" data-label="物料齐套率" /><span class="field-checkmark"></span>
<span>物料齐套率</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="materialCode" data-label="物料编码" /><span class="field-checkmark"></span>
<span>物料编码</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="materialName" data-label="物料名称" /><span class="field-checkmark"></span>
<span>物料名称</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="batchNo" data-label="物料批次号" /><span class="field-checkmark"></span>
<span>物料批次号</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="level" data-label="物料层级" /><span class="field-checkmark"></span>
<span>物料层级</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="requiredQty" data-label="所需物料数" /><span class="field-checkmark"></span>
<span>所需物料数</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="stockQty" data-label="库存现有物料数" /><span class="field-checkmark"></span>
<span>库存现有物料数</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="shortage" data-label="欠缺值" /><span class="field-checkmark"></span>
<span>欠缺值</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="acquireMethod" data-label="物料获取方式" /><span class="field-checkmark"></span>
<span>物料获取方式</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="realtimeStock" data-label="实时库存值" /><span class="field-checkmark"></span>
<span>实时库存值</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="pendingQty" data-label="待入库数量" /><span class="field-checkmark"></span>
<span>待入库数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="dispatchedQty" data-label="派发数量" /><span class="field-checkmark"></span>
<span>派发数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="receivedQty" data-label="入库数量" /><span class="field-checkmark"></span>
<span>入库数量</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="submitter" data-label="提交人" /><span class="field-checkmark"></span>
<span>提交人</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="submitTime" data-label="提交时间" /><span class="field-checkmark"></span>
<span>提交时间</span>
</label>
<label class="export-field-item">
<input type="checkbox" class="export-field-checkbox" value="updateTime" data-label="更新时间" /><span class="field-checkmark"></span>
<span>更新时间</span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="MaterialPurchase.closeExportDialog()">取消</button>
<button class="btn btn-primary" onclick="MaterialPurchase.performExport()">
<span>📤</span> 导出Excel
</button>
</div>
</div>
</div>
<!-- 新增/编辑对话框 -->
<div id="material-dialog" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header">
<h3 id="dialog-title">新增物料需求</h3>
<button class="modal-close" onclick="MaterialPurchase.closeDialog()">×</button>
</div>
<div class="modal-body">
<form id="material-form">
<div class="form-grid">
<div class="form-group">
<label>标题 <span class="required">*</span></label>
<input type="text" id="form-title" class="input-field" required />
</div>
<div class="form-group">
<label>生产计划明细物料需求清单编号 <span class="required">*</span></label>
<input type="text" id="form-list-no" class="input-field" required />
</div>
<div class="form-group">
<label>生产计划编号 <span class="required">*</span></label>
<input type="text" id="form-plan-no" class="input-field" required />
</div>
<div class="form-group">
<label>产品BOM分析结果</label>
<input type="text" id="form-bom-result" class="input-field" />
</div>
<div class="form-group">
<label>状态 <span class="required">*</span></label>
<select id="form-status" class="input-field" required>
<option value="pending">待处理</option>
<option value="processing">处理中</option>
<option value="completed">已完成</option>
</select>
</div>
<div class="form-group">
<label>需求状态 <span class="required">*</span></label>
<select id="form-demand-status" class="input-field" required>
<option value="urgent">紧急</option>
<option value="normal">正常</option>
<option value="low">低优先级</option>
</select>
</div>
<div class="form-group">
<label>物料齐套率 (%)</label>
<input type="number" id="form-complete-rate" class="input-field" min="0" max="100" step="0.01" />
</div>
<div class="form-group">
<label>物料编码 <span class="required">*</span></label>
<input type="text" id="form-material-code" class="input-field" required />
</div>
<div class="form-group">
<label>物料名称 <span class="required">*</span></label>
<input type="text" id="form-material-name" class="input-field" required />
</div>
<div class="form-group">
<label>物料批次号</label>
<input type="text" id="form-batch-no" class="input-field" />
</div>
<div class="form-group">
<label>物料层级</label>
<input type="number" id="form-level" class="input-field" min="1" />
</div>
<div class="form-group">
<label>所需物料数 <span class="required">*</span></label>
<input type="number" id="form-required-qty" class="input-field" min="0" required />
</div>
<div class="form-group">
<label>库存现有物料数</label>
<input type="number" id="form-stock-qty" class="input-field" min="0" />
</div>
<div class="form-group">
<label>欠缺值</label>
<input type="number" id="form-shortage" class="input-field" readonly />
</div>
<div class="form-group">
<label>物料获取方式</label>
<select id="form-acquire-method" class="input-field">
<option value="">请选择</option>
<option value="purchase">采购</option>
<option value="production">生产</option>
<option value="transfer">调拨</option>
</select>
</div>
<div class="form-group">
<label>实时库存值</label>
<input type="number" id="form-realtime-stock" class="input-field" min="0" />
</div>
<div class="form-group">
<label>待入库数量</label>
<input type="number" id="form-pending-qty" class="input-field" min="0" />
</div>
<div class="form-group">
<label>派发数量</label>
<input type="number" id="form-dispatched-qty" class="input-field" min="0" />
</div>
<div class="form-group">
<label>入库数量</label>
<input type="number" id="form-received-qty" class="input-field" min="0" />
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="MaterialPurchase.closeDialog()">取消</button>
<button class="btn btn-primary" onclick="MaterialPurchase.saveData()">保存</button>
</div>
</div>
</div>
<!-- 回收站对话框 -->
<div id="recycle-bin-dialog" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 1200px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header">
<h3>🗑️ 回收站</h3>
<button class="modal-close" onclick="MaterialPurchase.closeRecycleBin()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 16px; padding: 12px; background: rgba(245, 158, 11, 0.1); border-left: 3px solid #f59e0b; border-radius: 6px; color: var(--text-2); font-size: 13px;">
<strong>提示:</strong>回收站中的数据将保留30天过期后将自动永久删除
</div>
<div class="table-container" style="overflow-x: auto; max-height: 500px;">
<table class="data-table">
<thead>
<tr>
<th style="min-width: 150px;">标题</th>
<th style="min-width: 180px;">物料需求清单编号</th>
<th style="min-width: 120px;">物料编码</th>
<th style="min-width: 150px;">物料名称</th>
<th style="min-width: 160px;">删除时间</th>
<th style="min-width: 120px;">操作</th>
</tr>
</thead>
<tbody id="recycle-bin-tbody">
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">
<div style="color: var(--text-2);">回收站为空</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" onclick="MaterialPurchase.emptyRecycleBin()">
<span>🗑️</span> 清空回收站
</button>
<button class="btn btn-secondary" onclick="MaterialPurchase.closeRecycleBin()">关闭</button>
</div>
</div>
</div>
<!-- 导入对话框 -->
<div id="import-dialog" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h3>📥 从Excel导入物料清单</h3>
<button class="modal-close" onclick="MaterialPurchase.closeImportDialog()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 16px; padding: 12px; background: rgba(79, 140, 255, 0.1); border-left: 3px solid var(--primary); border-radius: 6px; color: var(--text-2); font-size: 13px;">
<strong>Excel文件要求</strong><br/>
• 必须包含以下列:标题、生产计划明细物料需求清单编号、生产计划编号、状态、需求状态、物料编码、物料名称、所需物料数<br/>
• 可选列产品BOM分析结果、物料齐套率、物料批次号、物料层级、库存现有物料数、欠缺值、物料获取方式、实时库存值、待入库数量、派发数量、入库数量<br/>
• 第一行必须是表头
</div>
<div class="form-group">
<label>选择Excel文件</label>
<input type="file" id="import-file-input" accept=".xlsx,.xls" class="input-field" />
</div>
<div id="import-status" style="margin-top: 16px; padding: 12px; border-radius: 6px; display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="MaterialPurchase.closeImportDialog()">取消</button>
<button class="btn btn-primary" onclick="MaterialPurchase.validateImportFile()">
<span>✓</span> 验证文件
</button>
<button class="btn btn-primary" id="import-confirm-btn" onclick="MaterialPurchase.confirmImport()" style="display: none;">
<span>📥</span> 确认导入
</button>
</div>
</div>
</div>
`;
});
window.MaterialPurchase = {
currentPage: 1,
pageSize: 20,
totalPages: 1,
data: [],
editingId: null,
selectedIds: new Set(),
recycleBin: [],
init() {
this.loadFromStorage();
this.loadData();
this.bindEvents();
},
// 从 localStorage 加载数据
loadFromStorage() {
try {
const savedData = localStorage.getItem('materialPurchaseData');
const savedRecycleBin = localStorage.getItem('materialPurchaseRecycleBin');
if (savedData) {
this.data = JSON.parse(savedData);
}
if (savedRecycleBin) {
this.recycleBin = JSON.parse(savedRecycleBin);
}
} catch (error) {
console.error('加载本地数据失败:', error);
}
},
// 保存到 localStorage
saveToStorage() {
try {
localStorage.setItem('materialPurchaseData', JSON.stringify(this.data));
localStorage.setItem('materialPurchaseRecycleBin', JSON.stringify(this.recycleBin));
} catch (error) {
console.error('保存本地数据失败:', error);
}
},
bindEvents() {
// 自动计算欠缺值
const requiredQty = document.getElementById('form-required-qty');
const stockQty = document.getElementById('form-stock-qty');
const shortage = document.getElementById('form-shortage');
const calculateShortage = () => {
const required = parseFloat(requiredQty?.value || 0);
const stock = parseFloat(stockQty?.value || 0);
if (shortage) {
shortage.value = Math.max(0, required - stock);
}
};
requiredQty?.addEventListener('input', calculateShortage);
stockQty?.addEventListener('input', calculateShortage);
},
async loadData() {
try {
const response = await fetch('/api/material-purchase/list', {
credentials: 'include'
});
const result = await response.json();
this.data = result.list || [];
// 加载回收站数据
const recycleBinResponse = await fetch('/api/material-purchase/recycle-bin', {
credentials: 'include'
});
const recycleBinResult = await recycleBinResponse.json();
this.recycleBin = recycleBinResult.list || [];
this.renderTable();
this.updatePagination();
} catch (error) {
API.toast('加载数据失败: ' + error.message, 'error');
}
},
generateMockData() {
// 生成模拟数据
const mockData = [];
for (let i = 1; i <= 5; i++) {
mockData.push({
id: i,
title: `物料需求计划-${i}`,
listNo: `MRL-2024-${String(i).padStart(4, '0')}`,
planNo: `PLAN-2024-${String(i).padStart(4, '0')}`,
bomResult: `BOM分析-${i}`,
status: ['pending', 'processing', 'completed'][i % 3],
demandStatus: ['urgent', 'normal', 'low'][i % 3],
completeRate: (Math.random() * 100).toFixed(2),
materialCode: `MAT-${String(i).padStart(5, '0')}`,
materialName: `物料名称-${i}`,
batchNo: `BATCH-${String(i).padStart(4, '0')}`,
level: Math.floor(Math.random() * 3) + 1,
requiredQty: Math.floor(Math.random() * 1000) + 100,
stockQty: Math.floor(Math.random() * 500),
shortage: 0,
acquireMethod: ['purchase', 'production', 'transfer'][i % 3],
realtimeStock: Math.floor(Math.random() * 500),
pendingQty: Math.floor(Math.random() * 200),
dispatchedQty: Math.floor(Math.random() * 100),
receivedQty: Math.floor(Math.random() * 150),
submitter: `用户${i}`,
submitTime: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString(),
updateTime: new Date().toISOString()
});
mockData[i - 1].shortage = Math.max(0, mockData[i - 1].requiredQty - mockData[i - 1].stockQty);
}
return mockData;
},
renderTable() {
const tbody = document.getElementById('material-tbody');
if (!tbody) return;
if (this.data.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="24" style="text-align: center; padding: 40px;">
<div style="color: var(--text-2);">暂无数据</div>
</td>
</tr>
`;
return;
}
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
const pageData = this.data.slice(start, end);
tbody.innerHTML = pageData.map(item => `
<tr>
<td style="text-align: center;">
<label class="custom-checkbox"><input type="checkbox" class="row-checkbox" value="${item.id}"
${this.selectedIds.has(item.id) ? 'checked' : ''}
onchange="MaterialPurchase.toggleRowSelection(${item.id}, this.checked)" /><span class="checkmark"></span></label>
</td>
<td>${this.escapeHtml(item.title)}</td>
<td>${this.escapeHtml(item.listNo)}</td>
<td>${this.escapeHtml(item.planNo)}</td>
<td>${this.escapeHtml(item.bomResult)}</td>
<td><span class="badge badge-${this.getStatusClass(item.status)}">${this.getStatusText(item.status)}</span></td>
<td><span class="badge badge-${this.getDemandStatusClass(item.demandStatus)}">${this.getDemandStatusText(item.demandStatus)}</span></td>
<td>${item.completeRate}%</td>
<td>${this.escapeHtml(item.materialCode)}</td>
<td>${this.escapeHtml(item.materialName)}</td>
<td>${this.escapeHtml(item.batchNo)}</td>
<td>${item.level}</td>
<td>${item.requiredQty}</td>
<td>${item.stockQty}</td>
<td>${item.shortage}</td>
<td>${this.getAcquireMethodText(item.acquireMethod)}</td>
<td>${item.realtimeStock}</td>
<td>${item.pendingQty}</td>
<td>${item.dispatchedQty}</td>
<td>${item.receivedQty}</td>
<td>${this.escapeHtml(item.submitter)}</td>
<td>${this.formatDateTime(item.submitTime)}</td>
<td>${this.formatDateTime(item.updateTime)}</td>
<td>
<button class="btn-icon" onclick="MaterialPurchase.editItem(${item.id})" title="编辑">✏️</button>
<button class="btn-icon" onclick="MaterialPurchase.deleteItem(${item.id})" title="删除">🗑️</button>
</td>
</tr>
`).join('');
// 更新全选框状态
this.updateSelectAllCheckbox();
},
updatePagination() {
this.totalPages = Math.ceil(this.data.length / this.pageSize);
const pageInfo = document.getElementById('page-info');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (pageInfo) {
pageInfo.textContent = `${this.currentPage} 页 / 共 ${this.totalPages}`;
}
if (prevBtn) {
prevBtn.disabled = this.currentPage === 1;
}
if (nextBtn) {
nextBtn.disabled = this.currentPage === this.totalPages || this.totalPages === 0;
}
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.renderTable();
this.updatePagination();
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.renderTable();
this.updatePagination();
}
},
search() {
const keyword = document.getElementById('search-keyword')?.value.toLowerCase() || '';
const status = document.getElementById('filter-status')?.value || '';
const demandStatus = document.getElementById('filter-demand-status')?.value || '';
this.data = this.generateMockData().filter(item => {
const matchKeyword = !keyword ||
item.title.toLowerCase().includes(keyword) ||
item.listNo.toLowerCase().includes(keyword) ||
item.planNo.toLowerCase().includes(keyword) ||
item.materialCode.toLowerCase().includes(keyword);
const matchStatus = !status || item.status === status;
const matchDemandStatus = !demandStatus || item.demandStatus === demandStatus;
return matchKeyword && matchStatus && matchDemandStatus;
});
this.currentPage = 1;
this.renderTable();
this.updatePagination();
},
resetFilter() {
document.getElementById('search-keyword').value = '';
document.getElementById('filter-status').value = '';
document.getElementById('filter-demand-status').value = '';
this.loadData();
},
showAddDialog() {
this.editingId = null;
document.getElementById('dialog-title').textContent = '新增物料需求';
document.getElementById('material-form').reset();
document.getElementById('material-dialog').style.display = 'flex';
},
editItem(id) {
this.editingId = id;
const item = this.data.find(d => d.id === id);
if (!item) return;
document.getElementById('dialog-title').textContent = '编辑物料需求';
document.getElementById('form-title').value = item.title;
document.getElementById('form-list-no').value = item.listNo;
document.getElementById('form-plan-no').value = item.planNo;
document.getElementById('form-bom-result').value = item.bomResult;
document.getElementById('form-status').value = item.status;
document.getElementById('form-demand-status').value = item.demandStatus;
document.getElementById('form-complete-rate').value = item.completeRate;
document.getElementById('form-material-code').value = item.materialCode;
document.getElementById('form-material-name').value = item.materialName;
document.getElementById('form-batch-no').value = item.batchNo;
document.getElementById('form-level').value = item.level;
document.getElementById('form-required-qty').value = item.requiredQty;
document.getElementById('form-stock-qty').value = item.stockQty;
document.getElementById('form-shortage').value = item.shortage;
document.getElementById('form-acquire-method').value = item.acquireMethod;
document.getElementById('form-realtime-stock').value = item.realtimeStock;
document.getElementById('form-pending-qty').value = item.pendingQty;
document.getElementById('form-dispatched-qty').value = item.dispatchedQty;
document.getElementById('form-received-qty').value = item.receivedQty;
document.getElementById('material-dialog').style.display = 'flex';
},
closeDialog() {
document.getElementById('material-dialog').style.display = 'none';
this.editingId = null;
},
async saveData() {
const form = document.getElementById('material-form');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
title: document.getElementById('form-title').value,
list_no: document.getElementById('form-list-no').value,
plan_no: document.getElementById('form-plan-no').value,
bom_result: document.getElementById('form-bom-result').value,
status: document.getElementById('form-status').value,
demand_status: document.getElementById('form-demand-status').value,
complete_rate: parseFloat(document.getElementById('form-complete-rate').value) || 0,
material_code: document.getElementById('form-material-code').value,
material_name: document.getElementById('form-material-name').value,
batch_no: document.getElementById('form-batch-no').value,
level: parseInt(document.getElementById('form-level').value) || 1,
required_qty: parseInt(document.getElementById('form-required-qty').value) || 0,
stock_qty: parseInt(document.getElementById('form-stock-qty').value) || 0,
shortage: parseInt(document.getElementById('form-shortage').value) || 0,
acquire_method: document.getElementById('form-acquire-method').value,
realtime_stock: parseInt(document.getElementById('form-realtime-stock').value) || 0,
pending_qty: parseInt(document.getElementById('form-pending-qty').value) || 0,
dispatched_qty: parseInt(document.getElementById('form-dispatched-qty').value) || 0,
received_qty: parseInt(document.getElementById('form-received-qty').value) || 0
};
try {
const url = this.editingId ? '/api/material-purchase/update' : '/api/material-purchase/add';
if (this.editingId) {
data.id = this.editingId;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
const result = await response.json();
if (result.ok) {
API.toast(this.editingId ? '更新成功' : '添加成功', 'success');
this.closeDialog();
await this.loadData();
} else {
API.toast(result.error || '保存失败', 'error');
}
} catch (error) {
API.toast('保存失败: ' + error.message, 'error');
}
},
async deleteItem(id) {
if (!confirm('确定要删除这条记录吗?\n\n删除的数据将移至回收站可在回收站中找回。')) return;
try {
const response = await fetch('/api/material-purchase/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ids: [id] })
});
const result = await response.json();
if (result.ok) {
API.toast('已删除,可在回收站中找回', 'success');
await this.loadData();
} else {
API.toast(result.error || '删除失败', 'error');
}
} catch (error) {
API.toast('删除失败: ' + error.message, 'error');
}
},
exportData() {
this.showExportDialog();
},
showExportDialog() {
const dialog = document.getElementById('export-dialog');
if (dialog) {
dialog.style.display = 'flex';
// 默认全选
document.querySelectorAll('.export-field-checkbox').forEach(cb => {
cb.checked = true;
});
}
},
closeExportDialog() {
const dialog = document.getElementById('export-dialog');
if (dialog) {
dialog.style.display = 'none';
}
},
async performExport() {
// 获取选中的字段
const selectedFields = [];
document.querySelectorAll('.export-field-checkbox:checked').forEach(cb => {
selectedFields.push({
key: cb.value,
label: cb.dataset.label
});
});
if (selectedFields.length === 0) {
API.toast('请至少选择一个导出字段', 'warning');
return;
}
try {
// 动态加载 SheetJS 库
if (!window.XLSX) {
API.toast('正在加载导出库...', 'info');
await this.loadSheetJS();
}
// 准备导出数据
const exportData = this.data.map(item => {
const row = {};
selectedFields.forEach(field => {
let value = item[field.key];
// 格式化特殊字段
if (field.key === 'status') {
value = this.getStatusText(value);
} else if (field.key === 'demandStatus') {
value = this.getDemandStatusText(value);
} else if (field.key === 'acquireMethod') {
value = this.getAcquireMethodText(value);
} else if (field.key === 'submitTime' || field.key === 'updateTime') {
value = this.formatDateTime(value);
} else if (field.key === 'completeRate') {
value = value + '%';
}
row[field.label] = value || '';
});
return row;
});
// 创建工作簿
const ws = window.XLSX.utils.json_to_sheet(exportData);
const wb = window.XLSX.utils.book_new();
window.XLSX.utils.book_append_sheet(wb, ws, '物料清单');
// 设置列宽
const colWidths = selectedFields.map(field => {
let width = 15;
if (field.key === 'listNo' || field.key === 'planNo') width = 20;
if (field.key === 'title' || field.key === 'materialName') width = 25;
if (field.key === 'submitTime' || field.key === 'updateTime') width = 20;
return { wch: width };
});
ws['!cols'] = colWidths;
// 生成文件名
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `物料清单-采购_${timestamp}.xlsx`;
// 导出文件
window.XLSX.writeFile(wb, filename);
API.toast('导出成功!', 'success');
this.closeExportDialog();
} catch (error) {
console.error('导出失败:', error);
API.toast('导出失败: ' + error.message, 'error');
}
},
async loadSheetJS() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js';
script.onload = resolve;
script.onerror = () => reject(new Error('加载导出库失败'));
document.head.appendChild(script);
});
},
selectAllFields() {
document.querySelectorAll('.export-field-checkbox').forEach(cb => {
cb.checked = true;
});
},
deselectAllFields() {
document.querySelectorAll('.export-field-checkbox').forEach(cb => {
cb.checked = false;
});
},
toggleRowSelection(id, checked) {
if (checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
this.updateSelectAllCheckbox();
this.updateSelectionInfo();
},
toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.row-checkbox');
checkboxes.forEach(cb => {
const id = parseInt(cb.value);
cb.checked = checkbox.checked;
if (checkbox.checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
});
this.updateSelectionInfo();
},
updateSelectAllCheckbox() {
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const checkboxes = document.querySelectorAll('.row-checkbox');
const checkedCount = document.querySelectorAll('.row-checkbox:checked').length;
if (selectAllCheckbox) {
selectAllCheckbox.checked = checkboxes.length > 0 && checkedCount === checkboxes.length;
selectAllCheckbox.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
},
updateSelectionInfo() {
const count = this.selectedIds.size;
const infoEl = document.getElementById('selection-info');
if (infoEl) {
if (count > 0) {
infoEl.textContent = `已选择 ${count}`;
infoEl.style.display = 'inline-block';
} else {
infoEl.style.display = 'none';
}
}
},
clearSelection() {
this.selectedIds.clear();
document.querySelectorAll('.row-checkbox').forEach(cb => {
cb.checked = false;
});
const selectAllCheckbox = document.getElementById('select-all-checkbox');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
this.updateSelectionInfo();
},
async deleteSelected() {
if (this.selectedIds.size === 0) {
API.toast('请先选择要删除的项', 'warning');
return;
}
if (!confirm(`确定要删除选中的 ${this.selectedIds.size} 项吗?\n\n删除的数据将移至回收站,可在回收站中找回。`)) {
return;
}
try {
const ids = Array.from(this.selectedIds);
const response = await fetch('/api/material-purchase/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ids })
});
const result = await response.json();
if (result.ok) {
API.toast(`已删除 ${result.count} 项,可在回收站中找回`, 'success');
this.selectedIds.clear();
await this.loadData();
this.updateSelectionInfo();
} else {
API.toast(result.error || '删除失败', 'error');
}
} catch (error) {
API.toast('删除失败: ' + error.message, 'error');
}
},
exportSelected() {
if (this.selectedIds.size === 0) {
API.toast('请先选择要导出的项', 'warning');
return;
}
// 临时保存原始数据
const originalData = this.data;
// 只导出选中的数据
this.data = this.data.filter(item => this.selectedIds.has(item.id));
// 显示导出对话框
this.showExportDialog();
// 恢复原始数据
setTimeout(() => {
this.data = originalData;
}, 100);
},
getStatusClass(status) {
const map = {
pending: 'warning',
processing: 'info',
completed: 'success'
};
return map[status] || 'default';
},
getStatusText(status) {
const map = {
pending: '待处理',
processing: '处理中',
completed: '已完成'
};
return map[status] || status;
},
getDemandStatusClass(status) {
const map = {
urgent: 'danger',
normal: 'info',
low: 'default'
};
return map[status] || 'default';
},
getDemandStatusText(status) {
const map = {
urgent: '紧急',
normal: '正常',
low: '低优先级'
};
return map[status] || status;
},
getAcquireMethodText(method) {
if (!method) return '-';
const map = {
purchase: '采购',
production: '生产',
transfer: '调拨',
// 支持中文输入
'采购': '采购',
'生产': '生产',
'调拨': '调拨'
};
return map[method] || method || '-';
},
formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
},
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// 回收站相关功能
showRecycleBin() {
const dialog = document.getElementById('recycle-bin-dialog');
if (dialog) {
this.renderRecycleBin();
dialog.style.display = 'flex';
}
},
closeRecycleBin() {
const dialog = document.getElementById('recycle-bin-dialog');
if (dialog) {
dialog.style.display = 'none';
}
},
renderRecycleBin() {
const tbody = document.getElementById('recycle-bin-tbody');
if (!tbody) return;
if (this.recycleBin.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">
<div style="color: var(--text-2);">回收站为空</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = this.recycleBin.map(item => `
<tr>
<td>${this.escapeHtml(item.title)}</td>
<td>${this.escapeHtml(item.listNo)}</td>
<td>${this.escapeHtml(item.materialCode)}</td>
<td>${this.escapeHtml(item.materialName)}</td>
<td>${this.formatDateTime(item.deletedAt)}</td>
<td>
<button class="btn-icon" onclick="MaterialPurchase.restoreItem(${item.id})" title="恢复">
♻️
</button>
<button class="btn-icon" onclick="MaterialPurchase.permanentDelete(${item.id})" title="永久删除">
</button>
</td>
</tr>
`).join('');
},
async restoreItem(id) {
try {
const response = await fetch('/api/material-purchase/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id })
});
const result = await response.json();
if (result.ok) {
API.toast('恢复成功', 'success');
await this.loadData();
this.renderRecycleBin();
} else {
API.toast(result.error || '恢复失败', 'error');
}
} catch (error) {
API.toast('恢复失败: ' + error.message, 'error');
}
},
async permanentDelete(id) {
if (!confirm('确定要永久删除这条记录吗?此操作不可恢复!')) {
return;
}
try {
const response = await fetch('/api/material-purchase/permanent-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id })
});
const result = await response.json();
if (result.ok) {
API.toast('已永久删除', 'success');
await this.loadData();
this.renderRecycleBin();
} else {
API.toast(result.error || '删除失败', 'error');
}
} catch (error) {
API.toast('删除失败: ' + error.message, 'error');
}
},
async emptyRecycleBin() {
if (this.recycleBin.length === 0) {
API.toast('回收站已经是空的', 'info');
return;
}
if (!confirm(`确定要清空回收站吗?这将永久删除 ${this.recycleBin.length} 项数据,此操作不可恢复!`)) {
return;
}
try {
const response = await fetch('/api/material-purchase/empty-recycle-bin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const result = await response.json();
if (result.ok) {
API.toast(`回收站已清空,删除了 ${result.count}`, 'success');
await this.loadData();
this.renderRecycleBin();
} else {
API.toast(result.error || '清空失败', 'error');
}
} catch (error) {
API.toast('清空失败: ' + error.message, 'error');
}
},
// 导入相关功能
showImportDialog() {
const dialog = document.getElementById('import-dialog');
if (dialog) {
// 重置状态
document.getElementById('import-file-input').value = '';
document.getElementById('import-status').style.display = 'none';
document.getElementById('import-confirm-btn').style.display = 'none';
dialog.style.display = 'flex';
}
},
closeImportDialog() {
const dialog = document.getElementById('import-dialog');
if (dialog) {
dialog.style.display = 'none';
}
},
async validateImportFile() {
const fileInput = document.getElementById('import-file-input');
const file = fileInput.files[0];
if (!file) {
API.toast('请选择要导入的文件', 'warning');
return;
}
const statusEl = document.getElementById('import-status');
const confirmBtn = document.getElementById('import-confirm-btn');
statusEl.style.display = 'block';
statusEl.style.background = 'rgba(79, 140, 255, 0.1)';
statusEl.style.color = 'var(--primary)';
statusEl.textContent = '正在验证文件...';
confirmBtn.style.display = 'none';
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/validate/material-purchase-file', {
method: 'POST',
credentials: 'include',
body: formData
});
const result = await response.json();
if (result.valid) {
statusEl.style.background = 'rgba(34, 197, 94, 0.1)';
statusEl.style.color = '#22c55e';
statusEl.textContent = '✓ ' + result.message;
confirmBtn.style.display = 'inline-block';
} else {
statusEl.style.background = 'rgba(239, 68, 68, 0.1)';
statusEl.style.color = '#ef4444';
statusEl.textContent = '✗ ' + result.message;
confirmBtn.style.display = 'none';
}
} catch (error) {
statusEl.style.background = 'rgba(239, 68, 68, 0.1)';
statusEl.style.color = '#ef4444';
statusEl.textContent = '✗ 验证失败: ' + error.message;
confirmBtn.style.display = 'none';
}
},
async confirmImport() {
const fileInput = document.getElementById('import-file-input');
const file = fileInput.files[0];
if (!file) {
API.toast('请选择要导入的文件', 'warning');
return;
}
const statusEl = document.getElementById('import-status');
const confirmBtn = document.getElementById('import-confirm-btn');
statusEl.style.display = 'block';
statusEl.style.background = 'rgba(79, 140, 255, 0.1)';
statusEl.style.color = 'var(--primary)';
statusEl.textContent = '正在导入数据...';
confirmBtn.disabled = true;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload/material-purchase-file', {
method: 'POST',
credentials: 'include',
body: formData
});
const result = await response.json();
if (result.ok) {
statusEl.style.background = 'rgba(34, 197, 94, 0.1)';
statusEl.style.color = '#22c55e';
statusEl.textContent = '✓ ' + result.message;
API.toast(`成功导入 ${result.count} 条数据`, 'success');
// 延迟关闭对话框并刷新数据
setTimeout(async () => {
this.closeImportDialog();
await this.loadData();
}, 1500);
} else {
statusEl.style.background = 'rgba(239, 68, 68, 0.1)';
statusEl.style.color = '#ef4444';
statusEl.textContent = '✗ ' + (result.error || '导入失败');
confirmBtn.disabled = false;
}
} catch (error) {
statusEl.style.background = 'rgba(239, 68, 68, 0.1)';
statusEl.style.color = '#ef4444';
statusEl.textContent = '✗ 导入失败: ' + error.message;
confirmBtn.disabled = false;
}
}
};
// 页面加载后初始化
Router.onAfterEach((path) => {
if (path === '/plan-mgmt/material-purchase') {
setTimeout(() => MaterialPurchase.init(), 100);
}
});
})();