Files
Inventory-Management-System/templates/txn.html
T
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

216 lines
8.7 KiB
HTML
Raw 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>
<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">
{% 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>
<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));
}
</script>
{% endblock %}