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
143 lines
12 KiB
HTML
143 lines
12 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: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<String code, _PickerItem></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]
|
||
├─ 命中 && 已在明细 ──▶ 该行 <span class="c">数量 +1</span>(超可用量则黄字提醒,仍加)
|
||
├─ 命中 && 不在明细 ──▶ <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>>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>,绝大多数门店够用。但两种情况本地会漏:①单仓商品 >1000(超分页);②某商品无 code(QR 只有 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),覆盖 >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>
|