22047b725c
出库单新建页桌面端加「扫码枪扫码」框:扫商品二维码 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
186 lines
15 KiB
HTML
186 lines
15 KiB
HTML
<!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>:扫码是爆发式输入(字符间隔通常 <30ms,几十毫秒打完整条 URL),人手打字间隔 >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>扫码枪插 PC(Windows / macOS)USB 口用,所以扫码出库主要落在<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>&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=..&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>桌面 PC(Win/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>
|