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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -86,6 +86,19 @@ List<_PickerItem> _aggregatePickerItems(List<Inventory> 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<StockOutFormScreen> {
|
||||
List<_PickerItem> _inventoryPickerItems = [];
|
||||
StockOutOrder? _loadedOrder;
|
||||
|
||||
// ── 扫码出库 ──────────────────────────────────────────────────────────────
|
||||
// 扫码枪=HID键盘,扫二维码URL→承接进 _scanCtrl→回车触发 _onScan。
|
||||
final _scanCtrl = TextEditingController();
|
||||
final _scanFocus = FocusNode();
|
||||
// 整仓库存按商品编码建索引(_loadInventory 时建),扫码→本地精确命中,零网络。
|
||||
Map<String, _PickerItem> _codeIndex = {};
|
||||
String _lastScanCode = '';
|
||||
int _lastScanMs = 0;
|
||||
|
||||
static const _fields = ['qty', 'sale'];
|
||||
|
||||
final List<_ItemRow> _items = [];
|
||||
@@ -179,6 +201,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
// 新单起始为空,用户通过「从库存选择」添加明细
|
||||
_initWarehouseDefault();
|
||||
}
|
||||
// 桌面端:进页即聚焦扫码框,走近即可连扫(窄屏无扫码枪不聚焦)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !context.isMobile) _scanFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
/// 新建单默认填充默认仓库(2026-07-14):仓库列表加载完成后,
|
||||
@@ -239,6 +265,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
_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<StockOutFormScreen> {
|
||||
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<StockOutFormScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 扫码出库:扫一个,明细自动 +1 ─────────────────────────────────────────
|
||||
/// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端
|
||||
/// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。
|
||||
Future<void> _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<StockOutFormScreen> {
|
||||
: 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(),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 130 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;} }
|
||||
</style>
|
||||
|
||||
Vendored
+31
-2
@@ -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+=`<div class="btn ghost sm" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="btn ghost sm" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; 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+=`<div class="scanbox" title="扫码枪扫商品二维码,明细自动+1"><svg viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/></svg><input id="scanInput" placeholder="扫码枪扫码…" autocomplete="off" onkeydown="onScanKey(event)"></div>`;
|
||||
if(mode==='out') h+=`<div class="${bcls}" onclick="openStockPicker()"><svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></svg>从库存批量选择</div>`; h+=`<div class="${bcls}" onclick="copyLast()"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg>复制上一行</div>`; 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();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!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>
|
||||
@@ -43,6 +43,8 @@
|
||||
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
|
||||
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
|
||||
<li><a href="design/stock-in-cost-confirm-design.html">入库确认进价(暂估价前向补偿)设计</a><span class="tag html">HTML</span></li>
|
||||
<li><a href="design/scan-stock-out-design.html">扫码出库(扫码枪扫二维码建出库单)设计</a><span class="tag html">HTML</span> <span class="hint">— 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决</span></li>
|
||||
<li><a href="design/scan-stock-out-plan.html">扫码出库 · 实现计划(出库表单扫码+1)</a><span class="tag html">HTML</span> <span class="hint">— 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型</span></li>
|
||||
<li><a href="design/inventory-filter-spec.md">库存筛选规格</a><span class="tag">MD</span></li>
|
||||
</ul>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user