From 396a19ff434717a0a398a4bd3a43aa11c28f0fe1 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Wed, 13 May 2026 15:14:14 +0800 Subject: [PATCH] feat(boxing): update many-to-one packing flow --- lib/pages/boxing_page.dart | 769 ++++++++++++++++++++++++---- lib/services/api_service.dart | 14 +- test/services/api_service_test.dart | 146 ++++++ 3 files changed, 817 insertions(+), 112 deletions(-) diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index 057b761..ebf69f7 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -24,6 +24,42 @@ enum _Phase { submitted, // 已提交成功 } +class _ManyToOnePackedItem { + final int boxItemId; + final String zongpaiNo; + final String? paichanNo; + final String? workOrderNo; + final int boxNo; + final int quantity; + final int? totalQuantity; + + const _ManyToOnePackedItem({ + required this.boxItemId, + required this.zongpaiNo, + this.paichanNo, + required this.workOrderNo, + required this.boxNo, + required this.quantity, + this.totalQuantity, + }); + + _ManyToOnePackedItem copyWith({ + int? boxNo, + int? quantity, + int? totalQuantity, + }) { + return _ManyToOnePackedItem( + boxItemId: boxItemId, + zongpaiNo: zongpaiNo, + paichanNo: paichanNo, + workOrderNo: workOrderNo, + boxNo: boxNo ?? this.boxNo, + quantity: quantity ?? this.quantity, + totalQuantity: totalQuantity ?? this.totalQuantity, + ); + } +} + // === 主页面 === class BoxingPage extends StatefulWidget { @@ -61,11 +97,11 @@ class _BoxingPageState extends State { final _boxNoFocusNode = FocusNode(); final _quantityFocusNode = FocusNode(); - // 多码一箱:已扫描过的总排号列表(用于显示) - final _scannedZongpais = []; - - // 箱号锁定(多码一箱模式) - bool _boxNoLocked = false; + // 多码一箱:本次操作已确认装入当前箱的明细 + final _manyToOnePackedItems = <_ManyToOnePackedItem>[]; + int? _editingPackedItemId; + String _editingPackedQuantity = ''; + int? _deletingPackedItemId; // 提交中 bool _isSubmitting = false; @@ -86,11 +122,13 @@ class _BoxingPageState extends State { @override void initState() { super.initState(); + _boxNoController.addListener(_onBoxNoTextChanged); _scannerService.scanResults.listen(_onScan); } @override void dispose() { + _boxNoController.removeListener(_onBoxNoTextChanged); _boxNoController.dispose(); _quantityController.dispose(); _boxNoFocusNode.dispose(); @@ -99,6 +137,15 @@ class _BoxingPageState extends State { super.dispose(); } + void _onBoxNoTextChanged() { + if (!mounted || _mode != BoxingMode.many2one) return; + setState(() { + _editingPackedItemId = null; + _editingPackedQuantity = ''; + _deletingPackedItemId = null; + }); + } + // === 模式切换 === String get _modeLabel { @@ -138,8 +185,10 @@ class _BoxingPageState extends State { _maxBoxNo = 0; _boxNoController.clear(); _quantityController.clear(); - _scannedZongpais.clear(); - _boxNoLocked = false; + _manyToOnePackedItems.clear(); + _editingPackedItemId = null; + _editingPackedQuantity = ''; + _deletingPackedItemId = null; _isSubmitting = false; _lastBoxNo = null; _editingBox = null; @@ -165,26 +214,7 @@ class _BoxingPageState extends State { final zongpai = parsed.value; - // 多码一箱已提交后:等待下一个扫码 - if (_mode == BoxingMode.many2one && _phase == _Phase.submitted) { - _handleNextScanMany2One(zongpai); - return; - } - - // 一码一箱和一码多箱:允许覆盖,直接查询新二维码(不提前清空状态,避免闪烁) - if (_mode == BoxingMode.many2one && _phase != _Phase.waiting) { - return; - } - - _queryBoxInfo(zongpai); - } - - void _handleNextScanMany2One(String zongpai) { - setState(() { - _phase = _Phase.waiting; - _zongpaiNo = null; - _statusOverrideText = null; - }); + // 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。 _queryBoxInfo(zongpai); } @@ -236,11 +266,6 @@ class _BoxingPageState extends State { _editingBox = null; _statusOverrideText = null; - // 多码一箱:记录已扫描列表 - if (_mode == BoxingMode.many2one && !_scannedZongpais.contains(zongpai)) { - _scannedZongpais.add(zongpai); - } - // 根据模式自动填充 _applyAutoFill(); }); @@ -260,11 +285,19 @@ class _BoxingPageState extends State { _quantityController.clear(); } case BoxingMode.many2one: - if (!_boxNoLocked) { + if (_boxNoController.text.trim().isEmpty) { _boxNoController.text = (_maxBoxNo + 1).toString(); - _boxNoLocked = true; } _quantityController.text = (_erpQuantity ?? 0).toString(); + _quantityController.selection = TextSelection( + baseOffset: 0, + extentOffset: _quantityController.text.length, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _mode == BoxingMode.many2one) { + _quantityFocusNode.requestFocus(); + } + }); } _checkDuplicateBoxNo(); } @@ -300,6 +333,39 @@ class _BoxingPageState extends State { return true; } + int? get _currentManyToOneBoxNo { + final boxNo = int.tryParse(_boxNoController.text.trim()); + if (boxNo == null || boxNo <= 0) return null; + return boxNo; + } + + List<_ManyToOnePackedItem> get _visibleManyToOnePackedItems { + final boxNo = _currentManyToOneBoxNo; + if (boxNo == null) return const []; + final byItemId = {}; + for (final box in _existingBoxes.where((box) => box.boxNo == boxNo)) { + for (final item in box.items) { + final boxItemId = item.boxItemId; + if (boxItemId == null) continue; + byItemId[boxItemId] = _ManyToOnePackedItem( + boxItemId: boxItemId, + zongpaiNo: item.zongpaiNo, + paichanNo: _paichanNo, + workOrderNo: item.workOrderNo, + boxNo: boxNo, + quantity: item.quantity, + totalQuantity: item.totalQuantity, + ); + } + } + for (final item in _manyToOnePackedItems.where( + (item) => item.boxNo == boxNo, + )) { + byItemId[item.boxItemId] = item; + } + return byItemId.values.toList(growable: false); + } + Future _submit() async { if (!_canSubmit) return; @@ -339,6 +405,7 @@ class _BoxingPageState extends State { boxItemId: editing.boxItemId, boxNo: boxNo, quantity: quantity, + boxMode: modeStr, ); if (!mounted) return; @@ -353,7 +420,7 @@ class _BoxingPageState extends State { } } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); - if (_mode == BoxingMode.one2one) { + if (result.errorCode == 'DUPLICATE_ZONGPAI_BIND') { _showStatusOverride( '该总排号已绑定箱号 ${result.boxNo ?? "?"},请勿重复装箱', StatusDotColor.red, @@ -424,11 +491,27 @@ class _BoxingPageState extends State { case BoxingMode.many2one: setState(() { - _phase = _Phase.submitted; + if (boxItemId != null) { + _manyToOnePackedItems.add( + _ManyToOnePackedItem( + boxItemId: boxItemId, + zongpaiNo: _zongpaiNo!, + paichanNo: _paichanNo, + workOrderNo: _workOrderNo, + boxNo: boxNo, + quantity: quantity, + totalQuantity: _erpQuantity, + ), + ); + } + _phase = _Phase.waiting; _zongpaiNo = null; + _editingPackedItemId = null; + _editingPackedQuantity = ''; + _deletingPackedItemId = null; }); _showStatusOverride( - '装箱成功,请扫描下一个总排号', + '装箱成功,请继续扫码', StatusDotColor.green, const Duration(milliseconds: 1500), ); @@ -486,6 +569,7 @@ class _BoxingPageState extends State { zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, + totalQuantity: _erpQuantity, ); if (targetIndex >= 0) { final target = nextBoxes[targetIndex]; @@ -510,6 +594,7 @@ class _BoxingPageState extends State { zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, + totalQuantity: _erpQuantity, ); final index = _existingBoxes.indexWhere((box) => box.boxNo == boxNo); if (index < 0) { @@ -532,6 +617,23 @@ class _BoxingPageState extends State { setState(() => _resetState()); } + void _finishManyToOneBox() { + final currentBoxNo = int.tryParse(_boxNoController.text.trim()); + setState(() { + _editingPackedItemId = null; + _editingPackedQuantity = ''; + _deletingPackedItemId = null; + _zongpaiNo = null; + _phase = _Phase.waiting; + _quantityController.clear(); + if (currentBoxNo != null && currentBoxNo > 0) { + _boxNoController.text = (currentBoxNo + 1).toString(); + } + _statusOverrideText = null; + _statusOverrideDot = null; + }); + } + void _startEditAssigned(CurrentZongpaiBoxData item) { setState(() { _editingBox = item; @@ -553,7 +655,128 @@ class _BoxingPageState extends State { }); } - Future _deleteAssigned(CurrentZongpaiBoxData item) async { + void _startEditPackedItem(_ManyToOnePackedItem item) { + setState(() { + _editingPackedItemId = item.boxItemId; + _editingPackedQuantity = item.quantity.toString(); + _deletingPackedItemId = null; + }); + } + + void _cancelEditPackedItem() { + setState(() { + _editingPackedItemId = null; + _editingPackedQuantity = ''; + }); + } + + Future _savePackedItem(_ManyToOnePackedItem item) async { + final quantity = int.tryParse(_editingPackedQuantity); + final boxNo = int.tryParse(_boxNoController.text.trim()); + if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) { + _showStatusOverride( + '请输入有效箱号和数量', + StatusDotColor.red, + const Duration(seconds: 2), + ); + return; + } + + final configService = AppConfigService(); + final baseUrl = await configService.getString('api_url') ?? ''; + if (baseUrl.isEmpty) { + _showStatusOverride( + '未配置 API 地址,请前往设置', + StatusDotColor.red, + const Duration(seconds: 2), + ); + return; + } + + setState(() => _isSubmitting = true); + final result = await _apiService.updateBoxRecord( + baseUrl: baseUrl, + boxItemId: item.boxItemId, + boxNo: boxNo, + quantity: quantity, + boxMode: 'many-to-one', + ); + if (!mounted) return; + + setState(() => _isSubmitting = false); + if (result.success) { + setState(() { + final index = _manyToOnePackedItems.indexWhere( + (packed) => packed.boxItemId == item.boxItemId, + ); + if (index >= 0) { + _manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith( + boxNo: boxNo, + quantity: quantity, + ); + } + _replaceExistingPackedItem(item, boxNo, quantity); + _editingPackedItemId = null; + _editingPackedQuantity = ''; + }); + _showStatusOverride( + '修改成功', + StatusDotColor.green, + const Duration(milliseconds: 1500), + ); + } else { + final dot = result.isDuplicate + ? StatusDotColor.amber + : StatusDotColor.red; + _showStatusOverride( + result.errorMessage ?? '修改失败', + dot, + const Duration(seconds: 2), + ); + } + } + + void _replaceExistingPackedItem( + _ManyToOnePackedItem item, + int boxNo, + int quantity, + ) { + final nextBoxes = []; + for (final box in _existingBoxes) { + final items = box.items.where((boxItem) { + return boxItem.boxItemId != item.boxItemId; + }).toList(); + if (items.isNotEmpty) { + nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); + } + } + + final updatedItem = BoxItemData( + boxItemId: item.boxItemId, + zongpaiNo: item.zongpaiNo, + workOrderNo: item.workOrderNo, + quantity: quantity, + totalQuantity: item.totalQuantity, + ); + final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); + if (targetIndex >= 0) { + final target = nextBoxes[targetIndex]; + nextBoxes[targetIndex] = BoxDetailData( + boxNo: target.boxNo, + items: [...target.items, updatedItem], + ); + } else { + nextBoxes.add(BoxDetailData(boxNo: boxNo, items: [updatedItem])); + } + nextBoxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); + _existingBoxes = nextBoxes; + _maxBoxNo = nextBoxes.fold( + 0, + (max, box) => box.boxNo > max ? box.boxNo : max, + ); + } + + Future _deletePackedItem(_ManyToOnePackedItem item) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { @@ -575,17 +798,15 @@ class _BoxingPageState extends State { setState(() { _isSubmitting = false; if (result.success) { - _currentZongpaiBoxes = _currentZongpaiBoxes - .where((box) => box.boxItemId != item.boxItemId) - .toList(); + _manyToOnePackedItems.removeWhere( + (packed) => packed.boxItemId == item.boxItemId, + ); _removeExistingBoxItem(item.boxItemId); - if (_editingBox?.boxItemId == item.boxItemId) { - _editingBox = null; + if (_editingPackedItemId == item.boxItemId) { + _editingPackedItemId = null; + _editingPackedQuantity = ''; } - _lastBoxNo = _maxBoxNo; - _boxNoController.text = (_maxBoxNo + 1).toString(); - _quantityController.clear(); - _isDuplicateBoxNo = false; + _deletingPackedItemId = null; } }); @@ -668,14 +889,17 @@ class _BoxingPageState extends State { switch (_phase) { case _Phase.waiting: _statusDot = StatusDotColor.blue; - if (_mode == BoxingMode.many2one && _boxNoLocked) { - _statusText = '请扫描下一个总排号'; + if (_mode == BoxingMode.many2one && + _visibleManyToOnePackedItems.isNotEmpty) { + _statusText = '请继续扫码或完成本箱'; } else { _statusText = '等待扫码'; } case _Phase.scanned: _statusDot = StatusDotColor.blue; - _statusText = '请确认信息并提交'; + _statusText = _mode == BoxingMode.many2one + ? '数量已填入,请确认或修改' + : '请确认信息并提交'; case _Phase.submitted: _statusDot = StatusDotColor.green; switch (_mode) { @@ -696,8 +920,7 @@ class _BoxingPageState extends State { final colorScheme = Theme.of(context).colorScheme; final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; final showActionButtons = - (_phase == _Phase.submitted && _mode == BoxingMode.many2one) || - (_phase == _Phase.scanned && _mode == BoxingMode.one2many); + _phase == _Phase.scanned && _mode == BoxingMode.one2many; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; @@ -736,35 +959,28 @@ class _BoxingPageState extends State { ), ), - // 主内容区 Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // === 扫码区 === - _buildScanArea(isWaiting), - - const SizedBox(height: 12), - - // === 信息区 === - _buildInfoArea(isWaiting, colorScheme), - - const Divider(height: 24), - - if (_mode == BoxingMode.one2many && - !isWaiting && - _currentZongpaiBoxes.isNotEmpty) ...[ - _buildAssignedBoxes(), - const Divider(height: 24), - ], - - // === 输入区 === - _buildInputArea(isWaiting), - ], - ), - ), + child: _mode == BoxingMode.many2one + ? _buildManyToOneBody(colorScheme) + : SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildScanArea(isWaiting), + const SizedBox(height: 12), + _buildInfoArea(isWaiting, colorScheme), + const Divider(height: 24), + if (_mode == BoxingMode.one2many && + !isWaiting && + _currentZongpaiBoxes.isNotEmpty) ...[ + _buildAssignedBoxes(), + const Divider(height: 24), + ], + _buildInputArea(isWaiting), + ], + ), + ), ), // 操作按钮(一码多箱 / 多码一箱) @@ -802,6 +1018,20 @@ class _BoxingPageState extends State { ), ), + if (_mode == BoxingMode.many2one) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: OutlinedButton( + onPressed: _visibleManyToOnePackedItems.isEmpty + ? null + : _finishManyToOneBox, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(40), + ), + child: const Text('完成本箱'), + ), + ), + // 底部状态栏 StatusBar(dotColor: effectiveDot, text: effectiveText), ], @@ -849,14 +1079,6 @@ class _BoxingPageState extends State { icon: const Icon(Icons.edit, size: 18), onPressed: () => _startEditAssigned(item), ), - IconButton( - tooltip: '删除', - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.delete_outline, size: 18), - onPressed: _isSubmitting - ? null - : () => _deleteAssigned(item), - ), ], ), ), @@ -866,6 +1088,353 @@ class _BoxingPageState extends State { ); } + Widget _buildManyToOneBody(ColorScheme colorScheme) { + final hasScan = _zongpaiNo != null && _phase == _Phase.scanned; + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildManyToOneBoxNoPanel(), + if (_paichanNo != null) ...[ + const SizedBox(height: 8), + _buildManyToOneInfoRow(colorScheme), + ], + const SizedBox(height: 12), + _buildManyToOnePackedList(), + const Divider(height: 24), + _buildManyToOneScanArea(hasScan), + const SizedBox(height: 10), + _buildManyToOneSubmitArea(hasScan), + ], + ), + ); + } + + Widget _buildManyToOneBoxNoPanel() { + return Column( + children: [ + Row( + children: [ + const Text( + '当前箱号', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(width: 12), + SizedBox( + width: 96, + height: 44, + child: TextField( + controller: _boxNoController, + focusNode: _boxNoFocusNode, + enabled: !_isSubmitting, + keyboardType: TextInputType.none, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade500), + ), + ), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(height: 1, color: Colors.grey.shade300), + ], + ); + } + + Widget _buildManyToOneInfoRow(ColorScheme colorScheme) { + return Row( + children: [ + Expanded( + child: Text( + '${_paichanNo ?? "--"} 已有${_existingBoxes.length}箱 最大箱号$_maxBoxNo', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _existingBoxes.isEmpty ? null : _openDetail, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '详情 →', + style: TextStyle( + fontSize: 13, + color: _existingBoxes.isEmpty + ? Colors.grey.shade400 + : colorScheme.primary, + ), + ), + ), + ], + ); + } + + Widget _buildManyToOnePackedList() { + final visibleItems = _visibleManyToOnePackedItems; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '已装入本箱(${visibleItems.length}项)', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + if (visibleItems.isEmpty) + Text( + '(空)', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ) + else + ...visibleItems.map(_buildManyToOnePackedRow), + ], + ); + } + + Widget _buildManyToOnePackedRow(_ManyToOnePackedItem item) { + final editing = _editingPackedItemId == item.boxItemId; + final deleting = _deletingPackedItemId == item.boxItemId; + final totalText = item.totalQuantity?.toString() ?? '--'; + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: editing ? Colors.orange.shade50 : Colors.grey.shade50, + border: Border.all( + color: editing ? Colors.orange : Colors.grey.shade300, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + '${item.zongpaiNo} ${item.workOrderNo ?? "--"}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (editing) + SizedBox( + width: 56, + height: 32, + child: TextField( + keyboardType: TextInputType.none, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + controller: + TextEditingController(text: _editingPackedQuantity) + ..selection = TextSelection.collapsed( + offset: _editingPackedQuantity.length, + ), + onChanged: (value) => _editingPackedQuantity = value, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 6), + border: OutlineInputBorder(), + ), + ), + ) + else + Text( + '${item.quantity}/$totalText', + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 4), + if (editing) ...[ + IconButton( + tooltip: '保存', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.check, size: 18), + onPressed: _isSubmitting ? null : () => _savePackedItem(item), + ), + IconButton( + tooltip: '取消', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18), + onPressed: _isSubmitting ? null : _cancelEditPackedItem, + ), + ] else ...[ + IconButton( + tooltip: '修改', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.edit, size: 18), + onPressed: _isSubmitting + ? null + : () => _startEditPackedItem(item), + ), + IconButton( + tooltip: '删除', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: _isSubmitting + ? null + : () { + setState(() { + _deletingPackedItemId = item.boxItemId; + _editingPackedItemId = null; + _editingPackedQuantity = ''; + }); + }, + ), + ], + ], + ), + if (deleting) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + const Expanded( + child: Text('确认删除此条记录?', style: TextStyle(fontSize: 13)), + ), + TextButton( + onPressed: _isSubmitting + ? null + : () => _deletePackedItem(item), + child: const Text('是'), + ), + TextButton( + onPressed: _isSubmitting + ? null + : () => setState(() => _deletingPackedItemId = null), + child: const Text('否'), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildManyToOneScanArea(bool hasScan) { + if (!hasScan) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.qr_code_scanner, color: Colors.grey, size: 20), + SizedBox(width: 8), + Text( + '请扫描执行卡二维码', + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.green, width: 1.5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text( + '${_zongpaiNo ?? ""} ${_workOrderNo ?? "--"} ${_erpQuantity ?? "--"}件', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildManyToOneSubmitArea(bool hasScan) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '数量', + style: TextStyle( + fontSize: 13, + color: hasScan ? Colors.black54 : Colors.grey, + ), + ), + const SizedBox(height: 4), + SizedBox( + height: 40, + child: TextField( + controller: _quantityController, + focusNode: _quantityFocusNode, + enabled: hasScan && !_isSubmitting, + keyboardType: TextInputType.none, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 10), + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 40, + child: ElevatedButton( + onPressed: _canSubmit ? _submit : null, + style: ElevatedButton.styleFrom( + backgroundColor: _canSubmit + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade300, + foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, + ), + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('确认', style: TextStyle(fontSize: 15)), + ), + ), + ], + ); + } + // === 扫码区 Widget === Widget _buildScanArea(bool isWaiting) { @@ -891,25 +1460,6 @@ class _BoxingPageState extends State { ); } - if (_mode == BoxingMode.many2one && _scannedZongpais.isNotEmpty) { - return Wrap( - spacing: 6, - runSpacing: 4, - children: _scannedZongpais.map((zp) { - final isCurrent = zp == _zongpaiNo; - return Chip( - avatar: Icon( - Icons.check_circle, - size: 16, - color: isCurrent ? Colors.green : Colors.grey, - ), - label: Text(zp, style: const TextStyle(fontSize: 13)), - visualDensity: VisualDensity.compact, - ); - }).toList(), - ); - } - return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), @@ -1086,7 +1636,7 @@ class _BoxingPageState extends State { child: TextField( controller: _boxNoController, focusNode: _boxNoFocusNode, - enabled: enabled && !_boxNoLocked, + enabled: enabled, keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => _checkDuplicateBoxNo(), @@ -1100,9 +1650,6 @@ class _BoxingPageState extends State { : Colors.grey.shade400, ), ), - suffixIcon: _boxNoLocked - ? const Icon(Icons.lock, size: 18, color: Colors.orange) - : null, ), style: const TextStyle(fontSize: 16), ), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index fdfd5e6..0d784ec 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -54,12 +54,14 @@ class BoxItemData { final String zongpaiNo; final String? workOrderNo; final int quantity; + final int? totalQuantity; BoxItemData({ this.boxItemId, required this.zongpaiNo, this.workOrderNo, required this.quantity, + this.totalQuantity, }); factory BoxItemData.fromJson(Map json) { @@ -68,6 +70,7 @@ class BoxItemData { zongpaiNo: json['zongpai_no'] as String, workOrderNo: json['work_order_no']?.toString(), quantity: json['quantity'] as int, + totalQuantity: json['total_quantity'] as int?, ); } } @@ -172,8 +175,10 @@ class BoxSaveResult { final bool success; final bool isDuplicate; final String? errorMessage; + final String? errorCode; final int? boxItemId; final String? paichanNo; + final String? zongpaiNo; final int? boxNo; final int? quantity; @@ -181,8 +186,10 @@ class BoxSaveResult { required this.success, this.isDuplicate = false, this.errorMessage, + this.errorCode, this.boxItemId, this.paichanNo, + this.zongpaiNo, this.boxNo, this.quantity, }); @@ -192,6 +199,7 @@ class BoxSaveResult { success: true, boxItemId: json['box_item_id'] as int?, paichanNo: json['paichan_no'] as String?, + zongpaiNo: json['zongpai_no'] as String?, boxNo: json['box_no'] as int?, quantity: json['quantity'] as int?, ); @@ -201,7 +209,10 @@ class BoxSaveResult { return BoxSaveResult( success: false, isDuplicate: true, + errorCode: json['error_code']?.toString(), + errorMessage: json['message']?.toString(), paichanNo: json['paichan_no'] as String?, + zongpaiNo: json['zongpai_no'] as String?, boxNo: json['box_no'] as int?, ); } @@ -358,6 +369,7 @@ class ApiService { required int boxItemId, required int boxNo, required int quantity, + String boxMode = 'one-to-many', }) async { final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId'); try { @@ -368,7 +380,7 @@ class ApiService { body: jsonEncode({ 'box_no': boxNo, 'quantity': quantity, - 'box_mode': 'one-to-many', + 'box_mode': boxMode, }), ) .timeout(timeout); diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart index 6e22123..144acc9 100644 --- a/test/services/api_service_test.dart +++ b/test/services/api_service_test.dart @@ -142,6 +142,152 @@ void main() { expect(result.errorMessage, '网络异常,请检查网络连接'); }); }); + + group('ApiService boxing APIs', () { + test('fetchBoxInfo parses current boxes and box item ids', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({ + 'zongpai_no': '26BW0011', + 'paichan_no': 'W00009', + 'work_order_no': '6-1(7)', + 'quantity': 80, + 'current_zongpai_boxes': [ + {'box_item_id': 101, 'box_no': 3, 'quantity': 30}, + ], + 'existing_boxes': [ + { + 'box_no': 3, + 'items': [ + { + 'box_item_id': 101, + 'zongpai_no': '26BW0011', + 'work_order_no': '6-1(7)', + 'quantity': 30, + 'total_quantity': 80, + }, + ], + }, + ], + 'max_box_no': 3, + 'suggested_box_no': 4, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.fetchBoxInfo( + baseUrl: 'http://localhost', + zongpaiNo: '26BW0011', + ); + + expect(result.success, isTrue); + expect(result.currentZongpaiBoxes.single.boxItemId, 101); + expect(result.existingBoxes.single.items.single.boxItemId, 101); + expect(result.existingBoxes.single.items.single.totalQuantity, 80); + expect(result.suggestedBoxNo, 4); + }); + + test('saveBoxRecord parses zongpai number from success response', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({ + 'box_item_id': 102, + 'paichan_no': 'W00009', + 'box_no': 4, + 'zongpai_no': '26BW0012', + 'quantity': 34, + 'created_at': '2026-05-13T10:00:00', + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.saveBoxRecord( + baseUrl: 'http://localhost', + zongpaiNo: '26BW0012', + boxNo: 4, + quantity: 34, + boxMode: 'many-to-one', + ); + + expect(result.success, isTrue); + expect(result.boxItemId, 102); + expect(result.zongpaiNo, '26BW0012'); + }); + + test('updateBoxRecord sends many-to-one box mode', () async { + late Map body; + final mockClient = _MockClient((request) async { + final req = request as http.Request; + body = jsonDecode(req.body) as Map; + return http.Response( + jsonEncode({ + 'box_item_id': 102, + 'paichan_no': 'W00009', + 'box_no': 4, + 'zongpai_no': '26BW0012', + 'quantity': 20, + 'updated_at': '2026-05-13T10:00:00', + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.updateBoxRecord( + baseUrl: 'http://localhost', + boxItemId: 102, + boxNo: 4, + quantity: 20, + boxMode: 'many-to-one', + ); + + expect(result.success, isTrue); + expect(body['box_mode'], 'many-to-one'); + }); + + test('deleteBoxRecord handles success and not found', () async { + var calls = 0; + final mockClient = _MockClient((request) async { + calls += 1; + if (calls == 1) { + return http.Response( + jsonEncode({'box_item_id': 102, 'deleted': true}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + return http.Response( + jsonEncode({ + 'error_code': 'BOX_ITEM_NOT_FOUND', + 'message': '指定装箱明细不存在', + }), + 404, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final ok = await svc.deleteBoxRecord( + baseUrl: 'http://localhost', + boxItemId: 102, + ); + final missing = await svc.deleteBoxRecord( + baseUrl: 'http://localhost', + boxItemId: 999, + ); + + expect(ok.success, isTrue); + expect(missing.success, isFalse); + expect(missing.errorMessage, '指定装箱明细不存在'); + }); + }); } class _MockClient extends http.BaseClient {