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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user