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