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
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}物料入库系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<div class="brand">📦 物料入库系统 <span class="ver">M1+M2+M3</span></div>
<nav>
<a href="{{ url_for('dashboard') }}">库存看板</a>
<a href="{{ url_for('materials') }}">物料</a>
<a href="{{ url_for('inbound') }}">入库</a>
<a href="{{ url_for('outbound') }}">出库</a>
{% if g.user and g.user.role in ('admin','manager') %}
<a href="{{ url_for('boms') }}">BOM</a>
<a href="{{ url_for('orders') }}">生产订单</a>
<a href="{{ url_for('report') }}">报表</a>
<a href="{{ url_for('logs') }}">日志</a>
{% endif %}
{% if g.user and g.user.role == 'admin' %}
<a href="{{ url_for('users') }}">用户</a>
{% endif %}
{% if g.user %}<span class="who">{{ g.user.display_name or g.user.username }} ({{ g.user.role }})</span>
<a href="{{ url_for('logout') }}">退出</a>{% endif %}
</nav>
</header>
<main>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}BOM 明细 {{ b.code }}{% endblock %}
{% block content %}
<div class="toolbar" data-page-node-id="kijLoAphCatqmeteC0fLS8">
<h3 style="margin:0" data-page-node-id="ECJxkuTNaoeZCptb3hYWET">BOM 明细 · {{ b.code }}</h3>
<div data-page-node-id="W3YF2xKkzcqbLyQSQHmDv9">
<a class="btn" href="{{ url_for('boms') }}" data-page-node-id="mkt8bYpaiDpnsyNak2CwEx">← 返回 BOM 列表</a>
<a class="btn" href="{{ url_for('bom_edit', bid=b.id) }}" data-page-node-id="b8G1I4yNJvCie28XRFo93k">编辑</a>
</div>
</div>
<div class="card" style="margin-bottom:16px" data-page-node-id="BMc2NONnJUN8plt66uKp3k">
<table class="tbl" style="box-shadow:none" data-page-node-id="HuBqdlqwPqeqvRGoctqyFK">
<tbody data-page-node-id="xq9k2F2CKZNS1J5oXVzrxN">
<tr data-page-node-id="CSgYyr7zDavFkvrYRL7J2l"><th style="width:120px" data-page-node-id="YaqRj0xvW6swCf9HolEsdZ">BOM 型号</th><td data-page-node-id="W3h0NKaeFcWnSk6bqpmnfd">{{ b.code }}</td>
<th style="width:120px" data-page-node-id="GrBiPbEdCAMAtba0DGidm8">名称</th><td data-page-node-id="t0dNbwOJnGYpyrvlMpJzHS">{{ b.name or '-' }}</td></tr>
<tr data-page-node-id="xnlzfzKyPIY29LTkzF5Exj"><th data-page-node-id="loONdVka6TX9pWPbpWr85x">备注</th><td colspan="3" data-page-node-id="vOtWTckSv3MzklsdnlC0FE">{{ b.note or '-' }}</td></tr>
</tbody>
</table>
</div>
<h3 data-page-node-id="t3eVJx5yZD6C7dR2AhvvI1">组成物料(物料编号 / 规格型号 / 单套用量)</h3>
<table class="tbl" data-page-node-id="df7zG7vdcHnmIIpkdv6sIX">
<thead data-page-node-id="U5mUzLwlJBjGq8lEuFm0Yp"><tr data-page-node-id="B10e5ndG2Fo8DbA4uD8s5e"><th data-page-node-id="clptv2dGFiVnRCTmGNcxGx">#</th><th data-page-node-id="FrYt8QMtBKNICWDU3FNTwu">物料编号</th><th data-page-node-id="WHXRD4OE5syF9gB7S9xUtN">型号 / 规格</th><th data-page-node-id="SF4qN0XEJENLIO6zMz4Rqj">单位</th><th data-page-node-id="5IfMfV4HQupPKakWftwag4">单套用量</th></tr></thead>
<tbody data-page-node-id="JBr6qsb25DZ6OzSa4g7USe">
{% for r in rows %}
<tr data-page-node-id="IM9jYB1JuNEMAWNi8ehaaZ">
<td class="num" data-page-node-id="8ad0FCRnUybzeEWz8FbALx">{{ loop.index }}</td>
<td data-page-node-id="gbLKKUuIavKSsnyYDv5tXm"><a href="{{ url_for('material_detail', mid=r.m.id) }}" data-page-node-id="m53G2ARWr2Udxdxlffdd1l">{{ r.m.part_number }}</a></td>
<td data-page-node-id="vIrrECfeuKSVoBo8MG4qw8">{{ r.m.manufacturer or '-' }}</td>
<td data-page-node-id="LdQUt8z687YhTj9wNbXNLp">{{ r.m.unit }}</td>
<td class="num" data-page-node-id="m2vUEqKxHfCxdgKNKdrMZS">{{ '%.4g'|format(r.qty) }}</td>
</tr>
{% else %}
<tr data-page-node-id="HRMX6kXmTYyKrAC01CI6EK"><td colspan="5" class="muted" data-page-node-id="G4RCcAkiwklmllhidB6cEW">该 BOM 还没有组成物料</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ '编辑 BOM' if b else '新增 BOM' }}{% endblock %}
{% block content %}
<form method="post" class="card form-card" style="max-width:640px">
<h3>{{ '编辑 BOM' if b else '新增 BOM' }}</h3>
<label>BOM 型号 *</label>
<input name="code" value="{{ b.code if b else '' }}" required {% if b %}readonly{% endif %}>
<label>名称</label>
<input name="name" value="{{ b.name if b else '' }}">
<label>备注</label>
<input name="note" value="{{ b.note if b else '' }}">
<h3>组成物料 (单套用量)</h3>
<div id="items">
{% for it in items %}
<div class="item-row" style="display:flex;gap:8px;margin-bottom:6px">
<input name="pn" placeholder="物料编号" value="{{ it.pn }}" style="flex:2">
<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" value="{{ it.qty }}" style="flex:1">
<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>
</div>
{% else %}
<div class="item-row" style="display:flex;gap:8px;margin-bottom:6px">
<input name="pn" placeholder="物料编号" style="flex:2">
<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" style="flex:1">
<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>
</div>
{% endfor %}
</div>
<button type="button" class="btn" onclick="addRow()">+ 添加一行</button>
<div class="row-actions"><button class="primary">保存</button></div>
</form>
<script>
function addRow(){
const d=document.createElement('div');
d.className='item-row';
d.style='display:flex;gap:8px;margin-bottom:6px';
d.innerHTML='<input name="pn" placeholder="物料编号" style="flex:2">'+
'<input name="qty" type="number" step="0.01" min="0" placeholder="单套用量" style="flex:1">'+
'<button type="button" class="del" onclick="this.parentNode.remove()">✕</button>';
document.getElementById('items').appendChild(d);
}
</script>
{% endblock %}
+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 %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}库存看板{% endblock %}
{% block content %}
<div class="stats">
<div class="stat"><div class="num">{{ rows|length }}</div><div class="lbl">物料种类</div></div>
<div class="stat warn"><div class="num">{{ low }}</div><div class="lbl">低于安全库存</div></div>
<div class="stat danger"><div class="num">{{ negative }}</div><div class="lbl">库存为负(缺料)</div></div>
</div>
<h3>库存预警看板</h3>
<table class="tbl">
<thead>
<tr><th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>在库</th><th>安全库存</th><th>状态</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="{{ 'row-'+r.status }}">
<td>{{ r.m.part_number }}</td>
<td>{{ r.m.manufacturer or '-' }}</td>
<td>{{ r.m.unit }}</td>
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
<td class="num">{{ '%.2f'|format(r.m.safety_stock or 0) }}</td>
<td>
{% if r.status=='danger' %}<span class="badge danger">缺料</span>
{% elif r.status=='warn' %}<span class="badge warn">偏低</span>
{% else %}<span class="badge ok">正常</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">暂无物料, 请先到"物料"页面添加</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Excel 导入{% endblock %}
{% block content %}
<div class="card form-card" style="max-width:520px">
<h3>导入 {{ '物料' if kind=='materials' else 'BOM' }}</h3>
<form method="post" enctype="multipart/form-data">
<label>选择 Excel 文件 (.xlsx) *</label>
<input type="file" name="file" accept=".xlsx" required>
<div class="row-actions"><button class="primary">上传并导入</button></div>
</form>
<p class="small muted">
{% if kind=='materials' %}
表头需含: 物料编号 / 型号规格 / 单位 / 安全库存 / 期初库存 (后三列可选)。
{% else %}
表头需含: BOM型号 / 名称 / 物料编号 / 单套用量。
{% endif %}
</p>
<p><a class="btn" href="{{ url_for('download_template', kind=kind) }}">⬇ 下载导入模板</a></p>
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}登录{% endblock %}
{% block content %}
<div class="login-wrap">
<form method="post" class="card login-card">
<h2>物料入库系统</h2>
<p class="muted">请输入账号登录</p>
<label>用户名</label>
<input name="username" autofocus required>
<label>密码</label>
<input name="password" type="password" required>
<button class="primary">登录</button>
<p class="muted small">默认管理员: admin / admin123</p>
</form>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}操作日志{% endblock %}
{% block content %}
<h3>操作日志 (最近 200 条)</h3>
<table class="tbl">
<thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>对象</th><th>详情</th></tr></thead>
<tbody>
{% for r in recs %}
<tr>
<td class="small">{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ r.operator or '-' }}</td>
<td>{{ r.action }}</td>
<td>{{ r.target or '-' }}</td>
<td class="small">{{ r.detail or '-' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">暂无日志</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}物料明细 {{ m.part_number }}{% endblock %}
{% block content %}
<div class="toolbar">
<h3 style="margin:0">物料明细 · {{ m.part_number }}</h3>
<div>
<a class="btn" href="{{ url_for('materials') }}">← 返回物料列表</a>
<a class="btn" href="{{ url_for('material_edit', mid=m.id) }}">编辑</a>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<table class="tbl" style="box-shadow:none">
<tbody>
<tr><th style="width:120px">物料编号</th><td>{{ m.part_number }}</td>
<th style="width:120px">型号 / 规格</th><td>{{ m.manufacturer or '-' }}</td></tr>
<tr><th>单位</th><td>{{ m.unit }}</td>
<th>安全库存</th><td class="num">{{ '%.2f'|format(m.safety_stock or 0) }}</td></tr>
<tr><th>备注</th><td colspan="3">{{ m.note or '-' }}</td></tr>
</tbody>
</table>
</div>
<div class="stats">
<div class="stat"><div class="num">{{ '%.2f'|format(stock) }}</div><div class="lbl">当前在库</div></div>
<div class="stat"><div class="num">{{ '%.2f'|format(inb_total) }}</div><div class="lbl">入库总数量</div></div>
<div class="stat"><div class="num">{{ '%.2f'|format(out_total) }}</div><div class="lbl">消耗总数量</div></div>
</div>
<h3>入库流水(每次入库时间 / 数量)</h3>
<table class="tbl">
<thead><tr><th>入库日期</th><th>录入时间</th><th>数量</th><th>批次号</th><th>来源 / 供应商</th><th>经手人</th><th>备注</th></tr></thead>
<tbody>
{% for r in inbounds %}
<tr>
<td>{{ r.occur_date }}</td>
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
<td>{{ r.batch_no or '-' }}</td>
<td>{{ r.source or '-' }}</td>
<td>{{ r.operator or '-' }}</td>
<td>{{ r.remark or '-' }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">暂无入库记录</td></tr>
{% endfor %}
</tbody>
</table>
<h3>出库 / 消耗流水</h3>
<table class="tbl">
<thead><tr><th>出库日期</th><th>录入时间</th><th>数量</th><th>用途</th><th>关联单号</th><th>经手人</th><th>备注</th></tr></thead>
<tbody>
{% for r in outbounds %}
<tr>
<td>{{ r.occur_date }}</td>
<td class="small muted">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '-' }}</td>
<td class="num">{{ '%.2f'|format(r.qty) }}</td>
<td>{{ r.purpose or '-' }}</td>
<td>{{ r.order_ref or '-' }}</td>
<td>{{ r.operator or '-' }}</td>
<td>{{ r.remark or '-' }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">暂无出库记录</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}{{ '编辑' if m else '新增' }}物料{% endblock %}
{% block content %}
<form method="post" class="card form-card">
<h3>{{ '编辑物料' if m else '新增物料' }}</h3>
<label>物料编号 *</label>
<input name="part_number" value="{{ m.part_number if m else '' }}" {{ 'readonly' if m }} required>
<label>型号 / 规格</label>
<input name="manufacturer" value="{{ m.manufacturer if m else '' }}">
<label>单位</label>
<input name="unit" value="{{ m.unit if m else '个' }}">
<label>安全库存</label>
<input name="safety_stock" type="number" step="0.01" value="{{ m.safety_stock if m else 0 }}">
<label>备注</label>
<input name="note" value="{{ m.note if m else '' }}">
<div class="row-actions">
<button class="primary">保存</button>
<a class="btn" href="{{ url_for('materials') }}">返回</a>
</div>
</form>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}物料{% endblock %}
{% block content %}
<div class="toolbar">
<form method="get" class="search">
<input name="q" value="{{ q }}" placeholder="搜索物料编号 / 型号">
<button>搜索</button>
</form>
<div>
<a class="btn" href="{{ url_for('download_template', kind='materials') }}">⬇ 下载模板</a>
<button class="primary" type="button" onclick="openBatch()">⊞ 批量导入窗口</button>
</div>
</div>
<table class="tbl">
<thead>
<tr><th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>安全库存</th><th>在库</th><th>入库总数量</th><th>消耗总数量</th><th>操作</th></tr>
</thead>
<tbody>
{% for m in mats %}
<tr>
<td>{{ m.part_number }}</td>
<td>{{ m.manufacturer or '-' }}</td>
<td>{{ m.unit }}</td>
<td class="num">{{ '%.2f'|format(m.safety_stock or 0) }}</td>
<td class="num">{{ '%.2f'|format(sm.get(m.id, 0)) }}</td>
<td class="num">{{ '%.2f'|format(inb_total.get(m.id, 0)) }}</td>
<td class="num">{{ '%.2f'|format(out_total.get(m.id, 0)) }}</td>
<td>
<a href="{{ url_for('material_detail', mid=m.id) }}">明细</a>
<a href="{{ url_for('material_edit', mid=m.id) }}">编辑</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="muted">没有匹配的物料</td></tr>
{% endfor %}
</tbody>
</table>
<!-- 批量导入窗口(模态) -->
<div class="modal-mask" id="batchMask" style="display:none">
<div class="modal">
<div class="modal-head">
<span>批量导入物料 · 多行清单</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="9" placeholder="P0001 示例型号A 个 100 500
P0002 示例型号B 卷 50 200"></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 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 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/materials/preview', {method:'POST', body:fd})
.then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
}
function postPreview(payload){
fetch('/api/import/materials/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? (r.exists? '<span class="badge warn">更新</span>' : '<span class="badge ok">新增</span>')
: `<span class="badge danger">${r.error||'错误'}</span>`;
html += `<tr class="${cls}"><td>${r.part_number||''}</td><td>${r.manufacturer||'-'}</td><td>${r.unit||'个'}</td><td class="num">${r.safety_stock}</td><td class="num">${r.opening_stock}</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/materials/commit', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({rows: lastRows})
}).then(r=>r.json()).then(d=>{
alert(`导入完成: 新增 ${d.added} 条, 更新 ${d.updated} 条`);
if(d.added+d.updated>0) location.reload();
}).catch(e=>alert('导入失败: '+e));
}
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}订单 {{ o.order_no }}{% endblock %}
{% block content %}
<div class="toolbar">
<h3 style="margin:0">生产订单 {{ o.order_no }}</h3>
<span class="badge {{ 'ok' if o.status=='picked' else 'warn' }}">{{ '已领料' if o.status=='picked' else '待领料' }}</span>
</div>
<div class="card" style="margin-bottom:16px">
<p>BOM: <b>{{ bom.code if bom else '-' }}</b> ({{ bom.name if bom else '' }}) | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 经手人: {{ o.operator or '-' }}</p>
</div>
<h3>领料需求 (单套用量 × 接单量)</h3>
<table class="tbl">
<thead><tr><th>物料编号</th><th>型号</th><th>需求数量</th><th>当前库存</th><th>缺口</th><th>状态</th></tr></thead>
<tbody>
{% for r in reqs %}
<tr class="{{ 'row-danger' if r.short else ('row-warn' if r.have < (r.m.safety_stock or 0)) }}">
<td>{{ r.m.part_number }}</td>
<td>{{ r.m.manufacturer or '-' }}</td>
<td class="num">{{ '%.2f'|format(r.need) }}</td>
<td class="num">{{ '%.2f'|format(r.have) }}</td>
<td class="num">{{ '%.2f'|format(r.gap) }}</td>
<td><span class="badge {{ 'danger' if r.short else 'ok' }}">{{ '缺料' if r.short else '充足' }}</span></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">该 BOM 没有组成物料</td></tr>
{% endfor %}
</tbody>
</table>
{% if o.status != 'picked' %}
<form method="post" action="{{ url_for('order_pick', oid=o.id) }}" onsubmit="return confirm('确认按 BOM 生成领料出库? 库存不足将记为负并在看板预警。');">
<div class="row-actions"><button class="primary">确认领料出库</button></div>
</form>
{% else %}
<p class="flash ok">该订单已领料出库。</p>
{% endif %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}新建生产订单{% endblock %}
{% block content %}
<form method="post" class="card form-card" style="max-width:520px">
<h3>新建生产订单</h3>
<label>订单号 (留空自动生成)</label>
<input name="order_no" placeholder="如 WO20260911xxx">
<label>BOM 型号 *</label>
<select name="bom_id" required>
<option value="">-- 选择 BOM --</option>
{% for b in boms %}
<option value="{{ b.id }}">{{ b.code }} ({{ b.name or '-' }})</option>
{% endfor %}
</select>
<label>接单量 (台/套) *</label>
<input name="qty" type="number" step="0.01" min="0.01" required>
<div class="row-actions"><button class="primary">生成订单</button></div>
<p class="small muted">生成后可在订单详情页按 BOM 展开领料需求, 并一键"确认领料"出库。</p>
</form>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}生产订单{% endblock %}
{% block content %}
<div class="toolbar">
<h3 style="margin:0">生产订单 ({{ data|length }})</h3>
<a class="btn primary" href="{{ url_for('order_new') }}">+ 新建订单</a>
</div>
<table class="tbl">
<thead><tr><th>订单号</th><th>BOM</th><th>接单量</th><th>状态</th><th>经手人</th><th>操作</th></tr></thead>
<tbody>
{% for d in data %}
<tr class="{{ 'row-danger' if d.o.status=='picked' and false }}">
<td>{{ d.o.order_no }}</td>
<td>{{ d.bom.code if d.bom else '-' }} ({{ d.bom.name if d.bom else '' }})</td>
<td class="num">{{ '%.2f'|format(d.o.qty) }}</td>
<td><span class="badge {{ 'ok' if d.o.status=='picked' else 'warn' }}">{{ '已领料' if d.o.status=='picked' else '待领料' }}</span></td>
<td>{{ d.o.operator or '-' }}</td>
<td><a href="{{ url_for('order_detail', oid=d.o.id) }}">查看</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">还没有生产订单</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}库存报表{% endblock %}
{% block content %}
<div class="toolbar no-print">
<h3 style="margin:0">库存报表 ({{ today }})</h3>
<div>
<button class="btn" onclick="window.print()">🖨 打印</button>
<a class="btn primary" href="{{ url_for('export_inventory') }}">⬇ 导出 Excel</a>
</div>
</div>
<table class="tbl">
<thead><tr><th>物料编号</th><th>型号/规格</th><th>单位</th><th>在库数量</th><th>安全库存</th><th>状态</th></tr></thead>
<tbody>
{% for r in rows %}
<tr class="{{ 'row-danger' if r.status=='danger' else ('row-warn' if r.status=='warn') }}">
<td>{{ r.m.part_number }}</td>
<td>{{ r.m.manufacturer or '-' }}</td>
<td>{{ r.m.unit }}</td>
<td class="num">{{ '%.2f'|format(r.stock) }}</td>
<td class="num">{{ '%.2f'|format(r.m.safety_stock or 0) }}</td>
<td><span class="badge {{ r.status }}">{{ '库存为负' if r.status=='danger' else ('低于安全库存' if r.status=='warn' else '正常') }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
<style media="print">
.topbar, .no-print { display: none !important; }
body { background:#fff; }
.tbl { box-shadow:none; }
</style>
{% endblock %}
+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 %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}用户管理{% endblock %}
{% block content %}
<h3>用户列表</h3>
<table class="tbl">
<thead><tr><th>用户名</th><th>姓名</th><th>角色</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{% for u in us %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.display_name or '-' }}</td>
<td><span class="badge ok">{{ u.role }}</span></td>
<td>{{ '启用' if u.active else '停用' }}</td>
<td>
{% if u.id != g.user.id %}
<form method="post" action="{{ url_for('user_toggle', uid=u.id) }}" style="display:inline">
<button class="link">{{ '停用' if u.active else '启用' }}</button>
</form>
{% else %}<span class="muted">当前账号</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>新增用户</h3>
<form method="post" action="{{ url_for('user_add') }}" class="card form-card" style="max-width:480px">
<label>用户名 *</label>
<input name="username" required>
<label>姓名</label>
<input name="display_name">
<label>密码 *</label>
<input name="password" type="password" required>
<label>角色</label>
<select name="role">
<option value="operator">operator (录入/出入库)</option>
<option value="manager">manager (物料/BOM/订单/报表/日志)</option>
<option value="admin">admin (含用户管理)</option>
</select>
<div class="row-actions"><button class="primary">添加</button></div>
</form>
{% endblock %}