From c2d9f5fd568c48686ae397a2c55fd1ee6a1ebe9b Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 21 May 2026 09:02:56 +0800 Subject: [PATCH] refactor: extract boxing logic into dedicated modules with tests - boxing_models.dart: add BoxingMode, BoxingPhase, BoxingPageArguments - boxing_box_mutations.dart: box item add/replace/remove operations - boxing_calculations.dart: pure calculation helpers (quantities, validation) - boxing_status_presenter.dart: status bar text/dot determination - dialogs/cross_paichan_dialog.dart: cross-paichan confirmation dialog - widgets/boxing_notice_banners.dart: notice banner widgets - Add unit tests for mutations, calculations, and status presenter Co-Authored-By: Claude Opus 4.6 --- lib/pages/boxing/boxing_box_mutations.dart | 135 ++++ lib/pages/boxing/boxing_calculations.dart | 101 +++ lib/pages/boxing/boxing_models.dart | 21 + lib/pages/boxing/boxing_status_presenter.dart | 97 +++ .../boxing/dialogs/cross_paichan_dialog.dart | 126 ++++ .../boxing/widgets/boxing_notice_banners.dart | 178 +++++ lib/pages/boxing_page.dart | 608 ++++-------------- .../boxing/boxing_box_mutations_test.dart | 88 +++ .../boxing/boxing_calculations_test.dart | 123 ++++ .../boxing/boxing_status_presenter_test.dart | 81 +++ 10 files changed, 1066 insertions(+), 492 deletions(-) create mode 100644 lib/pages/boxing/boxing_box_mutations.dart create mode 100644 lib/pages/boxing/boxing_calculations.dart create mode 100644 lib/pages/boxing/boxing_status_presenter.dart create mode 100644 lib/pages/boxing/dialogs/cross_paichan_dialog.dart create mode 100644 lib/pages/boxing/widgets/boxing_notice_banners.dart create mode 100644 test/pages/boxing/boxing_box_mutations_test.dart create mode 100644 test/pages/boxing/boxing_calculations_test.dart create mode 100644 test/pages/boxing/boxing_status_presenter_test.dart diff --git a/lib/pages/boxing/boxing_box_mutations.dart b/lib/pages/boxing/boxing_box_mutations.dart new file mode 100644 index 0000000..a7b50b1 --- /dev/null +++ b/lib/pages/boxing/boxing_box_mutations.dart @@ -0,0 +1,135 @@ +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +class BoxMutationResult { + final List boxes; + final int maxBoxNo; + + const BoxMutationResult({required this.boxes, required this.maxBoxNo}); +} + +int maxBoxNoFor(List boxes) { + return boxes.fold(0, (max, box) => box.boxNo > max ? box.boxNo : max); +} + +BoxMutationResult addExistingBoxItem({ + required List existingBoxes, + required int boxNo, + required int quantity, + required int? boxItemId, + required String zongpaiNo, + required String? workOrderNo, + required int? totalQuantity, +}) { + final item = BoxItemData( + boxItemId: boxItemId, + zongpaiNo: zongpaiNo, + workOrderNo: workOrderNo, + quantity: quantity, + totalQuantity: totalQuantity, + ); + final index = existingBoxes.indexWhere((box) => box.boxNo == boxNo); + if (index < 0) { + final boxes = [ + ...existingBoxes, + BoxDetailData(boxNo: boxNo, items: [item]), + ]..sort((a, b) => a.boxNo.compareTo(b.boxNo)); + return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes)); + } + + final boxes = List.from(existingBoxes); + final box = boxes[index]; + boxes[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]); + return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes)); +} + +BoxMutationResult replaceExistingBoxItem({ + required List existingBoxes, + required CurrentZongpaiBoxData editing, + required int boxNo, + required int quantity, + required String zongpaiNo, + required String? workOrderNo, + required int? totalQuantity, +}) { + final updatedItem = BoxItemData( + boxItemId: editing.boxItemId, + zongpaiNo: zongpaiNo, + workOrderNo: workOrderNo, + quantity: quantity, + totalQuantity: totalQuantity, + ); + return _replaceItemById( + existingBoxes: existingBoxes, + boxItemId: editing.boxItemId, + targetBoxNo: boxNo, + updatedItem: updatedItem, + ); +} + +BoxMutationResult replaceExistingPackedItem({ + required List existingBoxes, + required ManyToOnePackedItem item, + required int boxNo, + required int quantity, +}) { + final updatedItem = BoxItemData( + boxItemId: item.boxItemId, + zongpaiNo: item.zongpaiNo, + workOrderNo: item.workOrderNo, + quantity: quantity, + totalQuantity: item.totalQuantity, + ); + return _replaceItemById( + existingBoxes: existingBoxes, + boxItemId: item.boxItemId, + targetBoxNo: boxNo, + updatedItem: updatedItem, + ); +} + +BoxMutationResult removeExistingBoxItem({ + required List existingBoxes, + required int boxItemId, +}) { + final boxes = []; + for (final box in existingBoxes) { + final items = box.items + .where((item) => item.boxItemId != boxItemId) + .toList(); + if (items.isNotEmpty) { + boxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); + } + } + return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes)); +} + +BoxMutationResult _replaceItemById({ + required List existingBoxes, + required int boxItemId, + required int targetBoxNo, + required BoxItemData updatedItem, +}) { + final boxes = []; + for (final box in existingBoxes) { + final items = box.items.where((item) { + return item.boxItemId != boxItemId; + }).toList(); + if (items.isNotEmpty) { + boxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); + } + } + + final targetIndex = boxes.indexWhere((box) => box.boxNo == targetBoxNo); + if (targetIndex >= 0) { + final target = boxes[targetIndex]; + boxes[targetIndex] = BoxDetailData( + boxNo: target.boxNo, + items: [...target.items, updatedItem], + ); + } else { + boxes.add(BoxDetailData(boxNo: targetBoxNo, items: [updatedItem])); + } + boxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); + return BoxMutationResult(boxes: boxes, maxBoxNo: maxBoxNoFor(boxes)); +} diff --git a/lib/pages/boxing/boxing_calculations.dart b/lib/pages/boxing/boxing_calculations.dart new file mode 100644 index 0000000..d1b17df --- /dev/null +++ b/lib/pages/boxing/boxing_calculations.dart @@ -0,0 +1,101 @@ +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +int packedQuantity(List items) { + return items.fold(0, (sum, item) => sum + item.quantity); +} + +int remainingQuantity({ + required int? totalQuantity, + required List currentBoxes, +}) { + return (totalQuantity ?? 0) - packedQuantity(currentBoxes); +} + +bool quantityTooHigh({ + required int remainingQuantity, + required String quantityText, +}) { + if (remainingQuantity <= 0) return false; + final quantity = int.tryParse(quantityText); + return quantity != null && quantity > remainingQuantity; +} + +bool canSubmitBoxing({ + required bool isSubmitting, + required BoxingPhase phase, + required String? zongpaiNo, + required String boxNoText, + required String quantityText, + required int remainingQuantity, + required bool quantityTooHigh, + required bool isDuplicateBoxNo, +}) { + if (isSubmitting || phase != BoxingPhase.scanned) return false; + if (zongpaiNo == null) return false; + final boxNo = int.tryParse(boxNoText); + final quantity = int.tryParse(quantityText); + if (boxNo == null || boxNo <= 0 || quantity == null || quantity <= 0) { + return false; + } + if (remainingQuantity <= 0) return false; + if (quantityTooHigh) return false; + if (isDuplicateBoxNo) return false; + return true; +} + +int? currentManyToOneBoxNo(String boxNoText) { + final boxNo = int.tryParse(boxNoText.trim()); + if (boxNo == null || boxNo <= 0) return null; + return boxNo; +} + +bool singleCodeBoxNoIsDuplicate({ + required String boxNoText, + required List currentBoxes, + required int? editingBoxItemId, +}) { + final boxNo = int.tryParse(boxNoText); + if (boxNo == null) return false; + return currentBoxes.any((box) { + if (box.boxNo != boxNo) return false; + return editingBoxItemId == null || box.boxItemId != editingBoxItemId; + }); +} + +List visibleManyToOnePackedItems({ + required int? boxNo, + required List existingBoxes, + required List packedItems, + required String? paichanNo, +}) { + 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 packedItems.where((item) => item.boxNo == boxNo)) { + byItemId[item.boxItemId] = item; + } + return byItemId.values.toList(growable: false); +} + +int maxAssignedQuantity({ + required int? totalQuantity, + required int packedQuantity, + required int itemQuantity, +}) { + return (totalQuantity ?? 0) - (packedQuantity - itemQuantity); +} diff --git a/lib/pages/boxing/boxing_models.dart b/lib/pages/boxing/boxing_models.dart index 08d193d..da1fe47 100644 --- a/lib/pages/boxing/boxing_models.dart +++ b/lib/pages/boxing/boxing_models.dart @@ -1,3 +1,24 @@ +enum BoxingMode { + singleCode, // 单码装箱 + multiCode, // 多码凑箱 +} + +enum BoxingPhase { + waiting, // 等待扫码 + scanned, // 已扫码,显示信息 + submitted, // 已提交成功 +} + +class BoxingPageArguments { + final BoxingMode initialMode; + final List autoScanCodes; + + const BoxingPageArguments({ + required this.initialMode, + required this.autoScanCodes, + }); +} + class ManyToOnePackedItem { final int boxItemId; final String zongpaiNo; diff --git a/lib/pages/boxing/boxing_status_presenter.dart b/lib/pages/boxing/boxing_status_presenter.dart new file mode 100644 index 0000000..3fd3441 --- /dev/null +++ b/lib/pages/boxing/boxing_status_presenter.dart @@ -0,0 +1,97 @@ +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/widgets/status_bar.dart'; + +class BoxingStatusPresentation { + final StatusDotColor dot; + final String text; + + const BoxingStatusPresentation({required this.dot, required this.text}); +} + +BoxingStatusPresentation boxingStatusFor({ + required bool isAutoProcessing, + required bool isSubmitting, + required bool isDuplicateBoxNo, + required bool quantityTooHigh, + required bool isEditingAssigned, + required bool isDeletingAssigned, + required bool hasPaichanSwitchNotice, + required BoxingPhase phase, + required bool isMultiCode, + required bool hasVisibleManyToOneItems, + required int remainingQuantity, + required String boxNoText, +}) { + if (isAutoProcessing) { + return const BoxingStatusPresentation( + dot: StatusDotColor.orange, + text: '正在同步转运数据...', + ); + } + if (isSubmitting) { + return const BoxingStatusPresentation( + dot: StatusDotColor.orange, + text: '正在提交…', + ); + } + if (isDuplicateBoxNo && phase == BoxingPhase.scanned) { + return BoxingStatusPresentation( + dot: StatusDotColor.amber, + text: '箱号 $boxNoText 已存在,请重新输入', + ); + } + if (quantityTooHigh && phase == BoxingPhase.scanned) { + return const BoxingStatusPresentation( + dot: StatusDotColor.red, + text: '超出可装数量上限', + ); + } + if (isEditingAssigned) { + return const BoxingStatusPresentation( + dot: StatusDotColor.amber, + text: '正在编辑已分配记录', + ); + } + if (isDeletingAssigned) { + return const BoxingStatusPresentation( + dot: StatusDotColor.red, + text: '请确认是否删除该箱记录', + ); + } + if (hasPaichanSwitchNotice) { + return const BoxingStatusPresentation( + dot: StatusDotColor.blue, + text: '排产号已切换,箱号已重置', + ); + } + + switch (phase) { + case BoxingPhase.waiting: + if (isMultiCode && hasVisibleManyToOneItems) { + return const BoxingStatusPresentation( + dot: StatusDotColor.blue, + text: '请继续扫码或完成本箱', + ); + } + return const BoxingStatusPresentation( + dot: StatusDotColor.blue, + text: '等待扫码', + ); + case BoxingPhase.scanned: + if (remainingQuantity <= 0) { + return const BoxingStatusPresentation( + dot: StatusDotColor.red, + text: '该总排号已全部装箱完毕', + ); + } + return const BoxingStatusPresentation( + dot: StatusDotColor.blue, + text: '数量已填入,请确认或修改', + ); + case BoxingPhase.submitted: + return BoxingStatusPresentation( + dot: StatusDotColor.green, + text: isMultiCode ? '请扫描下一个总排号' : '装箱成功,可继续扫码', + ); + } +} diff --git a/lib/pages/boxing/dialogs/cross_paichan_dialog.dart b/lib/pages/boxing/dialogs/cross_paichan_dialog.dart new file mode 100644 index 0000000..b17281a --- /dev/null +++ b/lib/pages/boxing/dialogs/cross_paichan_dialog.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +Future showCrossPaichanDialog({ + required BuildContext context, + required String currentPaicha, + required String newPaicha, +}) async { + final dialogFocus = FocusNode(); + var selectedIndex = 0; // 0 = "否" (default), 1 = "是" + + final dialogFuture = showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + void dismissAsNo() => Navigator.of(ctx).pop(false); + void confirmSwitch() => Navigator.of(ctx).pop(true); + + return KeyboardListener( + focusNode: dialogFocus, + onKeyEvent: (event) { + if (event is! KeyDownEvent) return; + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + setDialogState(() => selectedIndex = 0); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + setDialogState(() => selectedIndex = 1); + } else if (event.logicalKey == LogicalKeyboardKey.enter) { + selectedIndex == 0 ? dismissAsNo() : confirmSwitch(); + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + dismissAsNo(); + } + }, + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) dismissAsNo(); + }, + child: AlertDialog( + backgroundColor: Colors.orange.shade50, + title: Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange.shade800), + const SizedBox(width: 8), + Text( + '跨排产号扫描', + style: TextStyle(color: Colors.orange.shade900), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '当前排产号:$currentPaicha\n' + '新排产号:$newPaicha\n\n' + '是否切换到新的排产号?\n\n' + '选择「是」将放弃当前已扫描的所有数据。', + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + _DialogOptionButton( + label: '否', + selected: selectedIndex == 0, + onTap: dismissAsNo, + ), + const SizedBox(height: 8), + _DialogOptionButton( + label: '是,切换排产号', + selected: selectedIndex == 1, + onTap: confirmSwitch, + ), + ], + ), + ), + ), + ); + }, + ), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + dialogFocus.requestFocus(); + }); + + final result = await dialogFuture; + dialogFocus.dispose(); + return result ?? false; +} + +class _DialogOptionButton extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _DialogOptionButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 40, + child: ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + backgroundColor: selected + ? Colors.blue.shade700 + : Colors.grey.shade200, + foregroundColor: selected ? Colors.white : Colors.black87, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/lib/pages/boxing/widgets/boxing_notice_banners.dart b/lib/pages/boxing/widgets/boxing_notice_banners.dart new file mode 100644 index 0000000..74f97ec --- /dev/null +++ b/lib/pages/boxing/widgets/boxing_notice_banners.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; + +class BoxingNoticeBanners extends StatelessWidget { + final bool isAutoProcessing; + final List autoWarnings; + final String? paichanSwitchNotice; + final bool isDuplicateBoxNo; + final bool quantityTooHigh; + final BoxingPhase phase; + final String boxNoText; + final int remainingQuantity; + final String? zongpaiNo; + final int? completedJumpBoxNo; + final VoidCallback onJumpToCompletedBox; + + const BoxingNoticeBanners({ + super.key, + required this.isAutoProcessing, + required this.autoWarnings, + required this.paichanSwitchNotice, + required this.isDuplicateBoxNo, + required this.quantityTooHigh, + required this.phase, + required this.boxNoText, + required this.remainingQuantity, + required this.zongpaiNo, + required this.completedJumpBoxNo, + required this.onJumpToCompletedBox, + }); + + @override + Widget build(BuildContext context) { + final banners = []; + if (isAutoProcessing) { + banners.add( + _NoticeBanner( + icon: SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange.shade700, + ), + ), + text: '正在同步转运数据...', + background: Colors.orange.shade50, + foreground: Colors.orange.shade900, + border: Colors.orange.shade100, + ), + ); + } + if (autoWarnings.isNotEmpty) { + banners.add( + _NoticeBanner( + icon: Icon( + Icons.warning_amber, + size: 14, + color: Colors.amber.shade900, + ), + text: autoWarnings.join(';'), + background: Colors.amber.shade100, + foreground: Colors.amber.shade900, + border: Colors.amber.shade200, + ), + ); + } + if (paichanSwitchNotice != null) { + banners.add( + _NoticeBanner( + icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900), + text: paichanSwitchNotice!, + background: Colors.blue.shade50, + foreground: Colors.blue.shade900, + border: Colors.blue.shade100, + ), + ); + } + if (isDuplicateBoxNo && phase == BoxingPhase.scanned) { + banners.add( + _NoticeBanner( + icon: Icon( + Icons.warning_amber, + size: 14, + color: Colors.amber.shade900, + ), + text: '箱号 $boxNoText 已存在,请重新输入', + background: Colors.amber.shade100, + foreground: Colors.amber.shade900, + border: Colors.amber.shade200, + ), + ); + } + if (quantityTooHigh && phase == BoxingPhase.scanned) { + banners.add( + _NoticeBanner( + icon: Icon(Icons.close, size: 14, color: Colors.red.shade800), + text: '超出可装数量上限(最多可装 $remainingQuantity 件)', + background: Colors.red.shade50, + foreground: Colors.red.shade800, + border: Colors.red.shade100, + ), + ); + } + if (phase == BoxingPhase.scanned && + zongpaiNo != null && + remainingQuantity <= 0) { + final jumpBoxNo = completedJumpBoxNo; + if (jumpBoxNo != null) { + banners.add( + GestureDetector( + onTap: onJumpToCompletedBox, + behavior: HitTestBehavior.opaque, + child: _NoticeBanner( + icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800), + text: '$zongpaiNo 已完成装箱。(P4跳转所在箱号)', + background: Colors.amber.shade50, + foreground: Colors.amber.shade800, + border: Colors.amber.shade200, + ), + ), + ); + } else { + banners.add( + _NoticeBanner( + icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800), + text: '$zongpaiNo 已全部装箱完毕,无需操作', + background: Colors.red.shade50, + foreground: Colors.red.shade800, + border: Colors.red.shade100, + ), + ); + } + } + return Column(mainAxisSize: MainAxisSize.min, children: banners); + } +} + +class _NoticeBanner extends StatelessWidget { + final Widget icon; + final String text; + final Color background; + final Color foreground; + final Color border; + + const _NoticeBanner({ + required this.icon, + required this.text, + required this.background, + required this.foreground, + required this.border, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: background, + border: Border(bottom: BorderSide(color: border)), + ), + child: Row( + children: [ + icon, + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 12, color: foreground), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index b37cb6b..702833a 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -8,37 +8,22 @@ import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/boxing_context.dart'; import 'package:pad_scanner/services/feedback_service.dart'; +import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart' + as box_mutations; +import 'package:pad_scanner/pages/boxing/boxing_calculations.dart' + as boxing_calculations; import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart'; +import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart'; import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart'; +import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart'; import 'package:pad_scanner/pages/boxing/widgets/multi_code_body.dart'; import 'package:pad_scanner/pages/boxing/widgets/single_code_body.dart'; import 'package:pad_scanner/pages/boxing_detail_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; -// === 装箱模式 === - -enum BoxingMode { - singleCode, // 单码装箱 - multiCode, // 多码凑箱 -} - -class BoxingPageArguments { - final BoxingMode initialMode; - final List autoScanCodes; - - const BoxingPageArguments({ - required this.initialMode, - required this.autoScanCodes, - }); -} - -// === 页面阶段 === - -enum _Phase { - waiting, // 等待扫码 - scanned, // 已扫码,显示信息 - submitted, // 已提交成功 -} +export 'package:pad_scanner/pages/boxing/boxing_models.dart' + show BoxingMode, BoxingPageArguments; // === 主页面 === @@ -62,7 +47,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { BoxingMode _mode = BoxingMode.singleCode; // 阶段 - _Phase _phase = _Phase.waiting; + BoxingPhase _phase = BoxingPhase.waiting; // 当前总排号 String? _zongpaiNo; @@ -221,7 +206,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } void _resetState() { - _phase = _Phase.waiting; + _phase = BoxingPhase.waiting; _zongpaiNo = null; _paichanNo = null; _workOrderNo = null; @@ -334,7 +319,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { _currentZongpaiBoxes = result.currentZongpaiBoxes; _existingBoxes = result.existingBoxes; _maxBoxNo = result.maxBoxNo; - _phase = _Phase.scanned; + _phase = BoxingPhase.scanned; _isDuplicateBoxNo = false; _editingBox = null; _editingAssignedItemId = null; @@ -469,98 +454,16 @@ class _BoxingPageState extends State with WidgetsBindingObserver { // === 跨排产号确认对话框 === - void _showCrossPaichaDialog() { + Future _showCrossPaichaDialog() async { final currentPaicha = _lastScannedPaichanNo ?? '--'; final newPaicha = _paichanNo ?? '--'; - final dialogFocus = FocusNode(); - var dismissed = false; - var selectedIndex = 0; // 0 = "否" (default), 1 = "是" - - void dismissAsNo() { - if (dismissed) return; - dismissed = true; - Navigator.of(context).pop(); - _cancelPaichanSwitch(); - } - - void confirmSwitch() { - if (dismissed) return; - dismissed = true; - Navigator.of(context).pop(); - _confirmPaichanSwitch(); - } - - showDialog( + final confirmed = await showCrossPaichanDialog( context: context, - barrierDismissible: false, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) { - return KeyboardListener( - focusNode: dialogFocus, - onKeyEvent: (event) { - if (event is! KeyDownEvent) return; - if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - setDialogState(() => selectedIndex = 0); - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - setDialogState(() => selectedIndex = 1); - } else if (event.logicalKey == LogicalKeyboardKey.enter) { - selectedIndex == 0 ? dismissAsNo() : confirmSwitch(); - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - dismissAsNo(); - } - }, - child: PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) dismissAsNo(); - }, - child: AlertDialog( - backgroundColor: Colors.orange.shade50, - title: Row( - children: [ - Icon(Icons.warning_amber, color: Colors.orange.shade800), - const SizedBox(width: 8), - Text( - '跨排产号扫描', - style: TextStyle(color: Colors.orange.shade900), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '当前排产号:$currentPaicha\n' - '新排产号:$newPaicha\n\n' - '是否切换到新的排产号?\n\n' - '选择「是」将放弃当前已扫描的所有数据。', - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 16), - _dialogOptionBtn( - label: '否', - selected: selectedIndex == 0, - onTap: dismissAsNo, - ), - const SizedBox(height: 8), - _dialogOptionBtn( - label: '是,切换排产号', - selected: selectedIndex == 1, - onTap: confirmSwitch, - ), - ], - ), - ), - ), - ); - }, - ), + currentPaicha: currentPaicha, + newPaicha: newPaicha, ); - - WidgetsBinding.instance.addPostFrameCallback((_) { - dialogFocus.requestFocus(); - }); + if (!mounted) return; + confirmed ? _confirmPaichanSwitch() : _cancelPaichanSwitch(); } void _confirmPaichanSwitch() { @@ -609,50 +512,20 @@ class _BoxingPageState extends State with WidgetsBindingObserver { _crossPaichaPending = false; _zongpaiNo = null; _paichanNo = _lastScannedPaichanNo; - _phase = _Phase.waiting; + _phase = BoxingPhase.waiting; }); } - Widget _dialogOptionBtn({ - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return SizedBox( - width: double.infinity, - height: 40, - child: ElevatedButton( - onPressed: onTap, - style: ElevatedButton.styleFrom( - backgroundColor: selected - ? Colors.blue.shade700 - : Colors.grey.shade200, - foregroundColor: selected ? Colors.white : Colors.black87, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: Text( - label, - style: TextStyle( - fontWeight: selected ? FontWeight.w800 : FontWeight.w600, - ), - ), - ), - ); - } - // === 重复箱号检测 === bool _boxNoIsDuplicate() { - final boxNo = int.tryParse(_boxNoController.text); - if (boxNo == null) { - return false; - } return switch (_mode) { BoxingMode.multiCode => false, - BoxingMode.singleCode => _currentZongpaiBoxes.any((b) { - if (b.boxNo != boxNo) return false; - return _editingBox == null || b.boxItemId != _editingBox!.boxItemId; - }), + BoxingMode.singleCode => boxing_calculations.singleCodeBoxNoIsDuplicate( + boxNoText: _boxNoController.text, + currentBoxes: _currentZongpaiBoxes, + editingBoxItemId: _editingBox?.boxItemId, + ), }; } @@ -663,33 +536,34 @@ class _BoxingPageState extends State with WidgetsBindingObserver { // === 提交 === int get _packedQuantity { - return _currentZongpaiBoxes.fold( - 0, - (sum, item) => sum + item.quantity, - ); + return boxing_calculations.packedQuantity(_currentZongpaiBoxes); } int get _remainingQuantity { - final total = _erpQuantity ?? 0; - return total - _packedQuantity; + return boxing_calculations.remainingQuantity( + totalQuantity: _erpQuantity, + currentBoxes: _currentZongpaiBoxes, + ); } bool get _quantityTooHigh { - if (_remainingQuantity <= 0) return false; - final qty = int.tryParse(_quantityController.text); - return qty != null && qty > _remainingQuantity; + return boxing_calculations.quantityTooHigh( + remainingQuantity: _remainingQuantity, + quantityText: _quantityController.text, + ); } 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 || boxNo <= 0 || qty == null || qty <= 0) return false; - if (_remainingQuantity <= 0) return false; - if (_quantityTooHigh) return false; - if (_isDuplicateBoxNo) return false; - return true; + return boxing_calculations.canSubmitBoxing( + isSubmitting: _isSubmitting, + phase: _phase, + zongpaiNo: _zongpaiNo, + boxNoText: _boxNoController.text, + quantityText: _quantityController.text, + remainingQuantity: _remainingQuantity, + quantityTooHigh: _quantityTooHigh, + isDuplicateBoxNo: _isDuplicateBoxNo, + ); } void _submitFromKeyboard() { @@ -710,36 +584,16 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } int? get _currentManyToOneBoxNo { - final boxNo = int.tryParse(_boxNoController.text.trim()); - if (boxNo == null || boxNo <= 0) return null; - return boxNo; + return boxing_calculations.currentManyToOneBoxNo(_boxNoController.text); } List 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); + return boxing_calculations.visibleManyToOnePackedItems( + boxNo: _currentManyToOneBoxNo, + existingBoxes: _existingBoxes, + packedItems: _manyToOnePackedItems, + paichanNo: _paichanNo, + ); } Future _submit() async { @@ -839,7 +693,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { ); } else { setState(() { - _phase = _Phase.scanned; + _phase = BoxingPhase.scanned; _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( @@ -872,7 +726,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { ), ); } - _phase = _Phase.waiting; + _phase = BoxingPhase.waiting; _zongpaiNo = null; _editingPackedItemId = null; _editingPackedQuantity = ''; @@ -925,62 +779,31 @@ class _BoxingPageState extends State with WidgetsBindingObserver { int boxNo, int quantity, ) { - final nextBoxes = []; - for (final box in _existingBoxes) { - final items = box.items.where((item) { - return item.boxItemId != editing.boxItemId; - }).toList(); - if (items.isNotEmpty) { - nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); - } - } - - final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); - final updatedItem = BoxItemData( - boxItemId: editing.boxItemId, + final result = box_mutations.replaceExistingBoxItem( + existingBoxes: _existingBoxes, + editing: editing, + boxNo: boxNo, + quantity: quantity, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, - quantity: quantity, totalQuantity: _erpQuantity, ); - 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, - ); + _existingBoxes = result.boxes; + _maxBoxNo = result.maxBoxNo; } void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) { - final item = BoxItemData( + final result = box_mutations.addExistingBoxItem( + existingBoxes: _existingBoxes, + boxNo: boxNo, + quantity: quantity, boxItemId: boxItemId, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, - quantity: quantity, totalQuantity: _erpQuantity, ); - final index = _existingBoxes.indexWhere((box) => box.boxNo == boxNo); - if (index < 0) { - _existingBoxes = [ - ..._existingBoxes, - BoxDetailData(boxNo: boxNo, items: [item]), - ]..sort((a, b) => a.boxNo.compareTo(b.boxNo)); - return; - } - - final next = List.from(_existingBoxes); - final box = next[index]; - next[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]); - _existingBoxes = next; + _existingBoxes = result.boxes; + _maxBoxNo = result.maxBoxNo; } void _finishManyToOneBox() { @@ -990,7 +813,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { _editingPackedQuantity = ''; _deletingPackedItemId = null; _zongpaiNo = null; - _phase = _Phase.waiting; + _phase = BoxingPhase.waiting; _quantityController.clear(); _boxNoController.text = (_maxBoxNo + 1).toString(); _statusOverrideText = null; @@ -1016,9 +839,11 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } int _maxAssignedQuantity(CurrentZongpaiBoxData item) { - final total = _erpQuantity ?? 0; - final packedWithoutItem = _packedQuantity - item.quantity; - return total - packedWithoutItem; + return boxing_calculations.maxAssignedQuantity( + totalQuantity: _erpQuantity, + packedQuantity: _packedQuantity, + itemQuantity: item.quantity, + ); } void _refreshSingleCodeNewInput({bool focusQuantity = true}) { @@ -1246,39 +1071,14 @@ class _BoxingPageState extends State with WidgetsBindingObserver { 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, + final result = box_mutations.replaceExistingPackedItem( + existingBoxes: _existingBoxes, + item: item, + boxNo: boxNo, 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, ); + _existingBoxes = result.boxes; + _maxBoxNo = result.maxBoxNo; } Future _deletePackedItem(ManyToOnePackedItem item) async { @@ -1331,20 +1131,12 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } void _removeExistingBoxItem(int boxItemId) { - final nextBoxes = []; - for (final box in _existingBoxes) { - final items = box.items - .where((item) => item.boxItemId != boxItemId) - .toList(); - if (items.isNotEmpty) { - nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); - } - } - _existingBoxes = nextBoxes; - _maxBoxNo = nextBoxes.fold( - 0, - (max, box) => box.boxNo > max ? box.boxNo : max, + final result = box_mutations.removeExistingBoxItem( + existingBoxes: _existingBoxes, + boxItemId: boxItemId, ); + _existingBoxes = result.boxes; + _maxBoxNo = result.maxBoxNo; } // === Status bar management === @@ -1392,69 +1184,22 @@ class _BoxingPageState extends State with WidgetsBindingObserver { // === 状态文字 === void _updateBaseStatus() { - if (_isAutoProcessing) { - _statusDot = StatusDotColor.orange; - _statusText = '正在同步转运数据...'; - return; - } - if (_isSubmitting) { - _statusDot = StatusDotColor.orange; - _statusText = '正在提交…'; - return; - } - if (_isDuplicateBoxNo && _phase == _Phase.scanned) { - _statusDot = StatusDotColor.amber; - _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; - return; - } - if (_quantityTooHigh && _phase == _Phase.scanned) { - _statusDot = StatusDotColor.red; - _statusText = '超出可装数量上限'; - return; - } - if (_editingAssignedItemId != null) { - _statusDot = StatusDotColor.amber; - _statusText = '正在编辑已分配记录'; - return; - } - if (_deletingAssignedItemId != null) { - _statusDot = StatusDotColor.red; - _statusText = '请确认是否删除该箱记录'; - return; - } - if (_paichanSwitchNotice != null) { - _statusDot = StatusDotColor.blue; - _statusText = '排产号已切换,箱号已重置'; - return; - } - switch (_phase) { - case _Phase.waiting: - _statusDot = StatusDotColor.blue; - if (_mode == BoxingMode.multiCode && - _visibleManyToOnePackedItems.isNotEmpty) { - _statusText = '请继续扫码或完成本箱'; - } else { - _statusText = '等待扫码'; - } - case _Phase.scanned: - _statusDot = StatusDotColor.blue; - if (_remainingQuantity <= 0) { - _statusDot = StatusDotColor.red; - _statusText = '该总排号已全部装箱完毕'; - return; - } - _statusText = _mode == BoxingMode.multiCode - ? '数量已填入,请确认或修改' - : '数量已填入,请确认或修改'; - case _Phase.submitted: - _statusDot = StatusDotColor.green; - switch (_mode) { - case BoxingMode.singleCode: - _statusText = '装箱成功,可继续扫码'; - case BoxingMode.multiCode: - _statusText = '请扫描下一个总排号'; - } - } + final status = boxingStatusFor( + isAutoProcessing: _isAutoProcessing, + isSubmitting: _isSubmitting, + isDuplicateBoxNo: _isDuplicateBoxNo, + quantityTooHigh: _quantityTooHigh, + isEditingAssigned: _editingAssignedItemId != null, + isDeletingAssigned: _deletingAssignedItemId != null, + hasPaichanSwitchNotice: _paichanSwitchNotice != null, + phase: _phase, + isMultiCode: _mode == BoxingMode.multiCode, + hasVisibleManyToOneItems: _visibleManyToOnePackedItems.isNotEmpty, + remainingQuantity: _remainingQuantity, + boxNoText: _boxNoController.text, + ); + _statusDot = status.dot; + _statusText = status.text; } // === Build === @@ -1487,12 +1232,25 @@ class _BoxingPageState extends State with WidgetsBindingObserver { ), body: Column( children: [ - ..._buildNoticeBanners(), + BoxingNoticeBanners( + isAutoProcessing: _isAutoProcessing, + autoWarnings: _autoWarnings, + paichanSwitchNotice: _paichanSwitchNotice, + isDuplicateBoxNo: _isDuplicateBoxNo, + quantityTooHigh: _quantityTooHigh, + phase: _phase, + boxNoText: _boxNoController.text, + remainingQuantity: _remainingQuantity, + zongpaiNo: _zongpaiNo, + completedJumpBoxNo: _completedJumpBoxNo, + onJumpToCompletedBox: _jumpToCompletedBox, + ), Expanded( child: _mode == BoxingMode.multiCode ? MultiCodeBody( - hasScan: _zongpaiNo != null && _phase == _Phase.scanned, + hasScan: + _zongpaiNo != null && _phase == BoxingPhase.scanned, zongpaiNo: _zongpaiNo, paichanNo: _paichanNo, workOrderNo: _workOrderNo, @@ -1532,9 +1290,10 @@ class _BoxingPageState extends State with WidgetsBindingObserver { setState(() => _deletingPackedItemId = null), ) : SingleCodeBody( - isWaiting: _phase == _Phase.waiting && _zongpaiNo == null, + isWaiting: + _phase == BoxingPhase.waiting && _zongpaiNo == null, isFinished: - _phase == _Phase.scanned && + _phase == BoxingPhase.scanned && _zongpaiNo != null && _remainingQuantity <= 0, zongpaiNo: _zongpaiNo, @@ -1586,139 +1345,4 @@ class _BoxingPageState extends State with WidgetsBindingObserver { ), ); } - - List _buildNoticeBanners() { - final banners = []; - if (_isAutoProcessing) { - banners.add( - _noticeBanner( - icon: SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.orange.shade700, - ), - ), - text: '正在同步转运数据...', - background: Colors.orange.shade50, - foreground: Colors.orange.shade900, - border: Colors.orange.shade100, - ), - ); - } - if (_autoWarnings.isNotEmpty) { - banners.add( - _noticeBanner( - icon: Icon( - Icons.warning_amber, - size: 14, - color: Colors.amber.shade900, - ), - text: _autoWarnings.join(';'), - background: Colors.amber.shade100, - foreground: Colors.amber.shade900, - border: Colors.amber.shade200, - ), - ); - } - if (_paichanSwitchNotice != null) { - banners.add( - _noticeBanner( - icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900), - text: _paichanSwitchNotice!, - background: Colors.blue.shade50, - foreground: Colors.blue.shade900, - border: Colors.blue.shade100, - ), - ); - } - if (_isDuplicateBoxNo && _phase == _Phase.scanned) { - banners.add( - _noticeBanner( - icon: Icon( - Icons.warning_amber, - size: 14, - color: Colors.amber.shade900, - ), - text: '箱号 ${_boxNoController.text} 已存在,请重新输入', - background: Colors.amber.shade100, - foreground: Colors.amber.shade900, - border: Colors.amber.shade200, - ), - ); - } - if (_quantityTooHigh && _phase == _Phase.scanned) { - banners.add( - _noticeBanner( - icon: Icon(Icons.close, size: 14, color: Colors.red.shade800), - text: '超出可装数量上限(最多可装 $_remainingQuantity 件)', - background: Colors.red.shade50, - foreground: Colors.red.shade800, - border: Colors.red.shade100, - ), - ); - } - if (_phase == _Phase.scanned && - _zongpaiNo != null && - _remainingQuantity <= 0) { - final jumpBoxNo = _completedJumpBoxNo; - if (jumpBoxNo != null) { - banners.add( - GestureDetector( - onTap: _jumpToCompletedBox, - behavior: HitTestBehavior.opaque, - child: _noticeBanner( - icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800), - text: '${_zongpaiNo!} 已完成装箱。(P4跳转所在箱号)', - background: Colors.amber.shade50, - foreground: Colors.amber.shade800, - border: Colors.amber.shade200, - ), - ), - ); - } else { - banners.add( - _noticeBanner( - icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800), - text: '${_zongpaiNo!} 已全部装箱完毕,无需操作', - background: Colors.red.shade50, - foreground: Colors.red.shade800, - border: Colors.red.shade100, - ), - ); - } - } - return banners; - } - - Widget _noticeBanner({ - required Widget icon, - required String text, - required Color background, - required Color foreground, - required Color border, - }) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), - decoration: BoxDecoration( - color: background, - border: Border(bottom: BorderSide(color: border)), - ), - child: Row( - children: [ - icon, - const SizedBox(width: 6), - Expanded( - child: Text( - text, - style: TextStyle(fontSize: 12, color: foreground), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } } diff --git a/test/pages/boxing/boxing_box_mutations_test.dart b/test/pages/boxing/boxing_box_mutations_test.dart new file mode 100644 index 0000000..18a5da5 --- /dev/null +++ b/test/pages/boxing/boxing_box_mutations_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart'; +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +void main() { + group('boxing box mutations', () { + test('adds item to a new sorted box', () { + final result = addExistingBoxItem( + existingBoxes: [BoxDetailData(boxNo: 3, items: const [])], + boxNo: 2, + quantity: 5, + boxItemId: 22, + zongpaiNo: 'ZP022', + workOrderNo: 'WO022', + totalQuantity: 5, + ); + + expect(result.boxes.map((box) => box.boxNo), [2, 3]); + expect(result.maxBoxNo, 3); + expect(result.boxes.first.items.single.zongpaiNo, 'ZP022'); + }); + + test('replaces single code item and removes empty source box', () { + final result = replaceExistingBoxItem( + existingBoxes: [ + BoxDetailData( + boxNo: 1, + items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)], + ), + ], + editing: CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 2), + boxNo: 4, + quantity: 3, + zongpaiNo: 'ZP001', + workOrderNo: 'WO001', + totalQuantity: 5, + ); + + expect(result.boxes.map((box) => box.boxNo), [4]); + expect(result.boxes.single.items.single.quantity, 3); + expect(result.maxBoxNo, 4); + }); + + test('moves packed item to a different box', () { + final result = replaceExistingPackedItem( + existingBoxes: [ + BoxDetailData( + boxNo: 2, + items: [BoxItemData(boxItemId: 9, zongpaiNo: 'ZP009', quantity: 1)], + ), + ], + item: const ManyToOnePackedItem( + boxItemId: 9, + zongpaiNo: 'ZP009', + workOrderNo: 'WO009', + boxNo: 2, + quantity: 1, + totalQuantity: 3, + ), + boxNo: 5, + quantity: 2, + ); + + expect(result.boxes.map((box) => box.boxNo), [5]); + expect(result.boxes.single.items.single.quantity, 2); + }); + + test('removes item and drops empty box', () { + final result = removeExistingBoxItem( + existingBoxes: [ + BoxDetailData( + boxNo: 1, + items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)], + ), + BoxDetailData( + boxNo: 3, + items: [BoxItemData(boxItemId: 2, zongpaiNo: 'ZP002', quantity: 1)], + ), + ], + boxItemId: 1, + ); + + expect(result.boxes.map((box) => box.boxNo), [3]); + expect(result.maxBoxNo, 3); + }); + }); +} diff --git a/test/pages/boxing/boxing_calculations_test.dart b/test/pages/boxing/boxing_calculations_test.dart new file mode 100644 index 0000000..a5dd5b5 --- /dev/null +++ b/test/pages/boxing/boxing_calculations_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/pages/boxing/boxing_calculations.dart'; +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +void main() { + group('boxing calculations', () { + final currentBoxes = [ + CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 4), + CurrentZongpaiBoxData(boxItemId: 2, boxNo: 2, quantity: 3), + ]; + + test('calculates packed and remaining quantity', () { + expect(packedQuantity(currentBoxes), 7); + expect( + remainingQuantity(totalQuantity: 10, currentBoxes: currentBoxes), + 3, + ); + }); + + test('detects quantity too high only when remaining is positive', () { + expect(quantityTooHigh(remainingQuantity: 3, quantityText: '4'), isTrue); + expect(quantityTooHigh(remainingQuantity: 3, quantityText: '3'), isFalse); + expect(quantityTooHigh(remainingQuantity: 0, quantityText: '1'), isFalse); + }); + + test('validates submit conditions', () { + expect( + canSubmitBoxing( + isSubmitting: false, + phase: BoxingPhase.scanned, + zongpaiNo: 'ZP001', + boxNoText: '1', + quantityText: '2', + remainingQuantity: 3, + quantityTooHigh: false, + isDuplicateBoxNo: false, + ), + isTrue, + ); + + expect( + canSubmitBoxing( + isSubmitting: true, + phase: BoxingPhase.scanned, + zongpaiNo: 'ZP001', + boxNoText: '1', + quantityText: '2', + remainingQuantity: 3, + quantityTooHigh: false, + isDuplicateBoxNo: false, + ), + isFalse, + ); + }); + + test('detects duplicate box number in single code mode', () { + expect( + singleCodeBoxNoIsDuplicate( + boxNoText: '1', + currentBoxes: currentBoxes, + editingBoxItemId: null, + ), + isTrue, + ); + expect( + singleCodeBoxNoIsDuplicate( + boxNoText: '1', + currentBoxes: currentBoxes, + editingBoxItemId: 1, + ), + isFalse, + ); + }); + + test('merges visible many-to-one items by box item id', () { + final existingBoxes = [ + BoxDetailData( + boxNo: 8, + items: [ + BoxItemData( + boxItemId: 11, + zongpaiNo: 'ZP011', + workOrderNo: 'WO011', + quantity: 2, + totalQuantity: 6, + ), + ], + ), + ]; + final packedItems = [ + const ManyToOnePackedItem( + boxItemId: 11, + zongpaiNo: 'ZP011', + paichanNo: 'PC001', + workOrderNo: 'WO011', + boxNo: 8, + quantity: 3, + totalQuantity: 6, + ), + const ManyToOnePackedItem( + boxItemId: 12, + zongpaiNo: 'ZP012', + paichanNo: 'PC001', + workOrderNo: 'WO012', + boxNo: 8, + quantity: 1, + totalQuantity: 4, + ), + ]; + + final visible = visibleManyToOnePackedItems( + boxNo: 8, + existingBoxes: existingBoxes, + packedItems: packedItems, + paichanNo: 'PC001', + ); + + expect(visible.map((item) => item.boxItemId), [11, 12]); + expect(visible.first.quantity, 3); + }); + }); +} diff --git a/test/pages/boxing/boxing_status_presenter_test.dart b/test/pages/boxing/boxing_status_presenter_test.dart new file mode 100644 index 0000000..03eebe3 --- /dev/null +++ b/test/pages/boxing/boxing_status_presenter_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/pages/boxing/boxing_models.dart'; +import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart'; +import 'package:pad_scanner/widgets/status_bar.dart'; + +void main() { + BoxingStatusPresentation status({ + bool isAutoProcessing = false, + bool isSubmitting = false, + bool isDuplicateBoxNo = false, + bool quantityTooHigh = false, + bool isEditingAssigned = false, + bool isDeletingAssigned = false, + bool hasPaichanSwitchNotice = false, + BoxingPhase phase = BoxingPhase.waiting, + bool isMultiCode = false, + bool hasVisibleManyToOneItems = false, + int remainingQuantity = 1, + String boxNoText = '1', + }) { + return boxingStatusFor( + isAutoProcessing: isAutoProcessing, + isSubmitting: isSubmitting, + isDuplicateBoxNo: isDuplicateBoxNo, + quantityTooHigh: quantityTooHigh, + isEditingAssigned: isEditingAssigned, + isDeletingAssigned: isDeletingAssigned, + hasPaichanSwitchNotice: hasPaichanSwitchNotice, + phase: phase, + isMultiCode: isMultiCode, + hasVisibleManyToOneItems: hasVisibleManyToOneItems, + remainingQuantity: remainingQuantity, + boxNoText: boxNoText, + ); + } + + group('boxingStatusFor', () { + test('reports waiting and multi-code continuation states', () { + expect(status().text, '等待扫码'); + final multi = status(isMultiCode: true, hasVisibleManyToOneItems: true); + expect(multi.text, '请继续扫码或完成本箱'); + }); + + test('prioritizes transient and validation states', () { + expect(status(isSubmitting: true).text, '正在提交…'); + expect( + status(isDuplicateBoxNo: true, phase: BoxingPhase.scanned).dot, + StatusDotColor.amber, + ); + expect( + status(quantityTooHigh: true, phase: BoxingPhase.scanned).text, + '超出可装数量上限', + ); + }); + + test('reports edit delete and switched paichan states', () { + expect(status(isEditingAssigned: true).text, '正在编辑已分配记录'); + expect(status(isDeletingAssigned: true).dot, StatusDotColor.red); + expect(status(hasPaichanSwitchNotice: true).text, '排产号已切换,箱号已重置'); + }); + + test('reports scanned completed and submitted states', () { + expect( + status(phase: BoxingPhase.scanned, remainingQuantity: 0).text, + '该总排号已全部装箱完毕', + ); + expect( + status(phase: BoxingPhase.scanned, remainingQuantity: 2).text, + '数量已填入,请确认或修改', + ); + expect( + status(phase: BoxingPhase.submitted, isMultiCode: false).text, + '装箱成功,可继续扫码', + ); + expect( + status(phase: BoxingPhase.submitted, isMultiCode: true).text, + '请扫描下一个总排号', + ); + }); + }); +}