From 28871eb21f1611e316bc6395fa5c3c5a650721b0 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 14 May 2026 12:29:33 +0800 Subject: [PATCH] feat(boxing): support v2.5 dual modes --- lib/pages/boxing_page.dart | 256 ++++++++++++++++------------ lib/services/api_service.dart | 9 +- test/services/api_service_test.dart | 14 +- 3 files changed, 161 insertions(+), 118 deletions(-) diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index ebf69f7..6e212e2 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -11,9 +11,8 @@ import 'package:pad_scanner/widgets/status_bar.dart'; // === 装箱模式 === enum BoxingMode { - one2one, // 一码一箱 - one2many, // 一码多箱 - many2one, // 多码一箱 + singleCode, // 单码装箱 + multiCode, // 多码凑箱 } // === 页面阶段 === @@ -75,7 +74,7 @@ class _BoxingPageState extends State { final _feedbackService = FeedbackService(); // 模式 - BoxingMode _mode = BoxingMode.one2one; + BoxingMode _mode = BoxingMode.singleCode; // 阶段 _Phase _phase = _Phase.waiting; @@ -97,7 +96,7 @@ class _BoxingPageState extends State { final _boxNoFocusNode = FocusNode(); final _quantityFocusNode = FocusNode(); - // 多码一箱:本次操作已确认装入当前箱的明细 + // 多码凑箱:本次操作已确认装入当前箱的明细 final _manyToOnePackedItems = <_ManyToOnePackedItem>[]; int? _editingPackedItemId; String _editingPackedQuantity = ''; @@ -106,7 +105,7 @@ class _BoxingPageState extends State { // 提交中 bool _isSubmitting = false; - // 一码多箱:上次提交的箱号(用于继续添加预填) + // 单码装箱:上次提交的箱号(用于继续添加预填) int? _lastBoxNo; CurrentZongpaiBoxData? _editingBox; @@ -138,7 +137,7 @@ class _BoxingPageState extends State { } void _onBoxNoTextChanged() { - if (!mounted || _mode != BoxingMode.many2one) return; + if (!mounted || _mode != BoxingMode.multiCode) return; setState(() { _editingPackedItemId = null; _editingPackedQuantity = ''; @@ -150,12 +149,10 @@ class _BoxingPageState extends State { String get _modeLabel { switch (_mode) { - case BoxingMode.one2one: - return '一码一箱'; - case BoxingMode.one2many: - return '一码多箱'; - case BoxingMode.many2one: - return '多码一箱'; + case BoxingMode.singleCode: + return '单码装箱'; + case BoxingMode.multiCode: + return '多码凑箱'; } } @@ -163,12 +160,10 @@ class _BoxingPageState extends State { _feedbackService.trigger(FeedbackEvent.modeSwitch); setState(() { switch (_mode) { - case BoxingMode.one2one: - _mode = BoxingMode.one2many; - case BoxingMode.one2many: - _mode = BoxingMode.many2one; - case BoxingMode.many2one: - _mode = BoxingMode.one2one; + case BoxingMode.singleCode: + _mode = BoxingMode.multiCode; + case BoxingMode.multiCode: + _mode = BoxingMode.singleCode; } _resetState(); }); @@ -273,18 +268,31 @@ class _BoxingPageState extends State { void _applyAutoFill() { switch (_mode) { - case BoxingMode.one2one: - _boxNoController.text = (_maxBoxNo + 1).toString(); - _quantityController.text = (_erpQuantity ?? 0).toString(); - case BoxingMode.one2many: + case BoxingMode.singleCode: + final remaining = _remainingQuantity; + if (remaining <= 0) { + _boxNoController.clear(); + _quantityController.clear(); + _statusOverrideText = '该总排号已全部装箱完毕'; + _statusOverrideDot = StatusDotColor.red; + return; + } if (_lastBoxNo != null) { _boxNoController.text = (_lastBoxNo! + 1).toString(); - _quantityController.clear(); } else { _boxNoController.text = (_maxBoxNo + 1).toString(); - _quantityController.clear(); } - case BoxingMode.many2one: + _quantityController.text = remaining.toString(); + _quantityController.selection = TextSelection( + baseOffset: 0, + extentOffset: _quantityController.text.length, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _mode == BoxingMode.singleCode) { + _quantityFocusNode.requestFocus(); + } + }); + case BoxingMode.multiCode: if (_boxNoController.text.trim().isEmpty) { _boxNoController.text = (_maxBoxNo + 1).toString(); } @@ -294,41 +302,60 @@ class _BoxingPageState extends State { extentOffset: _quantityController.text.length, ); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _mode == BoxingMode.many2one) { + if (mounted && _mode == BoxingMode.multiCode) { _quantityFocusNode.requestFocus(); } }); } - _checkDuplicateBoxNo(); + _isDuplicateBoxNo = _boxNoIsDuplicate(); } // === 重复箱号检测 === - void _checkDuplicateBoxNo() { + bool _boxNoIsDuplicate() { final boxNo = int.tryParse(_boxNoController.text); if (boxNo == null) { - setState(() => _isDuplicateBoxNo = false); - return; + return false; } - final exists = switch (_mode) { - BoxingMode.many2one => false, - BoxingMode.one2one => _existingBoxes.any((b) => b.boxNo == boxNo), - BoxingMode.one2many => _existingBoxes.any((b) { + return switch (_mode) { + BoxingMode.multiCode => false, + BoxingMode.singleCode => _currentZongpaiBoxes.any((b) { if (b.boxNo != boxNo) return false; - return _editingBox == null || b.boxNo != _editingBox!.boxNo; + return _editingBox == null || b.boxItemId != _editingBox!.boxItemId; }), }; - setState(() => _isDuplicateBoxNo = exists); + } + + void _checkDuplicateBoxNo() { + setState(() => _isDuplicateBoxNo = _boxNoIsDuplicate()); } // === 提交 === + int get _packedQuantity { + return _currentZongpaiBoxes.fold( + 0, + (sum, item) => sum + item.quantity, + ); + } + + int get _remainingQuantity { + final total = _erpQuantity ?? 0; + return total - _packedQuantity; + } + + bool get _quantityTooHigh { + final qty = int.tryParse(_quantityController.text); + return qty != null && qty > _remainingQuantity; + } + bool get _canSubmit { if (_isSubmitting || _phase != _Phase.scanned) return false; if (_zongpaiNo == null) return false; final boxNo = int.tryParse(_boxNoController.text); final qty = int.tryParse(_quantityController.text); - if (boxNo == null || qty == null || qty <= 0) return false; + if (boxNo == null || boxNo <= 0 || qty == null || qty <= 0) return false; + if (_quantityTooHigh) return false; if (_isDuplicateBoxNo) return false; return true; } @@ -386,11 +413,6 @@ class _BoxingPageState extends State { setState(() => _isSubmitting = true); - final modeStr = switch (_mode) { - BoxingMode.one2one => 'one-to-one', - BoxingMode.one2many => 'one-to-many', - BoxingMode.many2one => 'many-to-one', - }; final editing = _editingBox; final result = editing == null ? await _apiService.saveBoxRecord( @@ -398,14 +420,12 @@ class _BoxingPageState extends State { zongpaiNo: _zongpaiNo!, boxNo: boxNo, quantity: quantity, - boxMode: modeStr, ) : await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: editing.boxItemId, boxNo: boxNo, quantity: quantity, - boxMode: modeStr, ); if (!mounted) return; @@ -420,19 +440,11 @@ class _BoxingPageState extends State { } } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); - if (result.errorCode == 'DUPLICATE_ZONGPAI_BIND') { - _showStatusOverride( - '该总排号已绑定箱号 ${result.boxNo ?? "?"},请勿重复装箱', - StatusDotColor.red, - const Duration(seconds: 2), - ); - } else { - _showStatusOverride( - '排产号 ${result.paichanNo ?? ""} 下箱号 ${result.boxNo ?? ""} 已存在', - StatusDotColor.amber, - const Duration(seconds: 2), - ); - } + _showStatusOverride( + '该总排号在箱号 ${result.boxNo ?? ""} 已存在,请重新输入', + StatusDotColor.amber, + const Duration(seconds: 2), + ); } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( @@ -451,7 +463,7 @@ class _BoxingPageState extends State { setState(() { _lastBoxNo = boxNo; - if (_mode == BoxingMode.one2many && boxItemId != null) { + if (_mode == BoxingMode.singleCode && boxItemId != null) { _currentZongpaiBoxes = List.from(_currentZongpaiBoxes) ..add( CurrentZongpaiBoxData( @@ -466,30 +478,36 @@ class _BoxingPageState extends State { }); switch (_mode) { - case BoxingMode.one2one: - _showStatusOverride( - '装箱成功', - StatusDotColor.green, - const Duration(milliseconds: 1500), - ); - Future.delayed(const Duration(milliseconds: 1500), () { - if (mounted) setState(() => _resetState()); - }); + case BoxingMode.singleCode: + final remaining = _remainingQuantity; + if (remaining <= 0) { + _showStatusOverride( + '装箱成功', + StatusDotColor.green, + const Duration(milliseconds: 1500), + ); + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _resetState()); + }); + } else { + setState(() { + _phase = _Phase.scanned; + _boxNoController.text = (boxNo + 1).toString(); + _quantityController.text = remaining.toString(); + _quantityController.selection = TextSelection( + baseOffset: 0, + extentOffset: _quantityController.text.length, + ); + _isDuplicateBoxNo = false; + }); + _showStatusOverride( + '请继续完成剩余数量装箱', + StatusDotColor.green, + const Duration(milliseconds: 1500), + ); + } - case BoxingMode.one2many: - setState(() { - _phase = _Phase.scanned; - _boxNoController.text = (boxNo + 1).toString(); - _quantityController.clear(); - _isDuplicateBoxNo = false; - }); - _showStatusOverride( - '装箱成功,可继续添加', - StatusDotColor.green, - const Duration(milliseconds: 1500), - ); - - case BoxingMode.many2one: + case BoxingMode.multiCode: setState(() { if (boxItemId != null) { _manyToOnePackedItems.add( @@ -538,7 +556,12 @@ class _BoxingPageState extends State { _editingBox = null; _lastBoxNo = _maxBoxNo; _boxNoController.text = (_maxBoxNo + 1).toString(); - _quantityController.clear(); + final remaining = _remainingQuantity; + if (remaining > 0) { + _quantityController.text = remaining.toString(); + } else { + _quantityController.clear(); + } _isDuplicateBoxNo = false; }); _showStatusOverride( @@ -699,7 +722,6 @@ class _BoxingPageState extends State { boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, - boxMode: 'many-to-one', ); if (!mounted) return; @@ -886,10 +908,15 @@ class _BoxingPageState extends State { _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; return; } + if (_quantityTooHigh && _phase == _Phase.scanned) { + _statusDot = StatusDotColor.red; + _statusText = '超出可装数量上限'; + return; + } switch (_phase) { case _Phase.waiting: _statusDot = StatusDotColor.blue; - if (_mode == BoxingMode.many2one && + if (_mode == BoxingMode.multiCode && _visibleManyToOnePackedItems.isNotEmpty) { _statusText = '请继续扫码或完成本箱'; } else { @@ -897,18 +924,21 @@ class _BoxingPageState extends State { } case _Phase.scanned: _statusDot = StatusDotColor.blue; - _statusText = _mode == BoxingMode.many2one + if (_remainingQuantity <= 0) { + _statusDot = StatusDotColor.red; + _statusText = '该总排号已全部装箱完毕'; + return; + } + _statusText = _mode == BoxingMode.multiCode ? '数量已填入,请确认或修改' - : '请确认信息并提交'; + : '数量已填入,请确认或修改'; case _Phase.submitted: _statusDot = StatusDotColor.green; switch (_mode) { - case BoxingMode.one2many: + case BoxingMode.singleCode: _statusText = '装箱成功,可继续添加或返回'; - case BoxingMode.many2one: + case BoxingMode.multiCode: _statusText = '请扫描下一个总排号'; - case BoxingMode.one2one: - _statusText = ''; } } } @@ -920,7 +950,7 @@ class _BoxingPageState extends State { final colorScheme = Theme.of(context).colorScheme; final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; final showActionButtons = - _phase == _Phase.scanned && _mode == BoxingMode.one2many; + _phase == _Phase.scanned && _mode == BoxingMode.singleCode; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; @@ -937,7 +967,7 @@ class _BoxingPageState extends State { icon: const Icon(Icons.swap_horiz, size: 18), label: Text(_modeLabel, style: const TextStyle(fontSize: 13)), style: TextButton.styleFrom( - foregroundColor: _mode == BoxingMode.many2one + foregroundColor: _mode == BoxingMode.multiCode ? Colors.orange : colorScheme.primary, ), @@ -960,7 +990,7 @@ class _BoxingPageState extends State { ), Expanded( - child: _mode == BoxingMode.many2one + child: _mode == BoxingMode.multiCode ? _buildManyToOneBody(colorScheme) : SingleChildScrollView( padding: const EdgeInsets.all(12), @@ -971,7 +1001,7 @@ class _BoxingPageState extends State { const SizedBox(height: 12), _buildInfoArea(isWaiting, colorScheme), const Divider(height: 24), - if (_mode == BoxingMode.one2many && + if (_mode == BoxingMode.singleCode && !isWaiting && _currentZongpaiBoxes.isNotEmpty) ...[ _buildAssignedBoxes(), @@ -983,7 +1013,7 @@ class _BoxingPageState extends State { ), ), - // 操作按钮(一码多箱 / 多码一箱) + // 操作按钮(单码装箱 / 多码凑箱) if (showActionButtons) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -998,11 +1028,13 @@ class _BoxingPageState extends State { child: const Text('返回'), ), ), - if (_mode == BoxingMode.one2many && _editingBox == null) ...[ + if (_mode == BoxingMode.singleCode && + _editingBox == null) ...[ const SizedBox(width: 12), const Expanded(child: SizedBox.shrink()), ], - if (_mode == BoxingMode.one2many && _editingBox != null) ...[ + if (_mode == BoxingMode.singleCode && + _editingBox != null) ...[ const SizedBox(width: 12), Expanded( child: ElevatedButton( @@ -1018,7 +1050,7 @@ class _BoxingPageState extends State { ), ), - if (_mode == BoxingMode.many2one) + if (_mode == BoxingMode.multiCode) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: OutlinedButton( @@ -1399,9 +1431,16 @@ class _BoxingPageState extends State { keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric(horizontal: 10), - border: OutlineInputBorder(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 10), + border: const OutlineInputBorder(), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _quantityTooHigh + ? Colors.red + : Colors.grey.shade400, + ), + ), ), ), ), @@ -1681,9 +1720,16 @@ class _BoxingPageState extends State { keyboardType: TextInputType.none, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric(horizontal: 10), - border: OutlineInputBorder(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 10), + border: const OutlineInputBorder(), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _quantityTooHigh + ? Colors.red + : Colors.grey.shade400, + ), + ), ), style: const TextStyle(fontSize: 16), ), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 0d784ec..319999a 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -325,7 +325,6 @@ class ApiService { required String zongpaiNo, required int boxNo, required int quantity, - required String boxMode, }) async { final uri = Uri.parse('$baseUrl/CargoTrace/box'); try { @@ -337,7 +336,6 @@ class ApiService { 'zongpai_no': zongpaiNo, 'box_no': boxNo, 'quantity': quantity, - 'box_mode': boxMode, }), ) .timeout(timeout); @@ -369,7 +367,6 @@ 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 { @@ -377,11 +374,7 @@ class ApiService { .patch( uri, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'box_no': boxNo, - 'quantity': quantity, - 'box_mode': boxMode, - }), + body: jsonEncode({'box_no': boxNo, 'quantity': quantity}), ) .timeout(timeout); diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart index 144acc9..f1ecc00 100644 --- a/test/services/api_service_test.dart +++ b/test/services/api_service_test.dart @@ -190,8 +190,11 @@ void main() { expect(result.suggestedBoxNo, 4); }); - test('saveBoxRecord parses zongpai number from success response', () async { + test('saveBoxRecord sends simplified request body', () 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, @@ -212,15 +215,16 @@ void main() { zongpaiNo: '26BW0012', boxNo: 4, quantity: 34, - boxMode: 'many-to-one', ); expect(result.success, isTrue); expect(result.boxItemId, 102); expect(result.zongpaiNo, '26BW0012'); + expect(body, {'zongpai_no': '26BW0012', 'box_no': 4, 'quantity': 34}); + expect(body.containsKey('box_mode'), isFalse); }); - test('updateBoxRecord sends many-to-one box mode', () async { + test('updateBoxRecord sends simplified request body', () async { late Map body; final mockClient = _MockClient((request) async { final req = request as http.Request; @@ -245,11 +249,11 @@ void main() { boxItemId: 102, boxNo: 4, quantity: 20, - boxMode: 'many-to-one', ); expect(result.success, isTrue); - expect(body['box_mode'], 'many-to-one'); + expect(body, {'box_no': 4, 'quantity': 20}); + expect(body.containsKey('box_mode'), isFalse); }); test('deleteBoxRecord handles success and not found', () async {