Files
jiu/docs/design/scan-stock-out-design.html
T
wangjia 22047b725c 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
2026-08-25 19:11:15 +08:00

186 lines
15 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫码出库 设计 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:20px;margin:0 0 4px;}
h2{font-size:16px;margin:26px 0 8px;color:var(--primary-dark);border-left:4px solid var(--primary);padding-left:10px;}
h3{font-size:14px;margin:18px 0 6px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;max-width:940px;margin-bottom:16px;}
p{font-size:14px;margin:6px 0;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
ol,ul{font-size:14px;margin:6px 0;padding-left:22px;}
li{margin:5px 0;}
table{width:100%;border-collapse:collapse;font-size:13px;max-width:940px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border:1px solid var(--border);}
td{padding:9px 10px;border:1px solid #EEF1F5;vertical-align:top;}
.tag{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;}
.tag.ok{background:#E6F3EC;color:var(--success);}
.tag.warn{background:#FFF4E5;color:var(--warn);}
.tag.no{background:var(--danger-bg);color:var(--danger);}
.lead{font-size:14px;background:#F0F6FF;border-left:3px solid var(--primary);padding:10px 14px;border-radius:4px;max-width:940px;}
.flow{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:940px;overflow:auto;line-height:1.7;}
.flow .c{color:#7fd1a0;}
.flow .k{color:#f0a868;}
.flow .d{color:#8593a6;}
.decision{background:#FFF9EC;border:1px solid #F0DDB0;border-radius:10px;padding:14px 18px;max-width:940px;margin-bottom:16px;}
.decision h3{color:var(--warn);margin-top:0;}
</style>
</head>
<body>
<h1>扫码出库 · 设计方案</h1>
<div class="sub">用扫码枪扫商品标签已印的二维码,自动加进出库单 · 2026-08-24 · 方案评审(未实现)</div>
<div class="lead">
把出库建单从「<b>手动翻库存勾选</b>」变成「<b>对着货连扫</b>」:操作员拿<b>扫码枪</b>扫商品标签上<b>已经在印</b>的二维码,
系统自动把对应商品加进当前出库单、数量累加。核心认知——<b>扫码枪不是摄像头,是一把「键盘」</b>:它把扫到的
URL 当作键盘输入敲进聚焦的输入框、末尾补一个回车。所以本方案<b>零摄像头、零图像识别依赖</b>,重点全在
「聚焦捕获 → 回车触发 → 按扫到的码查本店库存 → 装配明细行」这条链路上。
</div>
<h2>1. 原理:扫码枪 = HID 键盘</h2>
<div class="card">
<p>USB 扫码枪的默认工作模式是 <b>HID 键盘(keyboard-wedge</b>:解码成功后,它把结果文本逐字符「打字」进操作系统<b>当前聚焦的输入控件</b>,并按出厂配置在末尾追加一个<b>回车(Enter/CR</b>。对应用而言,扫一次码 ≈ 用户瞬间在输入框里粘了一串文本又按了回车。</p>
<p>我们标签上二维码编码的正是一条 URL(后端 <code>product.go:290</code><code>go-qrcode</code> 生成):</p>
<div class="flow"><span class="d"># 二维码内容</span>
{PublicURL}<span class="k">/app/product/</span><span class="c">{public_id}</span>?code=<span class="c">{code}</span></div>
<p>因此扫一次,捕获框里会瞬间出现<b>整条 URL + 回车</b>。前端只需:<b>一个常聚焦的输入框</b> + <b>监听回车</b> + <b>解析 URL 取 public_id</b></p>
<p><b>区分扫码 vs 人手打字</b>:扫码是爆发式输入(字符间隔通常 &lt;30ms,几十毫秒打完整条 URL),人手打字间隔 &gt;100ms。可用「回车结束 + 输入速度」双判据;但出库这种<b>专用扫码模式</b>下,只靠「专用捕获框 + 回车提交」就够,不必依赖计时。</p>
<p class="tag warn" style="display:block;max-width:920px;padding:10px 14px;line-height:1.6;">⚠ 必须是 <b>2D(二维码)扫码枪</b>。便宜的一维条码枪读不了 QR。若采购的是 1D 枪 → 见<b>附录 B</b>(改印一维条码)。</p>
</div>
<h2>2. 端上定位:桌面优先</h2>
<div class="card">
<p>扫码枪插 PCWindows / macOSUSB 口用,所以扫码出库主要落在<b>桌面/宽屏端的出库建单页</b>。手机端不接扫码枪——手机若要扫码得走摄像头(<code>mobile_scanner</code>)那是另一条技术路线,<b>本方案不含</b></p>
<p>前端只在 <code>stock_out_form_screen.dart</code><b>宽屏布局</b>里加「扫码模式」开关,窄屏不出现。</p>
</div>
<h2>3. 数据链路:扫到的 URL → 本店 product_id</h2>
<div class="card">
<p><b>关键 gap</b>:出库明细行以 <code>product_id</code>(数字主键)为锚(<code>StockOutItem.ProductID</code>),但扫到的是 <b>public_id / code</b>。需要一步映射,把扫到的码解析成<b>当前店</b>的 product + 该仓库可用库存。分两段:</p>
<h3>3.1 前端解析 URL</h3>
<ul>
<li><code>public_id</code> = 路径中 <code>product/</code> 之后、<code>?</code> 之前的段。<b>容错</b>两种前缀(<code>/app/product/</code> 与 SSR 的 <code>/product/</code>),也容错「直接扫纯 public_id / 纯 code」的情况。</li>
<li><code>code</code> = query 参数 <code>code</code>(作兜底匹配用)。</li>
<li>都解析不出 → 提示「无法识别的二维码」,不发请求。</li>
</ul>
<h3>3.2 后端新增解析接口(鉴权 + shop 隔离)</h3>
<div class="flow"><span class="k">GET</span> /api/v1/inventory/scan?public_id=<span class="c">{id}</span>&amp;warehouse_id=<span class="c">{wid}</span> <span class="d">(登录鉴权,shop_id 取自 JWT</span>
<span class="d">逻辑:</span>
1. 本店精确查 product WHERE shop_id=? AND public_id=? <span class="d">(跨店查不到 → 404</span>
2. 查该仓该 product 的 inventory 行 status IN (stock,on_sale) <span class="d">(排除 sold</span>
3. 返回 { product_id, product_code, name, series, spec,
available_qty, sale_price } <span class="d">(结构同前端 _PickerItem</span></div>
<p>命中不到 / 不属本店 / 该仓无库存 / 已售罄 → 返回明确错误码,前端据此红字提示。返回结构<b>复用出库表单已有的 <code>_PickerItem</code></b>,前端拿到即可直接装配明细行,无需二次转换。</p>
<p><b>为何要新接口、不复用现有的</b></p>
<table>
<tr><th>现有能力</th><th>为什么不能直接用</th></tr>
<tr><td>鉴权 <code>GET /inventory?code=</code></td><td><code>code</code><b>LIKE 模糊</b>匹配(<code>inventory.go:113</code>),不精确、也不吃 public_id</td></tr>
<tr><td>公开 <code>GET /public/products/:public_id</code></td><td>按 public_id 查,但<b>无鉴权、无 shop 隔离、无库存维度</b>,不能驱动出库</td></tr>
<tr><td>鉴权 <code>GET /inventory?product_id=</code></td><td>精确,但入参已经是 product_id——正是我们缺的那一步转换</td></tr>
</table>
</div>
<h2>4. 交互流程:扫一个,加一个</h2>
<div class="card">
<div class="flow"><span class="d">① 打开出库建单 → 选仓库 → 开「扫码模式」</span>
<span class="d">② 捕获框自动聚焦(失焦自动重夺)</span>
<span class="d">③ 扣扳机 ─────────────▶</span> URL + 回车 进框
<span class="d">④ 前端解析 public_id ─▶</span> GET /inventory/scan?public_id=..&amp;warehouse_id=..
<span class="d">⑤ 命中:</span>
该 product 已在明细 ─▶ <span class="c">数量 +1</span>(可内联改)
不在明细 ─▶ <span class="c">新增一行</span> qty=1,带 product_id / 售价 / 可用量
<span class="c">✓ 成功提示音 + toast</span>「茅台飞天 已加,当前 3 件」,焦点留在捕获框,接着扫
<span class="d">⑥ 未命中 / 售罄 / 跨店:</span>
<span class="k">✗ 失败提示音 + 红 toast</span>,不加行
<span class="d">⑦ 扫完 → 照常提交出库单</span>(提交仍走 CheckInventoryAvailability 库存校验)</div>
<p>整个过程<b>手不离枪</b>:焦点始终锁在捕获框,一次扫码 = 一次「解析 → 查 → 加行 → 提示」闭环,无需鼠标。</p>
</div>
<div class="decision">
<h3>⚑ 需要你拍板:序列号语义下,一次扫码加多少数量?</h3>
<p>数据铁律里 <b>product = 一个特有产品 / 序列号</b>,很多时候一个 product 对应一件 / 一批具体货。这决定「扫一次加几件」:</p>
<table>
<tr><th>口径</th><th>行为</th><th>适用</th></tr>
<tr><td><b>A · 每扫 +1</b><span class="tag ok" style="margin-left:6px;">建议默认</span></td><td>每扫一次数量 +1,可内联改;超可用量即时红字告警</td><td>一个 product = 多件同款,标签贴整批,按扫的次数点件数</td></tr>
<tr><td><b>B · 一码一件</b></td><td>扫一次即锁定该 product(固定 1,或整行可用量),重复扫视为重复→忽略/提示</td><td>严格一物一码一序列号,一个码就是一件</td></tr>
</table>
<p>建议先按 <b>A(每扫 +1,数量可改)</b> 落地,最贴合现在「批量勾选后改数量」的习惯。<b>请你确认口径</b>再进实现。</p>
</div>
<h2>5. 改动清单</h2>
<div class="card">
<h3>后端</h3>
<ul>
<li><b>新增</b> <code>GET /api/v1/inventory/scan</code>handler + service 查询,shop 隔离、排除 sold、返回 <code>_PickerItem</code> 同构体。(<code>inventory.go</code> / router</li>
<li><b>(可选)</b>修正二维码生成路径:<code>/app/product/</code> → 与 SSR 路由 <code>/product/</code> 统一(<code>product.go:290</code>),消除解析歧义。不改也行,前端做前缀容错即可。</li>
</ul>
<h3>前端 · <code>client/lib/screens/stock_out/stock_out_form_screen.dart</code></h3>
<ul>
<li>「扫码模式」开关 + 常聚焦捕获输入(<code>RawKeyboardListener</code> 或自动重夺焦点的隐藏 <code>TextField</code>)。</li>
<li>URL 解析工具(取 public_id / code,容错前缀)。</li>
<li>调 scan 接口 → 复用 <code>_PickerItem</code> 装配 → 加行 / 累加数量。</li>
<li>成功 / 失败<b>提示音 + toast</b>(扫码场景听觉反馈很重要,操作员眼睛在货上不在屏上)。</li>
<li><b>不需要</b>引入 <code>mobile_scanner</code> / <code>camera</code>——扫码枪走键盘,零摄像头依赖,<code>pubspec.yaml</code> 不动。</li>
</ul>
</div>
<h2>6. 边界与坑</h2>
<div class="card">
<table>
<tr><th></th><th>处理</th></tr>
<tr><td>1D 枪读不了 QR</td><td>需 2D 枪;或加印 Code128(code)(附录 B</td></tr>
<tr><td>中文输入法拦截扫码字符</td><td>捕获用 raw key 监听绕过 IME,或强制英文态</td></tr>
<tr><td>扫码枪没配回车后缀</td><td>多数出厂默认带;没有则扫设置码开「Add Enter/CR Suffix」</td></tr>
<tr><td>焦点丢失(用户点了别处)</td><td>失焦自动重夺 + 明显的「扫码模式」态提示</td></tr>
<tr><td>枪抖动同码连发</td><td>去重窗口:300ms 内同 public_id 忽略</td></tr>
<tr><td>跨店 / 售罄 / 未上架</td><td>后端硬拦(shop_id 隔离 + 排除 sold),前端红字</td></tr>
<tr><td>权限</td><td>出库受 <code>ReadOnly</code> 中间件保护;scan 接口只读,只读用户可查不可提交,天然安全</td></tr>
<tr><td>连扫速度</td><td>每扫打一次后端;若要极致速度可预载该仓全量 inventory 做本地 public_id→product_id 映射(附录 C,可选)</td></tr>
</table>
</div>
<h2>附录 A · 扫码枪 vs 手机摄像头</h2>
<div class="card">
<table>
<tr><th></th><th>扫码枪(本方案)</th><th>手机摄像头</th></tr>
<tr><td>技术路线</td><td>HID 键盘输入,无图像</td><td><code>mobile_scanner</code> + 摄像头 + 图像解码</td></tr>
<tr><td></td><td>桌面 PCWin/Mac</td><td>手机 App</td></tr>
<tr><td>前端依赖</td><td><b>零新增</b></td><td>新增扫码库 + 摄像头权限</td></tr>
<tr><td>连扫效率</td><td>高(扣扳机即出)</td><td>中(对焦、找码)</td></tr>
<tr><td>硬件成本</td><td>一把 2D 枪</td><td>已有手机</td></tr>
<tr><td>本方案</td><td><span class="tag ok">采用</span></td><td><span class="tag no">不含</span>(未来可作手机端补充)</td></tr>
</table>
</div>
<h2>附录 B · 若用 1D 枪 → 加印一维条码</h2>
<div class="card">
<p>若采购的是便宜的一维条码枪(读不了 QR),改在标签上加印一段 <b>Code128 一维条码,内容 = 商品 <code>code</code></b>(同店唯一)。扫到的就是纯 <code>code</code>,走 <code>GET /inventory/scan?code={code}</code> 精确匹配即可(同样带 shop 隔离)。QR 与一维条码可并存于同一标签(QR 供顾客溯源,条码供出库)。改动落在 <code>label_data.dart</code> / 打印工具。</p>
</div>
<h2>附录 C · 本地映射加速(可选优化)</h2>
<div class="card">
<p>选好仓库后一次性把该仓 inventory 全量拉到前端,建 <code>public_id → {product_id, 售价, 可用量}</code> 内存映射。扫码时<b>本地命中</b>、零网络往返,连扫如飞;提交前再由后端 <code>CheckInventoryAvailability</code> 兜底校验。适合货多、连扫密集的场景,第一版可不做。</p>
</div>
<h2>落地评级</h2>
<div class="card">
<p>本改动 = <b>新接口 + 跨后端/前端 2 模块</b>,属项目规则里的「大改」:按 <code>CLAUDE.md</code>,实现前须<b>先进 plan 模式经你批准</b>;且前端改动须遵守 <b>design-first</b>(先改设计系统原型再改真实页面)。<b>本文件仅方案评审</b>,你确认「数量口径(A/B)」+「是否修正二维码路径前缀」后,我再出实现 plan。</p>
</div>
</body>
</html>