Files
jiu/docs/design/scan-stock-out-plan.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

143 lines
12 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:960px;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:960px;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:960px;}
.flow{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:960px;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:960px;margin-bottom:16px;}
.decision h3{color:var(--warn);margin-top:0;}
.chk{list-style:none;padding-left:4px;}
.chk li{margin:6px 0;} .chk li::before{content:"☐ ";color:var(--primary);font-weight:700;}
</style>
</head>
<body>
<h1>扫码出库 · 实现计划</h1>
<div class="sub">目标页:<b>新建/修改出库单</b><code>stock_out_form_screen.dart</code>)· 扫码枪扫商品 → 明细自动 +1 · 2026-08-25 · 待批准</div>
<div class="lead">
在「新建出库单」页,操作员用<b>扫码枪</b>扫商品标签上已印的二维码,商品明细里<b>自动新增该商品并数量 +1</b>;同一商品重复扫则数量累加。
核心省力点:二维码 URL 里带 <code>?code={商品编码}</code>,而本页 <code>_loadInventory</code> 已把整仓库存(≤1000 个,<code>stockOutPickerPageSize=1000</code>
聚合进本地 <code>_inventoryPickerItems</code>(每条含 <code>productCode</code>)——<b>扫码→解析 code→本地命中→加行,零网络、零后端改动</b>
</div>
<h2>1. 原理(一句话回顾)</h2>
<div class="card">
<p>扫码枪 = HID 键盘:扫到内容当作键盘输入敲进<b>当前聚焦的输入框</b>、末尾补回车。本页放一个聚焦的「扫码框」承接即可,<b>零摄像头依赖</b>
扫到的是 URL <code>https://…/app/product/{public_id}?code={code}</code>;我们取其中的 <code>code</code> 与整仓已加载的库存做精确匹配。</p>
</div>
<h2>2. 数据流</h2>
<div class="card">
<div class="flow"><span class="d">① 选出库仓库(本页已有)→ _loadInventory 建两张本地表:</span>
_inventoryPickerItems(现有) + <span class="c">_codeIndex: Map&lt;String code, _PickerItem&gt;</span><b>新增</b>
<span class="d">② 扫码框聚焦(失焦自动重夺)</span>
<span class="d">③ 扣扳机 ──▶</span> URL + 回车 落入扫码框,onSubmitted 触发 _onScan(raw)
<span class="d">④ _onScan</span>
解析 code = Uri.parse(raw).queryParameters['code'] <span class="d">(拿不到则整串 trim 当 code 兜底)</span>
item = _codeIndex[code]
├─ 命中 &amp;&amp; 已在明细 ──▶ 该行 <span class="c">数量 +1</span>(超可用量则黄字提醒,仍加)
├─ 命中 &amp;&amp; 不在明细 ──▶ <span class="c">新增一行,数量=1</span>,带 productId/售价/可用量
└─ 未命中 ───────────▶ <span class="k">红 toast「未找到该商品/不在本仓」</span>,不加行
<span class="d">⑤ 清空扫码框 + 重新聚焦 + 轻提示音,等下一扫</span>
<span class="d">⑥ 扫完照常「提交审核」(提交时后端 CheckInventoryAvailability 兜底校验)</span></div>
<p><b>数量口径 = A(每扫 +1,已定)</b>。注意:与「从库存选择」默认带出整箱可用量不同,扫码是<b>按扫的次数点件数</b>,故首扫为 1、再扫累加。数量列仍可手动改。</p>
</div>
<h2>3. 前端改动(唯一必改文件:<code>stock_out_form_screen.dart</code></h2>
<div class="card">
<table>
<tr><th>改动点</th><th>具体</th></tr>
<tr><td><b>本地 code 索引</b></td><td><code>_loadInventory</code> 里,聚合完 <code>_inventoryPickerItems</code> 后顺手建 <code>_codeIndex = { for it in items : it.productCode : it }</code>productCode 非空才入)。</td></tr>
<tr><td><b>扫码框</b></td><td>明细头 <code>DetailHead(actions:[…])</code> 里,在「从库存选择」左侧加一个紧凑 <code>DsInput</code>(图标 <code>LucideIcons.scanLine</code>,占位「扫码枪扫码…」)+ 独立 <code>FocusNode _scanFocus</code>,桌面端 <code>autofocus</code>。窄屏(<code>context.isMobile</code>)不显示(扫码枪是桌面外设)。</td></tr>
<tr><td><b>_onScan(raw)</b></td><td>新增方法:无仓库→toast「请先选择出库仓库」;解析 code;查 <code>_codeIndex</code>;命中则 <code>_addOrBumpByScan(item)</code>;未命中红 toast。末尾 <code>_scanCtrl.clear()</code> + <code>_scanFocus.requestFocus()</code></td></tr>
<tr><td><b>_addOrBumpByScan(item)</b></td><td><code>_items</code><code>productId==item.productId</code>:有→<code>qtyCtrl.text = (now+1)</code>;无→复用 <code>_addItem</code> 的建行逻辑但 <b>qty 固定 1</b><code>setState</code> 刷新。超可用量(<code>&gt;availableQty</code>)→黄字 toast 提醒但不拦。</td></tr>
<tr><td><b>去重防抖</b></td><td><code>_lastScan(code,ts)</code>,同 code 300ms 内重复直接忽略(防扳机抖动连发)。</td></tr>
<tr><td><b>提示音</b></td><td>成功 <code>SystemSound.play(SystemSoundType.click)</code>;失败可 <code>HapticFeedback</code>/仅红 toast。轻量,主反馈仍是 toast(操作员眼在货上)。</td></tr>
<tr><td><b>清理</b></td><td><code>dispose()</code> 里释放 <code>_scanCtrl</code>/<code>_scanFocus</code></td></tr>
</table>
<p>解析函数抽成纯函数 <code>String? parseScanCode(String raw)</code>(放本文件或 util),便于单测:覆盖 <code>/app/product/x?code=1</code><code>/product/x?code=1</code>、纯 code、脏串。</p>
<p><b>不新增依赖</b>:不引 <code>mobile_scanner</code>/<code>camera</code><code>pubspec.yaml</code> 不动。</p>
</div>
<h2>4. 原型改动(design-first,先改再改代码,<b>同一提交</b></h2>
<div class="card">
<p>按项目铁律「原型与真实 100% 一致,前端改动必须先改原型」:在 <code>design/prototype/screens/stock-out.html</code> 的明细头,与「从库存选择」并排加一个扫码输入框原件(用登记过的 atoms,占位「扫码枪扫码…」)。评审原型 → 代码随后落地 → 原型与代码同提交。若涉及新图标 <code>scan-line</code> 未登记,先在 <code>index.html</code> + <code>icons.js</code> 登记再用(L1 单源)。</p>
</div>
<h2>5. 二维码路径统一(顺带,小改)</h2>
<div class="card">
<p>后端 <code>product.go:290</code> 生成 URL 用的是 <code>/app/product/</code>,与 SSR 路由 <code>/product/</code> 不一致。改成 <code>/product/</code> 统一(已定)。<b>对本方案非必需</b>(我们只取 <code>?code=</code>,不依赖路径段),但顺手消歧、利于将来按 public_id 解析。属 server 侧一行改动,随下次 server 发版生效。</p>
</div>
<div class="decision">
<h3>⚑ 唯一待你拍板:要不要现在就做后端精确查接口?</h3>
<p>本地 code 索引覆盖<b>整仓 ≤1000 个商品</b>,绝大多数门店够用。但两种情况本地会漏:①单仓商品 &gt;1000(超分页);②某商品无 codeQR 只有 public_id,则无 <code>?code=</code>)。</p>
<table>
<tr><th>方案</th><th>范围</th><th>建议</th></tr>
<tr><td><b>A · 仅前端(本地匹配)</b></td><td>零后端改动、零部署,扫→加立即生效;漏网情况给红 toast 提示改用「从库存选择」</td><td><span class="tag ok">推荐先做,最快落地</span></td></tr>
<tr><td><b>B · 加后端 <code>GET /inventory/scan</code></b></td><td>本地未命中时回退查后端(精确 by code/public_id、shop 隔离、排除 sold),覆盖 &gt;1000 与 public_id-only;需 server 发版</td><td>作 Phase-2,等确有大仓再加</td></tr>
</table>
<p>我的建议:<b>先做 A(纯前端)</b> 把这页跑通,B 留作后续。你也可以要求一步到位做 A+B。</p>
</div>
<h2>6. 验证(无扫码枪也能全验,你已有 macOS app)</h2>
<div class="card">
<ol>
<li><b>端到端</b>:手动往扫码框<b>粘贴一条真实商品 URL</b>(形如 <code>…/app/product/xxx?code=P1234</code>)再按回车——效果与扫码枪 100% 等价。看是否新增行、再粘同一条是否 +1、粘不存在的 code 是否红 toast。</li>
<li><b>解析单测</b><code>flutter test</code> 覆盖 <code>parseScanCode</code> 各输入。</li>
<li><b>DoD</b><code>flutter analyze --no-fatal-infos --no-fatal-warnings</code> 无 error<code>flutter test</code> 全过。</li>
<li>上线后拿 2D 扫码枪扫一张真标签确认「带回车后缀」即可(1D 枪读不了 QR,见原理文档附录 B)。</li>
</ol>
</div>
<h2>7. 改动文件清单 & 执行顺序</h2>
<div class="card">
<ul class="chk">
<li>原型:<code>design/prototype/screens/stock-out.html</code> 加扫码框(+ 必要时 <code>index.html</code>/<code>icons.js</code> 登记 scan-line 图标)</li>
<li>前端:<code>client/lib/screens/stock_out/stock_out_form_screen.dart</code> —— <code>_codeIndex</code> / 扫码框 / <code>_onScan</code> / <code>_addOrBumpByScan</code> / 防抖 / dispose;抽 <code>parseScanCode</code></li>
<li>测试:<code>client/test/…/parse_scan_code_test.dart</code> 新增</li>
<li>(方案 B 选做)后端:<code>inventory.go</code> + router 加 <code>GET /inventory/scan</code></li>
<li>(顺带)后端:<code>product.go:290</code> 路径 <code>/app/product/</code><code>/product/</code></li>
<li>本地验证(analyze + test + macOS app 手动粘 URL)→ 你验收 → 发版 <code>/release client</code></li>
</ul>
<p><b>落地评级</b>:方案 A 为<b>前端单文件 + 原型 + 测试</b>,属中等改动;方案 B 追加后端接口即升为跨模块大改。均遵守 design-first。<b>本文件仅计划评审,你批准后再实现。</b></p>
</div>
<h2>8. 待确认</h2>
<div class="card">
<ol>
<li>先做<b>方案 A(纯前端)</b>,还是一步到位 <b>A+B(含后端精确查)</b>?(建议 A</li>
<li>二维码路径 <code>/app/product/</code><code>/product/</code> 这次<b>一并改</b>吗?(已定要改,确认随本次一起)</li>
</ol>
</div>
</body>
</html>