feat: 多工厂/厂间调拨/替代料/流水台账折叠 + 重写 README

- 多工厂(factory 表) + 顶栏工厂下拉过滤, 库存按厂聚合
- 厂间调拨(transfer): 源厂减+目标厂加, 总库存不变, 双向流水对账
- 替代料关系(substitute): 出库/BOM领料/生产领料缺料时提示可替代料
- 流水台账(ledger): 入出按日期合并折叠, 按日期导出同一 Excel
- 生产订单增强: 编辑/删除(级联清关联流水)/调入数量/确认调入=厂间调拨
- 入库新增送货日期字段; 出库物料编码扫码定位
- 重写 README 补全全部模块; .gitignore 排除 dist_pkg/build_pkg/*.db
- 停止跟踪 instance/app.db(运行时自动生成)
This commit is contained in:
2026-09-29 11:17:48 +08:00
parent 33efc7c846
commit 5060d027e5
22 changed files with 1961 additions and 149 deletions
+110
View File
@@ -23,6 +23,13 @@
<option value="{{ m.id }}" data-pn="{{ m.part_number }}">{{ m.part_number }} ({{ m.manufacturer or '-' }})</option>
{% endfor %}
</select>
<div id="subHint" class="sub-hint" style="margin-top:8px"></div>
<label>所属工厂 *</label>
<select name="factory_id" required>
{% for f in factories %}
<option value="{{ f.id }}" {% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
{% endfor %}
</select>
<label>批次号</label>
<input name="batch_no">
<label>数量 *</label>
@@ -49,6 +56,43 @@
</div>
</form>
{% if kind == 'outbound' %}
<div class="card bom-issue-card" style="margin-top:18px">
<h3 style="margin-top:0">🧩 按 BOM 批量操作(领料出库 / 调拨入库)</h3>
<p class="small muted">选择 BOM 与数量,系统按单套用量自动生成每种组件的出库/入库流水,任意数量均可。</p>
<form method="post" action="{{ url_for('bom_issue') }}" class="form-card">
<label>操作类型 *</label>
<select name="mode" id="bomMode" onchange="onBomModeChange();refreshBomPreview()">
<option value="pick">按BOM齐套领料出库(扣本厂库存)</option>
<option value="transfer">按BOM调拨入库(调入目标厂)</option>
</select>
<label>BOM 型号 *</label>
<select name="bom_id" id="bomSel" onchange="refreshBomPreview()" 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" id="bomQty" type="number" step="0.01" min="0.01" oninput="refreshBomPreview()" required>
<label id="facLabel">本厂 *</label>
<select name="factory_id" id="bomFac" onchange="refreshBomPreview()">
{% for f in factories %}
<option value="{{ f.id }}" {% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}>{{ f.code }} · {{ f.name }}</option>
{% endfor %}
</select>
<label>经手人</label>
<input name="operator">
<label>业务日期</label>
<input name="occur_date" type="date" value="{{ today }}">
<label>备注</label>
<input name="remark">
<div class="row-actions"><button class="primary" type="submit">生成流水</button></div>
</form>
<div id="bomPreview" style="margin-top:12px"></div>
</div>
{% endif %}
<h3>最近 {{ recs|length }} 条{{ '入库' if kind=='inbound' else '出库' }}记录</h3>
<table class="tbl">
<thead>
@@ -213,5 +257,71 @@ function commitBatch(){
if(d.added>0) location.reload();
}).catch(e=>alert('导入失败: '+e));
}
/* ---- 出库页 BOM 卡片: 实时需求预览 ---- */
function refreshBomPreview(){
const bid = document.getElementById('bomSel');
const qty = document.getElementById('bomQty');
const fac = document.getElementById('bomFac');
const mode = document.getElementById('bomMode');
if(!bid || !qty || !fac || !mode) return;
const box = document.getElementById('bomPreview');
if(!bid.value || !qty.value || parseFloat(qty.value) <= 0){ box.innerHTML=''; return; }
const u = `/api/bom/preview?bom_id=${bid.value}&qty=${encodeURIComponent(qty.value)}&factory_id=${fac.value}&mode=${mode.value}`;
fetch(u).then(r=>r.json()).then(d=>{
if(!d.items.length){ box.innerHTML='<p class="small muted">该 BOM 无组成物料</p>'; return; }
let html = '<table class="tbl"><thead><tr><th>物料编号</th><th>型号</th><th>需求数量</th><th>工厂库存</th><th>状态</th></tr></thead><tbody>';
d.items.forEach(it=>{
const cls = it.short ? 'row-danger' : '';
const tag = it.short ? '<span class="badge danger">缺料</span>' : '<span class="badge ok">充足</span>';
html += `<tr class="${cls}"><td>${it.pn}</td><td>${it.mf}</td><td class="num">${it.need}</td><td class="num">${it.stock}</td><td>${tag}</td></tr>`;
if(it.subs && it.subs.length){
const avail = it.subs.filter(s=>s.available);
const subTxt = it.subs.map(s=>`${s.pn}(库存${s.stock}${s.available?' 有货':' 无货'})`).join(',');
const subCls = avail.length ? 'row-in' : '';
html += `<tr class="${subCls}"><td colspan="5" class="small muted">↳ 可替代料: ${subTxt}</td></tr>`;
}
});
html += '</tbody></table>';
if(d.any_short && mode.value === 'pick'){
html = '<div class="flash error">存在缺料, 提交将被拦截</div>' + html;
}
box.innerHTML = html;
}).catch(e=>{ box.innerHTML=''; });
}
function onBomModeChange(){
const mode = document.getElementById('bomMode');
const lbl = document.getElementById('facLabel');
if(mode && lbl){ lbl.textContent = (mode.value === 'pick') ? '本厂 *' : '目标厂 *'; }
}
if(document.getElementById('bomMode')){
onBomModeChange();
}
/* ---- 出库单物料: 实时提示可替代料 ---- */
function refreshSubHint(){
const sel = document.getElementById('materialSelect');
const box = document.getElementById('subHint');
if(!sel || !box) return;
const mid = sel.value;
if(!mid){ box.innerHTML=''; return; }
fetch(`/api/substitutes/${mid}`).then(r=>r.json()).then(d=>{
if(!d.subs || !d.subs.length){
box.innerHTML = '<p class="small muted">该物料暂无登记替代料</p>';
return;
}
let html = '<div class="card" style="padding:10px 12px"><b>可替代料(库存提示):</b>'
+ '<table class="tbl"><thead><tr><th>替代料编号</th><th>型号</th><th>当前库存</th><th>状态</th></tr></thead><tbody>';
d.subs.forEach(s=>{
const cls = s.available ? 'row-in' : 'row-out';
const tag = s.available ? '<span class="badge ok">有货</span>' : '<span class="badge danger">无货</span>';
html += `<tr class="${cls}"><td>${s.pn}</td><td>${s.mf}</td><td class="num">${s.stock}</td><td>${tag}</td></tr>`;
});
html += '</tbody></table></div>';
box.innerHTML = html;
}).catch(()=>{ box.innerHTML=''; });
}
const _subSel = document.getElementById('materialSelect');
if(_subSel){ _subSel.addEventListener('change', refreshSubHint); }
</script>
{% endblock %}