Files
star 012e018b12 feat: 生产订单库存口径/排序/编辑体验与入库默认工厂优化
- 入库登记「所属工厂」默认选中总部(SELF),出库不变
- 编辑生产订单改为仅更新字段:改单号同步关联出入库 order_ref、保留已录入调入数量与已入账流水(移除冲销逻辑)
- 生产订单列表/详情按最终订单号(WO+YYYYMMDD+序号)降序排序
- 生产订单详情库存/缺口改用「下单前库存快照」(按下单时间截断),缺口=库存-需求,升序使缺料排前
- 生产工厂列显示「编号 · 公司名称」
- 详情列表列序调整:下单前库存置需求左边、调入数量置缺口右边;状态右侧新增「工厂库存」列(=缺口+调入数量)
- 状态「缺料/充足」徽标强制单行显示(.badge white-space:nowrap)
- 已调入(已入账)数量可编辑:保存/确认后按新值自动撤销原流水并从原来源厂重新入账,源厂库存不足判定缺料保留原入账,系统总库存不变
2026-10-15 13:30:15 +08:00

328 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>
<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" id="materialSelect" required>
<option value="">-- 选择物料 --</option>
{% for m in mats %}
<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 kind=='inbound' and default_factory_id %}{% if f.id == default_factory_id %}selected{% endif %}{% else %}{% if (current_factory_id == f.id) or (not current_factory_id and loop.first) %}selected{% endif %}{% endif %}>{{ f.code }} · {{ f.name }}</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">
<label>送货日期</label>
<input name="delivery_date" type="date" value="{{ today }}">
{% 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>
{% 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>
<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(){
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(); });
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));
}
/* ---- 出库页 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 %}