feat: 库存管理系统 MaterialWMS 初始版本

- Flask + SQLAlchemy 物料/入库/出库/BOM/生产订单全模块管理
- PyInstaller 打包为单文件 EXE (双击运行, 自动建库)
- 物料维度: 入库总数量 / 消耗总数量 / 逐次入库流水(含时间)
- BOM 详情展示组成物料的规格型号与单套用量
- 支持 Altium / 嘉立创 BOM 批量导入(文本与 Excel 双通道)
- 内置端到端验证脚本(tools/_exe_util.py 等, 清理进程树防残留)
This commit is contained in:
2026-09-11 15:41:56 +08:00
commit 7d34a481b3
38 changed files with 3601 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}BOM 清单{% endblock %}
{% block content %}
<div class="toolbar">
<h3 style="margin:0">BOM 清单 ({{ data|length }})</h3>
<div>
<a class="btn" href="{{ url_for('download_template', kind='bom') }}">⬇ 下载模板</a>
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
</div>
</div>
<table class="tbl">
<thead><tr><th>BOM 型号</th><th>名称</th><th>组成项数</th><th>操作</th></tr></thead>
<tbody>
{% for d in data %}
<tr>
<td><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.b.code }}</a></td>
<td>{{ d.b.name or '-' }}</td>
<td class="num"><a href="{{ url_for('bom_detail', bid=d.b.id) }}">{{ d.count }}</a></td>
<td>
<a href="{{ url_for('bom_detail', bid=d.b.id) }}">查看</a>
<a href="{{ url_for('bom_edit', bid=d.b.id) }}">编辑</a>
<form method="post" action="{{ url_for('bom_delete', bid=d.b.id) }}" style="display:inline" onsubmit="return confirm('确认删除该 BOM?');">
<button class="link">删除</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">还没有 BOM, 点右上角新增</td></tr>
{% endfor %}
</tbody>
</table>
<!-- 批量导入窗口(模态) -->
<div class="modal-mask" id="batchMask" style="display:none">
<div class="modal">
<div class="modal-head">
<span>批量导入 BOM · 多 BOM 清单</span>
<button class="x" onclick="closeBatch()">✕</button>
</div>
<div class="modal-body">
<p class="small muted">支持两种录入:① 直接上传 <b>Altium / 嘉立创 BOM</b>(每个 worksheet 即一个 BOM,<b>Part Number</b> 关联物料、<b>Quantity</b> 列即单套用量,下载模板即此格式);② 粘贴文本,每行一个 BOM,以 <b>#BOM &lt;型号&gt; &lt;名称&gt;</b> 开头,其下为 <b>物料编号 单套用量</b>,空行分隔。物料不存在时导入会自动新建主数据。</p>
<textarea id="batchText" rows="11" placeholder="#BOM M4021 示例产品A
P0001 2
P0002 0.5
#BOM M4022 示例产品B
P0003 4"></textarea>
<div class="row-actions">
<button type="button" onclick="parsePaste()">解析预览</button>
<label class="btn" style="cursor:pointer">📁 上传文件
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()">
</label>
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
</div>
<div id="batchResult" style="margin-top:12px"></div>
</div>
</div>
</div>
<script>
let lastBoms = [];
function openBatch(){ document.getElementById('batchMask').style.display='flex'; }
function closeBatch(){ document.getElementById('batchMask').style.display='none'; }
document.getElementById('batchMask').addEventListener('click', e=>{ if(e.target.id==='batchMask') closeBatch(); });
function parsePaste(){
const text = document.getElementById('batchText').value.trim();
if(!text){ alert('请先粘贴清单'); return; }
postPreview({text: text});
}
function parseFile(){
const f = document.getElementById('batchFile').files[0];
if(!f) return;
const fd = new FormData();
fd.append('file', f);
fetch('/api/import/boms/preview', {method:'POST', body:fd})
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
}
function postPreview(payload){
fetch('/api/import/boms/preview', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload)
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
}
function renderPreview(data){
lastBoms = data.boms || [];
const valid = data.valid, invalid = data.invalid;
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastBoms.length} 个 BOM · 可导入 <b>${valid}</b> 个 · 异常 <b>${invalid}</b> 个</div>`;
lastBoms.forEach(b=>{
const btag = b.ok? '<span class="badge ok">OK</span>' : `<span class="badge danger">${b.error||'错误'}</span>`;
html += `<div class="bom-card ${b.ok?'':'card-danger'}">`;
html += `<div class="bom-head"><b>${b.code||''}</b> ${b.name||''} ${btag} <span class="muted small">成功项 ${b.valid_items}/${b.items.length}</span></div>`;
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>型号/规格</th><th>单套用量</th><th>校验</th></tr></thead><tbody>';
b.items.forEach(it=>{
const cls = it.ok? '' : 'row-danger';
let tag;
if (it.auto_create) tag = '<span class="badge warn">新建</span>';
else if (it.ok) tag = '<span class="badge ok">OK</span>';
else tag = `<span class="badge danger">${it.error||'错误'}</span>`;
html += `<tr class="${cls}"><td>${it.part_number||''}</td><td class="muted small">${it.manufacturer||''}</td><td class="num">${it.qty_per_unit}</td><td>${tag}</td></tr>`;
});
html += '</tbody></table></div>';
});
document.getElementById('batchResult').innerHTML = html;
document.getElementById('commitBtn').disabled = valid===0;
}
function commitBatch(){
if(!lastBoms.length) return;
fetch('/api/import/boms/commit', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({boms: lastBoms})
}).then(r=>r.json()).then(d=>{
let msg = `导入完成: ${d.boms} 个 BOM, 成功 ${d.added_items} 项`;
if (d.created_materials) msg += `, 新建物料 ${d.created_materials}`;
if (d.skipped_items) msg += `, 跳过 ${d.skipped_items} 项`;
alert(msg);
if(d.boms>0) location.reload();
}).catch(e=>alert('导入失败: '+e));
}
</script>
{% endblock %}