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:
wangjia
2026-08-25 19:11:15 +08:00
parent b5715aec8d
commit 22047b725c
11 changed files with 556 additions and 5 deletions
@@ -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(),