Files
star 5060d027e5 feat: 多工厂/厂间调拨/替代料/流水台账折叠 + 重写 README
- 多工厂(factory 表) + 顶栏工厂下拉过滤, 库存按厂聚合
- 厂间调拨(transfer): 源厂减+目标厂加, 总库存不变, 双向流水对账
- 替代料关系(substitute): 出库/BOM领料/生产领料缺料时提示可替代料
- 流水台账(ledger): 入出按日期合并折叠, 按日期导出同一 Excel
- 生产订单增强: 编辑/删除(级联清关联流水)/调入数量/确认调入=厂间调拨
- 入库新增送货日期字段; 出库物料编码扫码定位
- 重写 README 补全全部模块; .gitignore 排除 dist_pkg/build_pkg/*.db
- 停止跟踪 instance/app.db(运行时自动生成)
2026-09-29 11:17:48 +08:00

190 lines
8.2 KiB
HTML

{% 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>
<div class="scope-banner">
{% if current_factory %}当前范围:<b>{{ current_factory.code }} · {{ current_factory.name }}</b> · 仅统计该工厂库存{% else %}当前范围:<b>全部工厂(汇总)</b> · 顶栏可切换具体工厂{% endif %}
</div>
<div class="tabs">
<button class="tab active" type="button" onclick="showTab('mat', this)">📦 库存预警</button>
{% if can_bom %}<button class="tab" type="button" onclick="showTab('bom', this)">🔧 BOM 物料齐套</button>{% endif %}
</div>
<!-- 标签一: 全部物料库存 -->
<div id="tab-mat">
<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>
</div>
{% if can_bom %}
<!-- 标签二: BOM 物料齐套看板 -->
<div id="tab-bom" style="display:none">
{% if bom_board %}
<div class="bom-filter">
<label for="bomSelect">选择 BOM</label>
<select id="bomSelect" onchange="filterBom()">
<option value="all">全部 BOM(总览)</option>
{% for bb in bom_board %}
<option value="{{ bb.b.id }}">{{ bb.b.code }}{% if bb.b.name %} · {{ bb.b.name }}{% endif %}</option>
{% endfor %}
</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">
<strong>{{ bb.b.code }}</strong>
<span class="muted">{{ bb.b.name or '' }}</span>
{% 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>
<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 }}" 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>
<td class="num">{{ '%.4g'|format(it.qty) }}</td>
<td class="num">{{ '%.2f'|format(it.stock) }}</td>
<td>
{% if it.status=='danger' %}<span class="badge danger">缺料</span>
{% 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="8" class="muted">该 BOM 暂无组成物料</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% else %}
<p class="muted">暂无 BOM,请先到 BOM 页面导入或新建。</p>
{% endif %}
</div>
{% endif %}
<script>
function showTab(id, btn){
document.getElementById('tab-mat').style.display = id==='mat' ? 'block' : 'none';
document.getElementById('tab-bom').style.display = id==='bom' ? 'block' : 'none';
document.querySelectorAll('.tabs .tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
if(id==='bom') filterBom();
}
function filterBom(){
var sel = document.getElementById('bomSelect');
if(!sel) return;
var v = sel.value;
var cards = document.querySelectorAll('#tab-bom [data-bid]');
cards.forEach(function(c){
c.style.display = (v==='all' || c.getAttribute('data-bid')===v) ? 'block' : 'none';
});
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 %}