diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index d09d873..12bb2d7 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -287,7 +287,8 @@ func (h *ProductHandler) QRCode(c *gin.Context) { h.db.Model(&product).Update("public_id", product.PublicID) } - url := config.C.Storage.PublicURL + "/app/product/" + product.PublicID + // 统一走 SSR 公开页路由 /product/:public_id(与 router 一致;旧 /app/product/ 前缀已弃) + url := config.C.Storage.PublicURL + "/product/" + product.PublicID if product.Code != "" { url += "?code=" + product.Code } diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index 12f87da..a9423e3 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -86,6 +86,19 @@ List<_PickerItem> _aggregatePickerItems(List rows) { return map.values.toList(); } +/// 从扫码枪扫到的内容里取商品编码: +/// 优先取二维码 URL 的 `?code=` 参数(形如 `…/product/{public_id}?code={code}`), +/// 取不到则把整串(去空白)当作编码兜底(支持直接扫纯编码/纯 code 条码)。 +String parseScanCode(String raw) { + raw = raw.trim(); + if (raw.isEmpty) return ''; + final q = Uri.tryParse(raw)?.queryParameters['code']; + if (q != null && q.trim().isNotEmpty) return q.trim(); + final m = RegExp(r'[?&]code=([^&]+)').firstMatch(raw); + if (m != null) return Uri.decodeComponent(m.group(1)!).trim(); + return raw; +} + class _ItemRow { int? productId; final String productCode; @@ -164,6 +177,15 @@ class _StockOutFormScreenState extends ConsumerState { List<_PickerItem> _inventoryPickerItems = []; StockOutOrder? _loadedOrder; + // ── 扫码出库 ────────────────────────────────────────────────────────────── + // 扫码枪=HID键盘,扫二维码URL→承接进 _scanCtrl→回车触发 _onScan。 + final _scanCtrl = TextEditingController(); + final _scanFocus = FocusNode(); + // 整仓库存按商品编码建索引(_loadInventory 时建),扫码→本地精确命中,零网络。 + Map _codeIndex = {}; + String _lastScanCode = ''; + int _lastScanMs = 0; + static const _fields = ['qty', 'sale']; final List<_ItemRow> _items = []; @@ -179,6 +201,10 @@ class _StockOutFormScreenState extends ConsumerState { // 新单起始为空,用户通过「从库存选择」添加明细 _initWarehouseDefault(); } + // 桌面端:进页即聚焦扫码框,走近即可连扫(窄屏无扫码枪不聚焦) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !context.isMobile) _scanFocus.requestFocus(); + }); } /// 新建单默认填充默认仓库(2026-07-14):仓库列表加载完成后, @@ -239,6 +265,8 @@ class _StockOutFormScreenState extends ConsumerState { _remarkCtrl.dispose(); _partnerFocus.dispose(); _warehouseFocus.dispose(); + _scanCtrl.dispose(); + _scanFocus.dispose(); for (final item in _items) { item.dispose(); } @@ -272,6 +300,12 @@ class _StockOutFormScreenState extends ConsumerState { for (final item in _inventoryPickerItems) item.productId: item.availableQty }; + // 扫码本地索引:按商品编码(小写归一)→ 聚合库存条目 + _codeIndex = { + for (final item in _inventoryPickerItems) + if (item.productCode.isNotEmpty) + item.productCode.toLowerCase(): item + }; }); } catch (_) {} } @@ -316,6 +350,92 @@ class _StockOutFormScreenState extends ConsumerState { }); } + // ── 扫码出库:扫一个,明细自动 +1 ───────────────────────────────────────── + /// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端 + /// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。 + Future _onScan(String raw) async { + // 立即清空并保持聚焦,承接下一次扫码 + _scanCtrl.clear(); + _scanFocus.requestFocus(); + if (_warehouseId == null) { + _snack('请先选择出库仓库', err: true); + return; + } + final code = parseScanCode(raw); + if (code.isEmpty) return; + // 防扳机抖动连发:同码 300ms 内忽略 + final now = DateTime.now().millisecondsSinceEpoch; + final lc = code.toLowerCase(); + if (lc == _lastScanCode && now - _lastScanMs < 300) return; + _lastScanCode = lc; + _lastScanMs = now; + + // 1) 本地整仓索引命中(已加载项,即时零网络) + var item = _codeIndex[lc]; + // 2) 本地未命中 → 服务端按编码精确查(大仓 >1000 未加载项的兜底) + if (item == null) { + item = await _lookupByCodeRemote(code); + if (!mounted) return; + } + if (item == null) { + _snack('未找到该商品:$code', err: true); + return; + } + if (item.availableQty <= 0) { + _snack('${item.productName} 无可用库存', err: true); + return; + } + _addOrBumpByScan(item); + } + + /// 服务端按编码精确查该仓库存(本地索引未覆盖时兜底,复用库存搜索接口)。 + Future<_PickerItem?> _lookupByCodeRemote(String code) async { + try { + final res = await ref.read(inventoryRepositoryProvider).listInventory( + warehouseId: _warehouseId, keyword: code, pageSize: 30); + final items = _aggregatePickerItems(res.data); + final lc = code.toLowerCase(); + for (final it in items) { + if (it.productCode.toLowerCase() == lc) return it; + } + } catch (_) { + // 网络/接口异常:按未找到处理,上层报 toast + } + return null; + } + + void _addOrBumpByScan(_PickerItem item) { + final existing = + _items.where((r) => r.productId == item.productId).firstOrNull; + final int qn; + if (existing != null) { + qn = (int.tryParse(existing.qtyCtrl.text) ?? 0) + 1; + setState(() => existing.qtyCtrl.text = qn.toString()); + } else { + final row = _ItemRow( + productId: item.productId, + productCode: item.productCode, + productName: item.productName, + series: item.series, + spec: item.spec, + costPrice: item.costPrice, + availableQty: item.availableQty, + salePrice: item.salePrice, // 默认带出参考售价 + ); + row.qtyCtrl.text = '1'; // 扫码按次数点件数,首扫为 1 + qn = 1; + setState(() => _items.add(row)); + } + SystemSound.play(SystemSoundType.click); // 轻提示音 + final over = qn > item.availableQty; + _snack( + over + ? '${item.productName} ×$qn(超可用 ${item.availableQty.toStringAsFixed(0)})' + : '${item.productName} 已加 ×$qn', + err: over, + ); + } + void _copyRow(int index) { final s = _items[index]; final r = _ItemRow( @@ -569,10 +689,26 @@ class _StockOutFormScreenState extends ConsumerState { : null, docHead: _buildDocHead(currentUser?.realName ?? '-'), detailHead: DetailHead(actions: [ + // 扫码枪承接框(HID键盘,扫二维码URL→取?code=→明细自动+1),仅桌面端 + if (!mobile) ...[ + SizedBox( + width: 200, + child: DsInput( + controller: _scanCtrl, + focusNode: _scanFocus, + hintText: '扫码枪扫码…', + onSubmitted: _onScan, + suffix: Icon(LucideIcons.scanLine, + size: 16, color: context.tokens.muted), + ), + ), + const SizedBox(width: 10), // 与按钮间距(对齐原型 .dh-act gap:10) + ], + // 桌面端与扫码框同高(38);窄屏保持 small(32) 不动移动 golden DsButton('从库存选择', icon: LucideIcons.plus, variant: DsBtnVariant.primary, - small: true, + small: mobile, onPressed: _addItem), ]), detail: mobile ? _buildMobileCards() : _buildGrid(), diff --git a/client/test/golden/goldens/stock_out_form_a.png b/client/test/golden/goldens/stock_out_form_a.png index 097f3e6..95d25a9 100644 Binary files a/client/test/golden/goldens/stock_out_form_a.png and b/client/test/golden/goldens/stock_out_form_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_b.png b/client/test/golden/goldens/stock_out_form_b.png index 744e56e..c2046c4 100644 Binary files a/client/test/golden/goldens/stock_out_form_b.png and b/client/test/golden/goldens/stock_out_form_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_c.png b/client/test/golden/goldens/stock_out_form_c.png index 2fc5d2f..56db14c 100644 Binary files a/client/test/golden/goldens/stock_out_form_c.png and b/client/test/golden/goldens/stock_out_form_c.png differ diff --git a/client/test/parse_scan_code_test.dart b/client/test/parse_scan_code_test.dart new file mode 100644 index 0000000..31bcfdd --- /dev/null +++ b/client/test/parse_scan_code_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/screens/stock_out/stock_out_form_screen.dart'; + +/// 扫码出库:从扫码枪内容里取商品编码。 +/// 扫码枪扫二维码得到 URL(形如 …/product/{public_id}?code={code})或纯编码。 +void main() { + group('parseScanCode', () { + test('新路径 /product/xxx?code=', () { + expect( + parseScanCode('https://jiu.51yanmei.com/product/abc-123?code=P1001'), + 'P1001', + ); + }); + + test('旧路径 /app/product/xxx?code=(兼容)', () { + expect( + parseScanCode('https://jiu.51yanmei.com/app/product/abc?code=P1002'), + 'P1002', + ); + }); + + test('纯编码直接扫(无 URL)', () { + expect(parseScanCode('P1003'), 'P1003'); + }); + + test('前后空白被去除', () { + expect(parseScanCode(' P1004 '), 'P1004'); + }); + + test('code 带 URL 编码字符被解码', () { + expect(parseScanCode('https://x/product/y?code=P%2D9'), 'P-9'); + }); + + test('多参数里的 code 也能取到', () { + expect(parseScanCode('https://x/product/y?foo=1&code=P1005&bar=2'), 'P1005'); + }); + + test('空串返回空', () { + expect(parseScanCode(''), ''); + expect(parseScanCode(' '), ''); + }); + + test('无 code 参数的 URL:整串兜底当编码', () { + // 无 ?code= 时不误判,返回原串(由上层按编码匹配,匹配不到再报未找到) + const raw = 'https://x/product/only-public-id'; + expect(parseScanCode(raw), raw); + }); + }); +} diff --git a/design/prototype/screens/stock-in.html b/design/prototype/screens/stock-in.html index f6905c7..b3f652b 100644 --- a/design/prototype/screens/stock-in.html +++ b/design/prototype/screens/stock-in.html @@ -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;} } diff --git a/design/prototype/screens/stock-in.js b/design/prototype/screens/stock-in.js index 165c918..4d7d13a 100644 --- a/design/prototype/screens/stock-in.js +++ b/design/prototype/screens/stock-in.js @@ -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+=`
从库存批量选择
`; h+=`
复制上一行
`; 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+=`
`; + if(mode==='out') h+=`
从库存批量选择
`; h+=`
复制上一行
`; 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(); diff --git a/docs/design/scan-stock-out-design.html b/docs/design/scan-stock-out-design.html new file mode 100644 index 0000000..dcc4d5b --- /dev/null +++ b/docs/design/scan-stock-out-design.html @@ -0,0 +1,185 @@ + + + + + +扫码出库 设计 — 酒库管理系统 + + + +

扫码出库 · 设计方案

+
用扫码枪扫商品标签已印的二维码,自动加进出库单 · 2026-08-24 · 方案评审(未实现)
+ +
+ 把出库建单从「手动翻库存勾选」变成「对着货连扫」:操作员拿扫码枪扫商品标签上已经在印的二维码, + 系统自动把对应商品加进当前出库单、数量累加。核心认知——扫码枪不是摄像头,是一把「键盘」:它把扫到的 + URL 当作键盘输入敲进聚焦的输入框、末尾补一个回车。所以本方案零摄像头、零图像识别依赖,重点全在 + 「聚焦捕获 → 回车触发 → 按扫到的码查本店库存 → 装配明细行」这条链路上。 +
+ +

1. 原理:扫码枪 = HID 键盘

+
+

USB 扫码枪的默认工作模式是 HID 键盘(keyboard-wedge):解码成功后,它把结果文本逐字符「打字」进操作系统当前聚焦的输入控件,并按出厂配置在末尾追加一个回车(Enter/CR)。对应用而言,扫一次码 ≈ 用户瞬间在输入框里粘了一串文本又按了回车。

+

我们标签上二维码编码的正是一条 URL(后端 product.go:290go-qrcode 生成):

+
# 二维码内容 +{PublicURL}/app/product/{public_id}?code={code}
+

因此扫一次,捕获框里会瞬间出现整条 URL + 回车。前端只需:一个常聚焦的输入框 + 监听回车 + 解析 URL 取 public_id

+

区分扫码 vs 人手打字:扫码是爆发式输入(字符间隔通常 <30ms,几十毫秒打完整条 URL),人手打字间隔 >100ms。可用「回车结束 + 输入速度」双判据;但出库这种专用扫码模式下,只靠「专用捕获框 + 回车提交」就够,不必依赖计时。

+

⚠ 必须是 2D(二维码)扫码枪。便宜的一维条码枪读不了 QR。若采购的是 1D 枪 → 见附录 B(改印一维条码)。

+
+ +

2. 端上定位:桌面优先

+
+

扫码枪插 PC(Windows / macOS)USB 口用,所以扫码出库主要落在桌面/宽屏端的出库建单页。手机端不接扫码枪——手机若要扫码得走摄像头(mobile_scanner)那是另一条技术路线,本方案不含

+

前端只在 stock_out_form_screen.dart宽屏布局里加「扫码模式」开关,窄屏不出现。

+
+ +

3. 数据链路:扫到的 URL → 本店 product_id

+
+

关键 gap:出库明细行以 product_id(数字主键)为锚(StockOutItem.ProductID),但扫到的是 public_id / code。需要一步映射,把扫到的码解析成当前店的 product + 该仓库可用库存。分两段:

+ +

3.1 前端解析 URL

+
    +
  • public_id = 路径中 product/ 之后、? 之前的段。容错两种前缀(/app/product/ 与 SSR 的 /product/),也容错「直接扫纯 public_id / 纯 code」的情况。
  • +
  • code = query 参数 code(作兜底匹配用)。
  • +
  • 都解析不出 → 提示「无法识别的二维码」,不发请求。
  • +
+ +

3.2 后端新增解析接口(鉴权 + shop 隔离)

+
GET /api/v1/inventory/scan?public_id={id}&warehouse_id={wid} (登录鉴权,shop_id 取自 JWT) + +逻辑: + 1. 本店精确查 product WHERE shop_id=? AND public_id=? (跨店查不到 → 404) + 2. 查该仓该 product 的 inventory 行 status IN (stock,on_sale) (排除 sold) + 3. 返回 { product_id, product_code, name, series, spec, + available_qty, sale_price } (结构同前端 _PickerItem)
+

命中不到 / 不属本店 / 该仓无库存 / 已售罄 → 返回明确错误码,前端据此红字提示。返回结构复用出库表单已有的 _PickerItem,前端拿到即可直接装配明细行,无需二次转换。

+

为何要新接口、不复用现有的

+ + + + + +
现有能力为什么不能直接用
鉴权 GET /inventory?code=codeLIKE 模糊匹配(inventory.go:113),不精确、也不吃 public_id
公开 GET /public/products/:public_id按 public_id 查,但无鉴权、无 shop 隔离、无库存维度,不能驱动出库
鉴权 GET /inventory?product_id=精确,但入参已经是 product_id——正是我们缺的那一步转换
+
+ +

4. 交互流程:扫一个,加一个

+
+
① 打开出库建单 → 选仓库 → 开「扫码模式」 +② 捕获框自动聚焦(失焦自动重夺) +③ 扣扳机 ─────────────▶ URL + 回车 进框 +④ 前端解析 public_id ─▶ GET /inventory/scan?public_id=..&warehouse_id=.. +⑤ 命中: + 该 product 已在明细 ─▶ 数量 +1(可内联改) + 不在明细 ─▶ 新增一行 qty=1,带 product_id / 售价 / 可用量 + ✓ 成功提示音 + toast「茅台飞天 已加,当前 3 件」,焦点留在捕获框,接着扫 +⑥ 未命中 / 售罄 / 跨店: + ✗ 失败提示音 + 红 toast,不加行 +⑦ 扫完 → 照常提交出库单(提交仍走 CheckInventoryAvailability 库存校验)
+

整个过程手不离枪:焦点始终锁在捕获框,一次扫码 = 一次「解析 → 查 → 加行 → 提示」闭环,无需鼠标。

+
+ +
+

⚑ 需要你拍板:序列号语义下,一次扫码加多少数量?

+

数据铁律里 product = 一个特有产品 / 序列号,很多时候一个 product 对应一件 / 一批具体货。这决定「扫一次加几件」:

+ + + + +
口径行为适用
A · 每扫 +1建议默认每扫一次数量 +1,可内联改;超可用量即时红字告警一个 product = 多件同款,标签贴整批,按扫的次数点件数
B · 一码一件扫一次即锁定该 product(固定 1,或整行可用量),重复扫视为重复→忽略/提示严格一物一码一序列号,一个码就是一件
+

建议先按 A(每扫 +1,数量可改) 落地,最贴合现在「批量勾选后改数量」的习惯。请你确认口径再进实现。

+
+ +

5. 改动清单

+
+

后端

+
    +
  • 新增 GET /api/v1/inventory/scan:handler + service 查询,shop 隔离、排除 sold、返回 _PickerItem 同构体。(inventory.go / router)
  • +
  • (可选)修正二维码生成路径:/app/product/ → 与 SSR 路由 /product/ 统一(product.go:290),消除解析歧义。不改也行,前端做前缀容错即可。
  • +
+

前端 · client/lib/screens/stock_out/stock_out_form_screen.dart

+
    +
  • 「扫码模式」开关 + 常聚焦捕获输入(RawKeyboardListener 或自动重夺焦点的隐藏 TextField)。
  • +
  • URL 解析工具(取 public_id / code,容错前缀)。
  • +
  • 调 scan 接口 → 复用 _PickerItem 装配 → 加行 / 累加数量。
  • +
  • 成功 / 失败提示音 + toast(扫码场景听觉反馈很重要,操作员眼睛在货上不在屏上)。
  • +
  • 不需要引入 mobile_scanner / camera——扫码枪走键盘,零摄像头依赖,pubspec.yaml 不动。
  • +
+
+ +

6. 边界与坑

+
+ + + + + + + + + + +
处理
1D 枪读不了 QR需 2D 枪;或加印 Code128(code)(附录 B)
中文输入法拦截扫码字符捕获用 raw key 监听绕过 IME,或强制英文态
扫码枪没配回车后缀多数出厂默认带;没有则扫设置码开「Add Enter/CR Suffix」
焦点丢失(用户点了别处)失焦自动重夺 + 明显的「扫码模式」态提示
枪抖动同码连发去重窗口:300ms 内同 public_id 忽略
跨店 / 售罄 / 未上架后端硬拦(shop_id 隔离 + 排除 sold),前端红字
权限出库受 ReadOnly 中间件保护;scan 接口只读,只读用户可查不可提交,天然安全
连扫速度每扫打一次后端;若要极致速度可预载该仓全量 inventory 做本地 public_id→product_id 映射(附录 C,可选)
+
+ +

附录 A · 扫码枪 vs 手机摄像头

+
+ + + + + + + + +
扫码枪(本方案)手机摄像头
技术路线HID 键盘输入,无图像mobile_scanner + 摄像头 + 图像解码
桌面 PC(Win/Mac)手机 App
前端依赖零新增新增扫码库 + 摄像头权限
连扫效率高(扣扳机即出)中(对焦、找码)
硬件成本一把 2D 枪已有手机
本方案采用不含(未来可作手机端补充)
+
+ +

附录 B · 若用 1D 枪 → 加印一维条码

+
+

若采购的是便宜的一维条码枪(读不了 QR),改在标签上加印一段 Code128 一维条码,内容 = 商品 code(同店唯一)。扫到的就是纯 code,走 GET /inventory/scan?code={code} 精确匹配即可(同样带 shop 隔离)。QR 与一维条码可并存于同一标签(QR 供顾客溯源,条码供出库)。改动落在 label_data.dart / 打印工具。

+
+ +

附录 C · 本地映射加速(可选优化)

+
+

选好仓库后一次性把该仓 inventory 全量拉到前端,建 public_id → {product_id, 售价, 可用量} 内存映射。扫码时本地命中、零网络往返,连扫如飞;提交前再由后端 CheckInventoryAvailability 兜底校验。适合货多、连扫密集的场景,第一版可不做。

+
+ +

落地评级

+
+

本改动 = 新接口 + 跨后端/前端 2 模块,属项目规则里的「大改」:按 CLAUDE.md,实现前须先进 plan 模式经你批准;且前端改动须遵守 design-first(先改设计系统原型再改真实页面)。本文件仅方案评审,你确认「数量口径(A/B)」+「是否修正二维码路径前缀」后,我再出实现 plan。

+
+ + + diff --git a/docs/design/scan-stock-out-plan.html b/docs/design/scan-stock-out-plan.html new file mode 100644 index 0000000..4cbb6c1 --- /dev/null +++ b/docs/design/scan-stock-out-plan.html @@ -0,0 +1,142 @@ + + + + + +扫码出库 实现计划 — 酒库管理系统 + + + +

扫码出库 · 实现计划

+
目标页:新建/修改出库单stock_out_form_screen.dart)· 扫码枪扫商品 → 明细自动 +1 · 2026-08-25 · 待批准
+ +
+ 在「新建出库单」页,操作员用扫码枪扫商品标签上已印的二维码,商品明细里自动新增该商品并数量 +1;同一商品重复扫则数量累加。 + 核心省力点:二维码 URL 里带 ?code={商品编码},而本页 _loadInventory 已把整仓库存(≤1000 个,stockOutPickerPageSize=1000) + 聚合进本地 _inventoryPickerItems(每条含 productCode)——扫码→解析 code→本地命中→加行,零网络、零后端改动。 +
+ +

1. 原理(一句话回顾)

+
+

扫码枪 = HID 键盘:扫到内容当作键盘输入敲进当前聚焦的输入框、末尾补回车。本页放一个聚焦的「扫码框」承接即可,零摄像头依赖。 + 扫到的是 URL https://…/app/product/{public_id}?code={code};我们取其中的 code 与整仓已加载的库存做精确匹配。

+
+ +

2. 数据流

+
+
① 选出库仓库(本页已有)→ _loadInventory 建两张本地表: + _inventoryPickerItems(现有) + _codeIndex: Map<String code, _PickerItem>新增) +② 扫码框聚焦(失焦自动重夺) +③ 扣扳机 ──▶ URL + 回车 落入扫码框,onSubmitted 触发 _onScan(raw) +④ _onScan: + 解析 code = Uri.parse(raw).queryParameters['code'] (拿不到则整串 trim 当 code 兜底) + item = _codeIndex[code] + ├─ 命中 && 已在明细 ──▶ 该行 数量 +1(超可用量则黄字提醒,仍加) + ├─ 命中 && 不在明细 ──▶ 新增一行,数量=1,带 productId/售价/可用量 + └─ 未命中 ───────────▶ 红 toast「未找到该商品/不在本仓」,不加行 +⑤ 清空扫码框 + 重新聚焦 + 轻提示音,等下一扫 +⑥ 扫完照常「提交审核」(提交时后端 CheckInventoryAvailability 兜底校验)
+

数量口径 = A(每扫 +1,已定)。注意:与「从库存选择」默认带出整箱可用量不同,扫码是按扫的次数点件数,故首扫为 1、再扫累加。数量列仍可手动改。

+
+ +

3. 前端改动(唯一必改文件:stock_out_form_screen.dart

+
+ + + + + + + + + +
改动点具体
本地 code 索引_loadInventory 里,聚合完 _inventoryPickerItems 后顺手建 _codeIndex = { for it in items : it.productCode : it }(productCode 非空才入)。
扫码框明细头 DetailHead(actions:[…]) 里,在「从库存选择」左侧加一个紧凑 DsInput(图标 LucideIcons.scanLine,占位「扫码枪扫码…」)+ 独立 FocusNode _scanFocus,桌面端 autofocus。窄屏(context.isMobile)不显示(扫码枪是桌面外设)。
_onScan(raw)新增方法:无仓库→toast「请先选择出库仓库」;解析 code;查 _codeIndex;命中则 _addOrBumpByScan(item);未命中红 toast。末尾 _scanCtrl.clear() + _scanFocus.requestFocus()
_addOrBumpByScan(item)_itemsproductId==item.productId:有→qtyCtrl.text = (now+1);无→复用 _addItem 的建行逻辑但 qty 固定 1setState 刷新。超可用量(>availableQty)→黄字 toast 提醒但不拦。
去重防抖_lastScan(code,ts),同 code 300ms 内重复直接忽略(防扳机抖动连发)。
提示音成功 SystemSound.play(SystemSoundType.click);失败可 HapticFeedback/仅红 toast。轻量,主反馈仍是 toast(操作员眼在货上)。
清理dispose() 里释放 _scanCtrl/_scanFocus
+

解析函数抽成纯函数 String? parseScanCode(String raw)(放本文件或 util),便于单测:覆盖 /app/product/x?code=1/product/x?code=1、纯 code、脏串。

+

不新增依赖:不引 mobile_scanner/camerapubspec.yaml 不动。

+
+ +

4. 原型改动(design-first,先改再改代码,同一提交

+
+

按项目铁律「原型与真实 100% 一致,前端改动必须先改原型」:在 design/prototype/screens/stock-out.html 的明细头,与「从库存选择」并排加一个扫码输入框原件(用登记过的 atoms,占位「扫码枪扫码…」)。评审原型 → 代码随后落地 → 原型与代码同提交。若涉及新图标 scan-line 未登记,先在 index.html + icons.js 登记再用(L1 单源)。

+
+ +

5. 二维码路径统一(顺带,小改)

+
+

后端 product.go:290 生成 URL 用的是 /app/product/,与 SSR 路由 /product/ 不一致。改成 /product/ 统一(已定)。对本方案非必需(我们只取 ?code=,不依赖路径段),但顺手消歧、利于将来按 public_id 解析。属 server 侧一行改动,随下次 server 发版生效。

+
+ +
+

⚑ 唯一待你拍板:要不要现在就做后端精确查接口?

+

本地 code 索引覆盖整仓 ≤1000 个商品,绝大多数门店够用。但两种情况本地会漏:①单仓商品 >1000(超分页);②某商品无 code(QR 只有 public_id,则无 ?code=)。

+ + + + +
方案范围建议
A · 仅前端(本地匹配)零后端改动、零部署,扫→加立即生效;漏网情况给红 toast 提示改用「从库存选择」推荐先做,最快落地
B · 加后端 GET /inventory/scan本地未命中时回退查后端(精确 by code/public_id、shop 隔离、排除 sold),覆盖 >1000 与 public_id-only;需 server 发版作 Phase-2,等确有大仓再加
+

我的建议:先做 A(纯前端) 把这页跑通,B 留作后续。你也可以要求一步到位做 A+B。

+
+ +

6. 验证(无扫码枪也能全验,你已有 macOS app)

+
+
    +
  1. 端到端:手动往扫码框粘贴一条真实商品 URL(形如 …/app/product/xxx?code=P1234)再按回车——效果与扫码枪 100% 等价。看是否新增行、再粘同一条是否 +1、粘不存在的 code 是否红 toast。
  2. +
  3. 解析单测flutter test 覆盖 parseScanCode 各输入。
  4. +
  5. DoDflutter analyze --no-fatal-infos --no-fatal-warnings 无 error;flutter test 全过。
  6. +
  7. 上线后拿 2D 扫码枪扫一张真标签确认「带回车后缀」即可(1D 枪读不了 QR,见原理文档附录 B)。
  8. +
+
+ +

7. 改动文件清单 & 执行顺序

+
+
    +
  • 原型:design/prototype/screens/stock-out.html 加扫码框(+ 必要时 index.html/icons.js 登记 scan-line 图标)
  • +
  • 前端:client/lib/screens/stock_out/stock_out_form_screen.dart —— _codeIndex / 扫码框 / _onScan / _addOrBumpByScan / 防抖 / dispose;抽 parseScanCode
  • +
  • 测试:client/test/…/parse_scan_code_test.dart 新增
  • +
  • (方案 B 选做)后端:inventory.go + router 加 GET /inventory/scan
  • +
  • (顺带)后端:product.go:290 路径 /app/product//product/
  • +
  • 本地验证(analyze + test + macOS app 手动粘 URL)→ 你验收 → 发版 /release client
  • +
+

落地评级:方案 A 为前端单文件 + 原型 + 测试,属中等改动;方案 B 追加后端接口即升为跨模块大改。均遵守 design-first。本文件仅计划评审,你批准后再实现。

+
+ +

8. 待确认

+
+
    +
  1. 先做方案 A(纯前端),还是一步到位 A+B(含后端精确查)?(建议 A)
  2. +
  3. 二维码路径 /app/product//product/ 这次一并改吗?(已定要改,确认随本次一起)
  4. +
+
+ + + diff --git a/docs/index.html b/docs/index.html index c0fe35d..c00769c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -43,6 +43,8 @@
  • 退单原型(已审核单据)HTML
  • 出入库单打印排版方案(6 种黑白样式精选)HTML · PDF
  • 入库确认进价(暂估价前向补偿)设计HTML
  • +
  • 扫码出库(扫码枪扫二维码建出库单)设计HTML — 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决
  • +
  • 扫码出库 · 实现计划(出库表单扫码+1)HTML — 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型
  • 库存筛选规格MD