feat(client): 扫码出库(扫码枪扫二维码,明细自动 +1)

出库单新建页桌面端加「扫码枪扫码」框:扫商品二维码 URL → 取其中
的 ?code= → 本地整仓索引精确命中 → 明细自动新增/累加数量(每扫
+1)。本地未命中(大仓库存超前端加载上限 1000)时走服务端按编码
精确查兜底,复用现有库存搜索接口,无需新后端接口。

- 前端 stock_out_form_screen:扫码框 + _onScan + _codeIndex +
  parseScanCode + 服务端兜底 + 300ms 防抖 + 提示音;桌面专属,
  窄屏隐藏。
- 后端 product.go:二维码 URL 路径 /app/product/ → /product/
  统一到 SSR 公开页路由。
- 原型 stock-in.js/html:出库明细头同步加扫码框(design-first)。
- 测试 parse_scan_code_test(8 例)+ 出库表单桌面 golden 重生成。
- 设计/实现计划文档 docs/design/scan-stock-out-*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
wangjia
2026-08-25 19:11:15 +08:00
parent b5715aec8d
commit 22047b725c
11 changed files with 556 additions and 5 deletions
+8 -1
View File
@@ -84,7 +84,13 @@
.detail-head .dt{font-size:var(--fs-title); font-weight:600; color:var(--heading);}
.detail-head .hint{margin-left:12px; font-size:var(--fs-sm); color:var(--faint);}
.detail-head .hint kbd{font-family:var(--font-mono); background:var(--bg); border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; font-size:var(--fs-xs); color:var(--muted);}
.detail-head .dh-act{margin-left:auto; display:flex; gap:10px;}
.detail-head .dh-act{margin-left:auto; display:flex; align-items:center; gap:10px;}
/* 扫码出库:扫码枪(HID键盘)承接框,仅出库模式桌面端出现 */
.scanbox{display:flex; align-items:center; gap:7px; height:38px; padding:0 10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); transition:border-color .15s,box-shadow .15s;}
.scanbox:focus-within{border-color:var(--primary); box-shadow:0 0 0 3px var(--brand50);}
.scanbox svg{width:16px; height:16px; color:var(--muted); stroke-width:1.8; fill:none; stroke:currentColor; flex:none;}
.scanbox input{border:none; outline:none; background:transparent; width:150px; font-size:var(--fs-body); color:var(--text); font-family:var(--font);}
.scanbox input::placeholder{color:var(--faint);}
#detail{flex:1; min-height:0; display:flex; flex-direction:column;}
.mcards{display:flex; flex-direction:column; gap:12px;}
@@ -106,6 +112,7 @@
.dochead-grid{grid-template-columns:1fr;}
.ph-actions .desk{display:none;}
.form-foot .foot-act .desk{display:none;}
.scanbox{display:none;} /* 扫码枪桌面外设,窄屏不出现 */
}
@media (min-width:601px){ .ph-actions .mob, .form-foot .foot-act .mob{display:none;} }
</style>
+31 -2
View File
@@ -30,7 +30,7 @@ const NAMES = [
['n12','古井贡年份原浆','gujinggongnianfenyuanjiang','gjgnfyj','s42','p5006',390,490,12],
['n13','西凤酒旗舰版','xifengjiuqijianban','xfjqjb','s45','p5006',280,360,88],
['n14','水井坊井台','shuijingfangjingtai','sjfjt','s52','p5006',680,860,6],
].map(a=>({id:a[0],name:a[1],py:a[2],init:a[3],dSeries:a[4],dSpec:a[5],cost:a[6],sale:a[7],stock:a[8]}));
].map((a,i)=>({id:a[0],name:a[1],py:a[2],init:a[3],dSeries:a[4],dSpec:a[5],cost:a[6],sale:a[7],stock:a[8],code:'P'+String(1001+i)}));
const SUPPLIERS = [
['g1','鼎晟供应链','dingshenggongyinglian','dsgyl','GYS001'],['g2','茅台华东总代','maotaihuadongzongdai','mthdzd','GYS002'],
['g3','川酒集团批发','chuanjiujituanpifa','cjjtpf','GYS003'],['g4','名烟名酒城','mingyanmingjiucheng','mymjc','GYS004'],
@@ -273,7 +273,36 @@ function renderDetail(){
detail.innerHTML = layout==='grid'? gridHTML() : cardsHTML();
}
function relayout(){ const layout=viewportW()<600?'cards':'grid'; if(layout!==curLayout) renderDetail(); }
function renderDetailHead(){ const el=document.getElementById('dhAct'); if(!el) return; let h=''; if(readonlyMode()){ el.innerHTML=''; return; } if(mode==='out') h+=`<div class="btn ghost sm" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="btn ghost sm" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; el.innerHTML=h; }
function renderDetailHead(){ const el=document.getElementById('dhAct'); if(!el) return; let h=''; if(readonlyMode()){ el.innerHTML=''; return; }
// 出库:扫码枪承接框(HID键盘,扫二维码URL→取?code=→明细自动+1),置于动作区最左
// 出库模式明细头有扫码框(38高),按钮同步用常规尺寸(38)与之对齐;入库无扫码框保持 sm(32)
const bcls = mode==='out' ? 'btn ghost' : 'btn ghost sm';
if(mode==='out') h+=`<div class="scanbox" title="扫码枪扫商品二维码,明细自动+1"><svg viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/></svg><input id="scanInput" placeholder="扫码枪扫码…" autocomplete="off" onkeydown="onScanKey(event)"></div>`;
if(mode==='out') h+=`<div class="${bcls}" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="${bcls}" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; el.innerHTML=h;
// 扫码框自动聚焦承接下一次扫码;但不抢正在编辑的单元格/下拉焦点
const si=document.getElementById('scanInput'); if(si){ const ae=document.activeElement; const inCell=ae&&(ae.classList&&ae.classList.contains('gci')||ae.closest&&ae.closest('.combo')); if(!inCell) si.focus(); }
}
// ── 扫码出库 ────────────────────────────────────────────────────────────────
let _lastScan={code:'',t:0};
/** 从扫码内容取商品编码:优先 URL 的 ?code=,否则整串当编码。 */
function parseScanCode(raw){ raw=(raw||'').trim(); if(!raw) return ''; try{ const c=new URL(raw).searchParams.get('code'); if(c) return c.trim(); }catch(_){} const m=raw.match(/[?&]code=([^&]+)/); if(m) return decodeURIComponent(m[1]).trim(); return raw; }
function onScanKey(e){ if(e.key==='Enter'){ e.preventDefault(); const v=e.target.value; e.target.value=''; onScanSubmit(v); } }
function onScanSubmit(raw){
if(readonlyMode()) return;
if(!state.doc.warehouse){ toast('请先选择出库仓库'); focusDoc('warehouse'); return; }
const code=parseScanCode(raw); if(!code) return;
const now=Date.now(); if(code===_lastScan.code && now-_lastScan.t<300) return; _lastScan={code,t:now};
const nm=NAMES.find(n=>n.code&&n.code.toLowerCase()===code.toLowerCase());
if(!nm){ toast('未找到该商品:'+code); return; }
if(nm.stock<=0){ toast(nm.name+' 无可用库存'); return; }
let r=state.rows.find(x=>x.name===nm.id);
if(r){ r.qty=String((parseInt(r.qty)||0)+1); }
else { r=state.rows.find(x=>!x.name); if(!r){ r=newRow(); state.rows.push(r); } r.name=nm.id; r.series=nm.dSeries; r.spec=nm.dSpec; r.price=String(nm.sale); r.sale=String(nm.sale); r.avail=nm.stock; r.qty='1'; }
const qn=parseInt(r.qty)||0, over=qn>nm.stock;
renderDetail(); updateTotals(); draftSave();
const si=document.getElementById('scanInput'); if(si) si.focus();
toast(over ? `${nm.name} ×${qn}(超可用 ${nm.stock}` : `${nm.name} 已加 ×${qn}`);
}
function toggleExpand(i){ state.rows[i].expanded=!state.rows[i].expanded; renderDetail(); }
function gridHTML(){
const cols=MODES[mode].cols; const ro=readonlyMode();