Compare commits

...
2 Commits
Author SHA1 Message Date
star 439877c6ba feat: 入库/出库单笔录入新增物料编码输入框(输入或扫码自动定位)
- 单笔录入表单顶部新增「物料编码」输入框, 输入或扫码物料编码即自动定位下拉物料:
  精确命中/前缀唯一立即锁定并把光标跳到数量; 多个前缀候选提示继续输入; 编码不存在标红提示
- 下拉保留作后备, 手选下拉会反向自动填入编码, 两种方式互通
- option 加 data-pn 携带物料编码供前端精确匹配, 不改后端提交逻辑(仍用 material_id)
- 新增 .pn-lookup / .pn-hint 样式(状态色与系统 ok/danger/warn 一致)
- 教程补充单笔录入物料编码输入框说明

(注: 此前模拟齐套 commit 9f29139 因网络断连未推送, 本次一并推送)
2026-09-23 11:14:27 +08:00
star 9f291398a1 feat: 库存看板 BOM 标签页新增「模拟齐套」试算模式
- BOM 物料齐套标签页新增模拟齐套工具条: 输入计划生产数量(套/PCS), 点模拟即按 单套用量×数量
  算出每颗料总需求用量, 与当前在库对比, 缺口为正的行整行标红并显示缺多少
- 每行新增「需求用量」「缺口」两列(默认隐藏, 模拟时显示); 每棵 BOM 标题出现模拟结论徽章
  (模拟齐套 / 模拟缺料 M/N 种); 选"全部 BOM"时对所有 BOM 同时模拟
