feat: BOM批量导入型号/名称可编辑 + 生产订单查看按下单前库存快照 + 编辑冲销回下单状态

This commit is contained in:
2026-10-10 12:26:21 +08:00
parent 5060d027e5
commit 574d608e11
6 changed files with 162 additions and 45 deletions
+39 -33
View File
@@ -1,58 +1,58 @@
{% 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 class="toolbar" data-page-node-id="vZTw3kPadHZliSPinOK7Z1">
<h3 style="margin:0" data-page-node-id="4wsrdtS5FO7LVSlfo7CSNG">BOM 清单 ({{ data|length }})</h3>
<div data-page-node-id="wqIH8X3KYDPieHFmuHJk1U">
<a class="btn" href="{{ url_for('download_template', kind='bom') }}" data-page-node-id="1uWZtIhFdzaXWtcVfxDuOg">⬇ 下载模板</a>
<button class="primary" type="button" onclick="openBatch()" data-page-node-id="gzZTAfVl4A7uSSGZbnvUiB">⊞ 批量导入窗口</button>
</div>
</div>
<table class="tbl">
<thead><tr><th>BOM 型号</th><th>名称</th><th>组成项数</th><th>操作</th></tr></thead>
<tbody>
<table class="tbl" data-page-node-id="b0iXNI8PpWQuniQYav5O0I">
<thead data-page-node-id="iYtImpRt9uTnLT1EuDXgCb"><tr data-page-node-id="9EW4NPakt3ZhDfzYevNQb6"><th data-page-node-id="sp7aqyu2wPcBXC14JySMsV">BOM 型号</th><th data-page-node-id="ON7POYFoxBWVPwDTJ5YFVh">名称</th><th data-page-node-id="H6pAPeWhIGXasJf7is1PlR">组成项数</th><th data-page-node-id="IQPsDz71IQrUtVnzjSvTqE">操作</th></tr></thead>
<tbody data-page-node-id="GlcDs3bYGt9ktLuIMCnQ4S">
{% 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>
<tr data-page-node-id="oIa73ogUxPXPW7hd6mSQqR">
<td data-page-node-id="txkFINu0hqDcunfToMsIHJ"><a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="wZa7pJWwB3UVob7w2rMPYi">{{ d.b.code }}</a></td>
<td data-page-node-id="4ZgT9JXaM292L2DxjmPWdn">{{ d.b.name or '-' }}</td>
<td class="num" data-page-node-id="81E2RDtrSvIDDtmXamJaKy"><a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="xibV2oxiCVTyWQxHQ3GBvC">{{ d.count }}</a></td>
<td data-page-node-id="z8hNVcUqGGY3LogEDYFO6M">
<a href="{{ url_for('bom_detail', bid=d.b.id) }}" data-page-node-id="y99zemTyOQfPCzgAuGfoHX">查看</a>
<a href="{{ url_for('bom_edit', bid=d.b.id) }}" data-page-node-id="Xlkt2Bi0z38orXYfmovRTF">编辑</a>
<form method="post" action="{{ url_for('bom_delete', bid=d.b.id) }}" style="display:inline" onsubmit="return confirm('确认删除该 BOM?');" data-page-node-id="GRCffEreUmFcM3CoH1FEUC">
<button class="link" data-page-node-id="fk4q7Q2tIJdpd368XDifLE">删除</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">还没有 BOM, 点右上角新增</td></tr>
<tr data-page-node-id="8dATczjWBQGQA7MAsGu9f8"><td colspan="4" class="muted" data-page-node-id="7Zbca69N7opSKetW07LkHr">还没有 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 class="modal-mask" id="batchMask" style="display:none" data-page-node-id="na2dwI5Srtijy3wkeG1h5s">
<div class="modal" data-page-node-id="tEI8MSJjVrQXENBtLlS1dA">
<div class="modal-head" data-page-node-id="EHG4VZ8LF2jK6OQuZpZ1ij">
<span data-page-node-id="xHsrz06nBQLPcxe4wbnUF5">批量导入 BOM · 多 BOM 清单</span>
<button class="x" onclick="closeBatch()" data-page-node-id="aoDJmcOoqnCSk7ji1ol2al">✕</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>
<div class="modal-body" data-page-node-id="v1ZVsZhJV9kTmwR8H3UTll">
<p class="small muted" data-page-node-id="uBFCxuwHOoYf4HDB7GHjqB">支持两种录入:① 直接上传 <b data-page-node-id="qPxNhhrFxrTrmWD4tIcNEf">Altium / 嘉立创 BOM</b>(每个 worksheet 即一个 BOM,<b data-page-node-id="6TXeirBIr5BJkAkpI9CMod">Part Number</b> 关联物料、<b data-page-node-id="NYIE9aPWd3xD4Tr0hLOqI3">Quantity</b> 列即单套用量,下载模板即此格式);② 粘贴文本,每行一个 BOM,以 <b data-page-node-id="jy8nTX15GDqKSV6wYSm78A">#BOM &lt;型号&gt; &lt;名称&gt;</b> 开头,其下为 <b data-page-node-id="Lgr6U8MBrInBJO29ZLmnN3">物料编号 单套用量</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()">
<div class="row-actions" data-page-node-id="Un0xhQE1iuii1GXRoLOGEy">
<button type="button" onclick="parsePaste()" data-page-node-id="2nie414a2o4ULz4FNMbFVC">解析预览</button>
<label class="btn" style="cursor:pointer" data-page-node-id="s3iZeq1kL8Etsahvv2hGKO">📁 上传文件
<input type="file" id="batchFile" accept=".xlsx,.csv" style="display:none" onchange="parseFile()" data-page-node-id="sj1omvbTTh7dteUj3QePnl">
</label>
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled>确认导入</button>
<button type="button" class="primary" id="commitBtn" onclick="commitBatch()" disabled data-page-node-id="LV1nhOjsxrIhBszlCoxA2s">确认导入</button>
</div>
<div id="batchResult" style="margin-top:12px"></div>
<div id="batchResult" style="margin-top:12px" data-page-node-id="zdtCCsWu38gEMMgkruNxOI"></div>
</div>
</div>
</div>
@@ -83,14 +83,20 @@ function postPreview(payload){
body:JSON.stringify(payload)
}).then(r=>r.json()).then(renderPreview).catch(e=>alert('解析失败: '+e));
}
function esc(s){
return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}
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=>{
lastBoms.forEach((b, i)=>{
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 += `<div class="bom-head">`;
html += `<label class="bh-lbl">型号</label><input class="bom-code-inp" value="${esc(b.code)}" oninput="lastBoms[${i}].code=this.value" title="可编辑 BOM 型号">`;
html += `<label class="bh-lbl">名称</label><input class="bom-name-inp" value="${esc(b.name)}" oninput="lastBoms[${i}].name=this.value" title="可编辑 BOM 名称(全部显示,可修改)">`;
html += `${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';
+5 -2
View File
@@ -7,17 +7,20 @@
<a class="btn" href="{{ url_for('order_edit', oid=o.id) }}">编辑订单</a>
</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> | 生产工厂: <b>{{ factory.code if factory else '未指定(按汇总库存)' }}</b> | 经手人: {{ o.operator or '-' }}</p>
<p class="small muted" style="margin-top:0"><b>下单信息(按下单时记录)</b></p>
<p>BOM: <b>{{ bom.code if bom else '-' }}</b> ({{ bom.name if bom else '' }}) | 接单量: <b>{{ '%.2f'|format(o.qty) }}</b> | 生产工厂: <b>{{ factory.code if factory else '未指定(按汇总库存)' }}</b> | 经手人: {{ o.operator or '-' }}{% if o.created_at %} | 下单时间: {{ o.created_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}</p>
{% if o.remark %}<p class="small muted">备注: {{ o.remark }}</p>{% endif %}
<p class="small muted" style="margin-bottom:0">提示:点击「编辑订单」保存后,该订单会冲销已产生的调入/领料流水并清空调入计划,自动回到「刚下单」状态(待领料、未调拨),可重新规划。</p>
</div>
<h3>领料需求与调入 (单套用量 × 接单量){% if factory %} | 按 {{ factory.code }} 工厂库存核算{% endif %}</h3>
<p class="small muted" style="margin-top:-6px">库存口径:下方「下单前库存」为 <b>该订单下单那一刻</b>的库存快照(已自动剔除本单后续调入/领料及下单之后的出入库),用于还原下单时的可供料情况,与实时库存可能不同。{% if as_of %}快照时点:{{ as_of.strftime('%Y-%m-%d %H:%M') }}。{% endif %}</p>
<form method="post" action="{{ url_for('order_alloc_save', oid=o.id) }}">
<table class="tbl">
<thead><tr>
<th>物料编号</th><th>型号</th><th>需求数量</th>
<th>调入数量</th><th>当前库存</th><th>缺口</th><th>状态</th><th>可替代料</th>
<th>调入数量</th><th>下单前库存</th><th>缺口</th><th>状态</th><th>可替代料</th>
</tr></thead>
<tbody>
{% for r in reqs %}
+2 -1
View File
@@ -2,7 +2,8 @@
{% block title %}{{ '编辑生产订单' if o else '新建生产订单' }}{% endblock %}
{% block content %}
<form method="post" class="card form-card" style="max-width:520px"
action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}">
action="{{ url_for('order_edit', oid=o.id) if o else url_for('order_new') }}"
{% if o %}onsubmit="return confirm('保存将把该订单冲销回「刚下单」状态(撤销已调入/领料流水、清空调入计划),确认继续?');"{% endif %}>
<h3>{{ '编辑生产订单' if o else '新建生产订单' }}</h3>
<label>订单号 {% if o %}(单号不可重复){% else %}(留空自动生成){% endif %}</label>
<input name="order_no" value="{{ o.order_no if o else '' }}" placeholder="如 WO20260911xxx">