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
+167
View File
@@ -0,0 +1,167 @@
{% extends "base.html" %}
{% block title %}{{ '入库登记' if kind=='inbound' else '出库登记' }}{% endblock %}
{% block content %}
<div class="toolbar">
<h3 style="margin:0">{{ '入库登记' if kind=='inbound' else '出库登记' }}</h3>
<div>
<a class="btn" href="{{ url_for('download_template', kind='transactions') }}">⬇ 下载批量模板</a>
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
</div>
</div>
<form method="post" class="card form-card txn-form">
<label>物料 *</label>
<select name="material_id" required>
<option value="">-- 选择物料 --</option>
{% for m in mats %}
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
{% endfor %}
</select>
<label>批次号</label>
<input name="batch_no">
<label>数量 *</label>
<input name="qty" type="number" step="0.01" min="0.01" required>
<label>经手人</label>
<input name="operator">
{% if kind=='inbound' %}
<label>来源 / 供应商</label>
<input name="source">
{% else %}
<label>用途</label>
<input name="purpose">
<label>关联单号</label>
<input name="order_ref">
{% endif %}
<label>业务日期</label>
<input name="occur_date" type="date" value="{{ today }}">
<label>备注</label>
<input name="remark">
<div class="row-actions">
<button class="primary">{{ '入库' if kind=='inbound' else '出库' }}</button>
</div>
</form>
<h3>最近 {{ recs|length }} 条{{ '入库' if kind=='inbound' else '出库' }}记录</h3>
<table class="tbl">
<thead>
<tr>
<th>物料</th><th>批次</th><th>数量</th><th>经手人</th>
{% if kind=='inbound' %}<th>来源</th>{% else %}<th>用途</th><th>单号</th>{% endif %}
<th>日期</th>
</tr>
</thead>
<tbody>
{% for r in recs %}
{% set m = mats|selectattr('id','equalto',r.material_id)|list|first %}
<tr>
<td>{{ m.part_number if m else r.material_id }}</td>
<td>{{ r.batch_no or '-' }}</td>
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
<td>{{ r.operator or '-' }}</td>
{% if kind=='inbound' %}<td>{{ r.source or '-' }}</td>
{% else %}<td>{{ r.purpose or '-' }}</td><td>{{ r.order_ref or '-' }}</td>{% endif %}
<td>{{ r.occur_date }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">暂无记录</td></tr>
{% endfor %}
</tbody>
</table>
<!-- 批量导入窗口(模态) -->
<div class="modal-mask" id="batchMask" style="display:none">
<div class="modal">
<div class="modal-head">
<span>批量导入{{ '入库' if kind=='inbound' else '出库' }} · 多行清单</span>
<button class="x" onclick="closeBatch()">✕</button>
</div>
<div class="modal-body">
<p class="small muted">每行一条: <b>物料编号 数量 批次(可选) 日期(可选) 备注(可选)</b>,列之间用空格或制表符分隔。也可直接上传 Excel/CSV。</p>
<textarea id="batchText" rows="8" placeholder="P0001 100 B20260911 2026-09-11 到货A
P0002 50 2026-09-11 到货B"></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>
const TXN_KIND = "{{ kind }}";
let lastRows = [];
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 splitLine(line){
const s = line.replace(/[\t,,;;]+/g,' ').trim();
if(!s) return [];
return s.split(/\s+/);
}
function parsePaste(){
const text = document.getElementById('batchText').value.trim();
if(!text){ alert('请先粘贴清单'); return; }
const rows = [];
text.split(/\r?\n/).forEach(raw=>{
const parts = splitLine(raw);
if(!parts.length) return;
const first = parts[0];
if(/物料|编号|part\s?number|数量|qty/i.test(first)) return; // 跳过表头
const pn = parts[0];
const qty = parseFloat(parts[1]);
const batch = parts[2] || '';
const date = parts[3] || '';
const remark = parts.slice(4).join(' ');
rows.push({part_number:pn, qty:isNaN(qty)?0:qty, batch_no:batch, date:date, remark:remark});
});
if(!rows.length){ alert('未解析到有效行'); return; }
postPreview({kind:TXN_KIND, rows:rows});
}
function parseFile(){
const f = document.getElementById('batchFile').files[0];
if(!f) return;
const fd = new FormData();
fd.append('file', f);
fd.append('kind', TXN_KIND);
fetch('/api/import/transactions/preview', {method:'POST', body:fd})
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
}
function postPreview(payload){
fetch('/api/import/transactions/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){
lastRows = data.rows || [];
const valid = data.valid, invalid = data.invalid;
let html = `<div class="flash ${invalid? 'error':'ok'}">共 ${lastRows.length} 行 · 可导入 <b>${valid}</b> 行 · 异常 <b>${invalid}</b> 行</div>`;
html += '<table class="tbl"><thead><tr><th>物料编号</th><th>数量</th><th>批次</th><th>日期</th><th>备注</th><th>校验</th></tr></thead><tbody>';
lastRows.forEach(r=>{
const cls = r.ok? '' : 'row-danger';
const tag = r.ok? '<span class="badge ok">OK</span>' : `<span class="badge danger">${r.error||'错误'}</span>`;
html += `<tr class="${cls}"><td>${r.part_number}</td><td class="num">${r.qty}</td><td>${r.batch_no||'-'}</td><td>${r.date||'-'}</td><td>${r.remark||'-'}</td><td>${tag}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('batchResult').innerHTML = html;
document.getElementById('commitBtn').disabled = valid===0;
}
function commitBatch(){
if(!lastRows.length) return;
fetch('/api/import/transactions/commit', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({kind:TXN_KIND, rows:lastRows})
}).then(r=>r.json()).then(d=>{
alert(`导入完成: 成功 ${d.added} 条${d.skipped? ',跳过 '+d.skipped+' 条':''}`);
if(d.added>0) location.reload();
}).catch(e=>alert('导入失败: '+e));
}
</script>
{% endblock %}