- 提供「重置」清除模拟结果, 纯前端计算、不改动任何库存数据
- 新增 .sim-bar / .row-sim-danger / .sim-badge 样式
- 教程同步补充模拟齐套章节
2026-09-22 13:45:12 +08:00
4 changed files with 147 additions and 6 deletions
+16
View File
@@ -78,6 +78,22 @@ button:hover, .btn:hover { filter: brightness(.96); }
.bom-filter label { margin: 0; white-space: nowrap; }
.bom-filter select { width: auto; min-width: 260px; }
/* 入库/出库: 物料编码输入框(输入或扫码自动定位) */
.pn-lookup { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.pn-lookup input { width: 280px; }
.pn-hint { font-size: 13px; white-space: nowrap; }
.pn-hint.ok { color: #1a7f47; }
.pn-hint.warn { color: #ad6800; }
.pn-hint.danger { color: #cf1322; }
/* BOM 模拟齐套模式 */
.sim-bar { display: flex; align-items: center; gap: 10px; margin: 0 0 16px; flex-wrap: wrap; padding: 10px 12px; background: #f7f9fc; border: 1px solid #e3e8ef; border-radius: 8px; }
.sim-bar label { margin: 0; white-space: nowrap; }
.sim-bar input[type=number] { width: 130px; }
.badge.sim-badge { margin-left: auto; }
tr.row-sim-danger { background: #fff1f0 !important; }
tr.row-sim-danger td { color: #cf1322; }
/* 批量导入窗口(模态) */
.modal-mask {
position: fixed; inset: 0; background: rgba(15,23,35,.5);
+71 -3
View File
@@ -53,6 +53,16 @@
</select>
<span class="muted" id="bomCount"></span>
</div>
<div class="sim-bar">
<label for="simQty">模拟需求量</label>
<input id="simQty" type="number" min="1" step="1" placeholder="如 1000" />
<span class="muted">套 / PCS(该 BOM 计划生产数量)</span>
<button type="button" class="btn primary" onclick="simulateBom()">▶ 模拟齐套</button>
<button type="button" class="btn" onclick="resetSim()">重置</button>
<span class="muted" id="simHint"></span>
</div>
{% for bb in bom_board %}
<div class="card" data-bid="{{ bb.b.id }}" style="margin-bottom:16px">
<div class="card-head">
@@ -61,14 +71,18 @@
{% if bb.status=='danger' %}<span class="badge danger">缺料</span>
{% elif bb.status=='warn' %}<span class="badge warn">部分偏低</span>
{% else %}<span class="badge ok">齐套</span>{% endif %}
<span class="badge sim-badge" id="simBadge-{{ bb.b.id }}" style="display:none"></span>
</div>
<table class="tbl" style="box-shadow:none; margin:0">
<thead>
<tr><th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>单套用量</th><th>当前在库</th><th>状态</th></tr>
<tr>
<th>物料编号</th><th>型号 / 规格</th><th>单位</th><th>单套用量</th><th>当前在库</th><th>状态</th>
<th class="sim-col" style="display:none">需求用量</th><th class="sim-col" style="display:none">缺口</th>
</tr>
</thead>
<tbody>
{% for it in bb.parts %}
<tr class="row-{{ it.status }}">
<tr class="row-{{ it.status }}" data-qty="{{ it.qty }}" data-stock="{{ it.stock }}">
<td><a href="{{ url_for('material_detail', mid=it.m.id) }}">{{ it.m.part_number }}</a></td>
<td>{{ it.m.manufacturer or '-' }}</td>
<td>{{ it.m.unit }}</td>
@@ -79,9 +93,11 @@
{% elif it.status=='warn' %}<span class="badge warn">偏低</span>
{% else %}<span class="badge ok">正常</span>{% endif %}
</td>
<td class="sim-col num" style="display:none"></td>
<td class="sim-col num" style="display:none"></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">该 BOM 暂无组成物料</td></tr>
<tr><td colspan="8" class="muted">该 BOM 暂无组成物料</td></tr>
{% endfor %}
</tbody>
</table>
@@ -112,6 +128,58 @@ function filterBom(){
var cnt = document.getElementById('bomCount');
if(cnt) cnt.textContent = (v==='all') ? ('共 ' + cards.length + ' 棵 BOM') : ('已选 1 / ' + cards.length + ' 棵');
}
function fmtNum(n){
// 去掉多余小数, 保留至多3位
var r = Math.round(n*1000)/1000;
return r.toLocaleString('zh-CN', {maximumFractionDigits:3});
}
function simulateBom(){
var qEl = document.getElementById('simQty');
var q = parseFloat(qEl.value);
if(!q || q <= 0){ alert('请输入有效的模拟需求量(正整数)'); return; }
var sel = document.getElementById('bomSelect');
var v = sel ? sel.value : 'all';
var cards = (v==='all')
? document.querySelectorAll('#tab-bom .card[data-bid]')
: document.querySelectorAll('#tab-bom .card[data-bid="'+v+'"]');
var anyShort = false;
cards.forEach(function(card){
var shortage = 0, total = 0;
card.querySelectorAll('tbody tr[data-qty]').forEach(function(tr){
var qpu = parseFloat(tr.getAttribute('data-qty'));
var stock = parseFloat(tr.getAttribute('data-stock'));
var need = qpu * q;
var gap = need - stock;
total++;
if(gap > 0){ shortage++; tr.classList.add('row-sim-danger'); }
else { tr.classList.remove('row-sim-danger'); }
var cols = tr.querySelectorAll('.sim-col');
cols[0].textContent = fmtNum(need);
cols[1].textContent = gap > 0 ? ('缺 ' + fmtNum(gap)) : '充足';
cols[0].style.display = '';
cols[1].style.display = '';
});
card.querySelectorAll('th.sim-col').forEach(function(th){ th.style.display=''; });
var badge = document.getElementById('simBadge-'+card.getAttribute('data-bid'));
if(badge){
badge.style.display = '';
if(shortage>0){ badge.className='badge sim-badge danger'; badge.textContent='模拟缺料: 缺 '+shortage+'/'+total+' 种'; anyShort=true; }
else { badge.className='badge sim-badge ok'; badge.textContent='模拟齐套: '+total+' 种充足'; }
}
});
var hint = document.getElementById('simHint');
if(hint){
if(cards.length>1) hint.textContent = anyShort ? ('共模拟 '+cards.length+' 棵 BOM,存在缺料') : ('共模拟 '+cards.length+' 棵 BOM,全部齐套');
else if(cards.length===1) hint.textContent = anyShort ? '该 BOM 模拟缺料' : '该 BOM 模拟齐套';
}
}
function resetSim(){
document.querySelectorAll('#tab-bom .sim-col').forEach(function(e){ e.style.display='none'; });
document.querySelectorAll('#tab-bom tbody tr').forEach(function(tr){ tr.classList.remove('row-sim-danger'); });
document.querySelectorAll('#tab-bom .sim-badge').forEach(function(b){ b.style.display='none'; });
var q = document.getElementById('simQty'); if(q) q.value='';
var h = document.getElementById('simHint'); if(h) h.textContent='';
}
filterBom();
</script>
{% endblock %}
+50 -2
View File
@@ -11,11 +11,16 @@
</div>
<form method="post" class="card form-card txn-form">
<label>物料编码(输入或扫码自动定位)</label>
<div class="pn-lookup">
<input id="pnLookup" placeholder="输入物料编码,如 P0001,回车自动定位" autocomplete="off">
<span id="pnHint" class="pn-hint"></span>
</div>
<label>物料 *</label>
<select name="material_id" required>
<select name="material_id" id="materialSelect" required>
<option value="">-- 选择物料 --</option>
{% for m in mats %}
<option value="{{ m.id }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
<option value="{{ m.id }}" data-pn="{{ m.part_number }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
{% endfor %}
</select>
<label>批次号</label>
@@ -96,6 +101,49 @@ P0002 50 2026-09-11 到货B"></textarea>
const TXN_KIND = "{{ kind }}";
let lastRows = [];
/* ---- 物料编码输入框: 输入/扫码自动定位下拉物料 ---- */
(function(){
const sel = document.getElementById('materialSelect');
const pn = document.getElementById('pnLookup');
const hint = document.getElementById('pnHint');
if(!sel || !pn || !hint) return;
function lockTo(o){
sel.value = o.value;
hint.textContent = '✓ ' + o.text;
hint.className = 'pn-hint ok';
const qty = document.querySelector('.txn-form input[name=qty]');
if(qty) qty.focus();
}
function locate(){
const q = pn.value.trim().toUpperCase();
if(!q){ hint.textContent=''; hint.className='pn-hint'; return; }
const opts = Array.from(sel.options).filter(o => o.value !== '');
const exact = opts.find(o => (o.getAttribute('data-pn')||'').toUpperCase() === q);
if(exact){ lockTo(exact); return; }
const prefix = opts.filter(o => (o.getAttribute('data-pn')||'').toUpperCase().startsWith(q));
if(prefix.length === 1){ lockTo(prefix[0]); return; }
if(prefix.length > 1){
hint.textContent = '… 多个候选(' + prefix.length + '),继续输入';
hint.className = 'pn-hint warn';
return;
}
hint.textContent = '✗ 未找到物料编码:' + q;
hint.className = 'pn-hint danger';
}
pn.addEventListener('input', locate);
pn.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locate(); } });
sel.addEventListener('change', () => {
const o = sel.options[sel.selectedIndex];
if(o && o.value !== ''){
pn.value = o.getAttribute('data-pn') || '';
hint.textContent = '✓ ' + o.text;
hint.className = 'pn-hint ok';
} else {
pn.value = ''; hint.textContent = ''; hint.className = 'pn-hint';
}
});
})();
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(); });
+10 -1
View File
@@ -79,6 +79,12 @@
> **BOM 物料齐套标签页**:看板顶部有「📦 库存预警 / 🔧 BOM 物料齐套」两个标签(仅管理员、主管可见)。切到 **BOM 物料齐套** 后,顶部有一个「选择 BOM」下拉框:默认「全部 BOM(总览)」会逐棵列出其组成物料的「单套用量 / 当前在库 / 状态」;选中某棵 BOM 则只看那棵。右侧显示「共 N 棵 / 已选 1/N 棵」计数。每棵 BOM 标题处给出整体齐套结论(齐套 / 部分偏低 / 缺料),点物料编号可跳到该料明细。这样不用进 BOM 列表就能在看板上直接看清每棵 BOM 里每颗料的库存情况。
> **模拟齐套(试算缺料)**:在 BOM 物料齐套标签页里,「选择 BOM」下拉框下方有一条「模拟齐套」工具条。输入该 BOM 的**计划生产数量**(如 1000 套 / PCS),点「▶ 模拟齐套」,系统按每颗料的「单套用量 × 计划数量」算出**总需求用量**,与**当前在库**对比:
> - 每行右侧新增「需求用量」「缺口」两列;缺口为正的行(需求 > 在库)整行标红并显示「缺 X」;
> - 该 BOM 标题处出现模拟结论徽章:「模拟齐套 / N 种充足」或「模拟缺料 / 缺 M/N 种」;
> - 选择「全部 BOM(总览)」时会对每棵 BOM 同时模拟,工具条右侧给出整体结论。
> 点「重置」可清除模拟结果。该模式**只做试算、不改任何库存数据**,适合接单前快速判断"这批货能不能齐套、还差多少料"。
---
## 五、物料管理
@@ -121,7 +127,10 @@
路径:导航栏 **入库**。两种录入方式:
### 6.1 单笔录入
选物料 → 填批次号、数量、经手人、来源/供应商、业务日期、备注 → 提交。库存实时 +N,并写操作日志。
表单顶部有一个 **「物料编码」输入框**(与「物料」下拉并列):
- **直接输入或扫码**物料编码(如 `P0001`),系统会**自动定位**到下拉里对应的物料:精确命中、或编码前缀唯一时立即锁定,并把光标跳到「数量」;多个前缀候选会提示「继续输入」;编码不存在会标红提示「未找到」。
- 也可以照旧在「物料」下拉里手动选择——手选后编码框会自动反向填入对应编码,两种方式互通。
- 填批次号、数量、经手人、来源/供应商(出库为用途/关联单号)、业务日期、备注 → 提交。库存实时 +N(出库实时 −N,不足会拦截),并写操作日志。
### 6.2 批量导入窗口
点右上角 **⊞ 批量导入窗口**,支持两种方式: