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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-21 09:02:56 +08:00
parent f2b2f41929
commit c2d9f5fd56
10 changed files with 1066 additions and 492 deletions

View File

@@ -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<BoxDetailData> boxes;
final int maxBoxNo;
const BoxMutationResult({required this.boxes, required this.maxBoxNo});
}
int maxBoxNoFor(List<BoxDetailData> boxes) {
return boxes.fold<int>(0, (max, box) => box.boxNo > max ? box.boxNo : max);
}
BoxMutationResult addExistingBoxItem({
required List<BoxDetailData> 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<BoxDetailData>.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<BoxDetailData> 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<BoxDetailData> 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<BoxDetailData> existingBoxes,
required int boxItemId,
}) {
final boxes = <BoxDetailData>[];
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<BoxDetailData> existingBoxes,
required int boxItemId,
required int targetBoxNo,
required BoxItemData updatedItem,
}) {
final boxes = <BoxDetailData>[];
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));
}

View File

@@ -0,0 +1,101 @@
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
int packedQuantity(List<CurrentZongpaiBoxData> items) {
return items.fold<int>(0, (sum, item) => sum + item.quantity);
}
int remainingQuantity({
required int? totalQuantity,
required List<CurrentZongpaiBoxData> 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<CurrentZongpaiBoxData> 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<ManyToOnePackedItem> visibleManyToOnePackedItems({
required int? boxNo,
required List<BoxDetailData> existingBoxes,
required List<ManyToOnePackedItem> packedItems,
required String? paichanNo,
}) {
if (boxNo == null) return const [];
final byItemId = <int, ManyToOnePackedItem>{};
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);
}

View File

@@ -1,3 +1,24 @@
enum BoxingMode {
singleCode, // 单码装箱
multiCode, // 多码凑箱
}
enum BoxingPhase {
waiting, // 等待扫码
scanned, // 已扫码,显示信息
submitted, // 已提交成功
}
class BoxingPageArguments {
final BoxingMode initialMode;
final List<String> autoScanCodes;
const BoxingPageArguments({
required this.initialMode,
required this.autoScanCodes,
});
}
class ManyToOnePackedItem { class ManyToOnePackedItem {
final int boxItemId; final int boxItemId;
final String zongpaiNo; final String zongpaiNo;

View File

@@ -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 ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Future<bool> showCrossPaichanDialog({
required BuildContext context,
required String currentPaicha,
required String newPaicha,
}) async {
final dialogFocus = FocusNode();
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
final dialogFuture = showDialog<bool>(
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,
),
),
),
);
}
}

View File

@@ -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<String> 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 = <Widget>[];
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,
),
),
],
),
);
}
}

View File

@@ -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/api_service.dart';
import 'package:pad_scanner/services/boxing_context.dart'; import 'package:pad_scanner/services/boxing_context.dart';
import 'package:pad_scanner/services/feedback_service.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_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_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/multi_code_body.dart';
import 'package:pad_scanner/pages/boxing/widgets/single_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/pages/boxing_detail_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart'; import 'package:pad_scanner/widgets/status_bar.dart';
// === 装箱模式 === export 'package:pad_scanner/pages/boxing/boxing_models.dart'
show BoxingMode, BoxingPageArguments;
enum BoxingMode {
singleCode, // 单码装箱
multiCode, // 多码凑箱
}
class BoxingPageArguments {
final BoxingMode initialMode;
final List<String> autoScanCodes;
const BoxingPageArguments({
required this.initialMode,
required this.autoScanCodes,
});
}
// === 页面阶段 ===
enum _Phase {
waiting, // 等待扫码
scanned, // 已扫码,显示信息
submitted, // 已提交成功
}
// === 主页面 === // === 主页面 ===
@@ -62,7 +47,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
BoxingMode _mode = BoxingMode.singleCode; BoxingMode _mode = BoxingMode.singleCode;
// 阶段 // 阶段
_Phase _phase = _Phase.waiting; BoxingPhase _phase = BoxingPhase.waiting;
// 当前总排号 // 当前总排号
String? _zongpaiNo; String? _zongpaiNo;
@@ -221,7 +206,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
} }
void _resetState() { void _resetState() {
_phase = _Phase.waiting; _phase = BoxingPhase.waiting;
_zongpaiNo = null; _zongpaiNo = null;
_paichanNo = null; _paichanNo = null;
_workOrderNo = null; _workOrderNo = null;
@@ -334,7 +319,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
_currentZongpaiBoxes = result.currentZongpaiBoxes; _currentZongpaiBoxes = result.currentZongpaiBoxes;
_existingBoxes = result.existingBoxes; _existingBoxes = result.existingBoxes;
_maxBoxNo = result.maxBoxNo; _maxBoxNo = result.maxBoxNo;
_phase = _Phase.scanned; _phase = BoxingPhase.scanned;
_isDuplicateBoxNo = false; _isDuplicateBoxNo = false;
_editingBox = null; _editingBox = null;
_editingAssignedItemId = null; _editingAssignedItemId = null;
@@ -469,98 +454,16 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
// === 跨排产号确认对话框 === // === 跨排产号确认对话框 ===
void _showCrossPaichaDialog() { Future<void> _showCrossPaichaDialog() async {
final currentPaicha = _lastScannedPaichanNo ?? '--'; final currentPaicha = _lastScannedPaichanNo ?? '--';
final newPaicha = _paichanNo ?? '--'; final newPaicha = _paichanNo ?? '--';
final dialogFocus = FocusNode(); final confirmed = await showCrossPaichanDialog(
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(
context: context, context: context,
barrierDismissible: false, currentPaicha: currentPaicha,
builder: (ctx) => StatefulBuilder( newPaicha: newPaicha,
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,
),
],
),
),
),
);
},
),
); );
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) { confirmed ? _confirmPaichanSwitch() : _cancelPaichanSwitch();
dialogFocus.requestFocus();
});
} }
void _confirmPaichanSwitch() { void _confirmPaichanSwitch() {
@@ -609,50 +512,20 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
_crossPaichaPending = false; _crossPaichaPending = false;
_zongpaiNo = null; _zongpaiNo = null;
_paichanNo = _lastScannedPaichanNo; _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() { bool _boxNoIsDuplicate() {
final boxNo = int.tryParse(_boxNoController.text);
if (boxNo == null) {
return false;
}
return switch (_mode) { return switch (_mode) {
BoxingMode.multiCode => false, BoxingMode.multiCode => false,
BoxingMode.singleCode => _currentZongpaiBoxes.any((b) { BoxingMode.singleCode => boxing_calculations.singleCodeBoxNoIsDuplicate(
if (b.boxNo != boxNo) return false; boxNoText: _boxNoController.text,
return _editingBox == null || b.boxItemId != _editingBox!.boxItemId; currentBoxes: _currentZongpaiBoxes,
}), editingBoxItemId: _editingBox?.boxItemId,
),
}; };
} }
@@ -663,33 +536,34 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
// === 提交 === // === 提交 ===
int get _packedQuantity { int get _packedQuantity {
return _currentZongpaiBoxes.fold<int>( return boxing_calculations.packedQuantity(_currentZongpaiBoxes);
0,
(sum, item) => sum + item.quantity,
);
} }
int get _remainingQuantity { int get _remainingQuantity {
final total = _erpQuantity ?? 0; return boxing_calculations.remainingQuantity(
return total - _packedQuantity; totalQuantity: _erpQuantity,
currentBoxes: _currentZongpaiBoxes,
);
} }
bool get _quantityTooHigh { bool get _quantityTooHigh {
if (_remainingQuantity <= 0) return false; return boxing_calculations.quantityTooHigh(
final qty = int.tryParse(_quantityController.text); remainingQuantity: _remainingQuantity,
return qty != null && qty > _remainingQuantity; quantityText: _quantityController.text,
);
} }
bool get _canSubmit { bool get _canSubmit {
if (_isSubmitting || _phase != _Phase.scanned) return false; return boxing_calculations.canSubmitBoxing(
if (_zongpaiNo == null) return false; isSubmitting: _isSubmitting,
final boxNo = int.tryParse(_boxNoController.text); phase: _phase,
final qty = int.tryParse(_quantityController.text); zongpaiNo: _zongpaiNo,
if (boxNo == null || boxNo <= 0 || qty == null || qty <= 0) return false; boxNoText: _boxNoController.text,
if (_remainingQuantity <= 0) return false; quantityText: _quantityController.text,
if (_quantityTooHigh) return false; remainingQuantity: _remainingQuantity,
if (_isDuplicateBoxNo) return false; quantityTooHigh: _quantityTooHigh,
return true; isDuplicateBoxNo: _isDuplicateBoxNo,
);
} }
void _submitFromKeyboard() { void _submitFromKeyboard() {
@@ -710,36 +584,16 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
} }
int? get _currentManyToOneBoxNo { int? get _currentManyToOneBoxNo {
final boxNo = int.tryParse(_boxNoController.text.trim()); return boxing_calculations.currentManyToOneBoxNo(_boxNoController.text);
if (boxNo == null || boxNo <= 0) return null;
return boxNo;
} }
List<ManyToOnePackedItem> get _visibleManyToOnePackedItems { List<ManyToOnePackedItem> get _visibleManyToOnePackedItems {
final boxNo = _currentManyToOneBoxNo; return boxing_calculations.visibleManyToOnePackedItems(
if (boxNo == null) return const []; boxNo: _currentManyToOneBoxNo,
final byItemId = <int, ManyToOnePackedItem>{}; existingBoxes: _existingBoxes,
for (final box in _existingBoxes.where((box) => box.boxNo == boxNo)) { packedItems: _manyToOnePackedItems,
for (final item in box.items) { paichanNo: _paichanNo,
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<bool> _submit() async { Future<bool> _submit() async {
@@ -839,7 +693,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
); );
} else { } else {
setState(() { setState(() {
_phase = _Phase.scanned; _phase = BoxingPhase.scanned;
_boxNoController.text = (_maxBoxNo + 1).toString(); _boxNoController.text = (_maxBoxNo + 1).toString();
_quantityController.text = remaining.toString(); _quantityController.text = remaining.toString();
_quantityController.selection = TextSelection( _quantityController.selection = TextSelection(
@@ -872,7 +726,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
), ),
); );
} }
_phase = _Phase.waiting; _phase = BoxingPhase.waiting;
_zongpaiNo = null; _zongpaiNo = null;
_editingPackedItemId = null; _editingPackedItemId = null;
_editingPackedQuantity = ''; _editingPackedQuantity = '';
@@ -925,62 +779,31 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
int boxNo, int boxNo,
int quantity, int quantity,
) { ) {
final nextBoxes = <BoxDetailData>[]; final result = box_mutations.replaceExistingBoxItem(
for (final box in _existingBoxes) { existingBoxes: _existingBoxes,
final items = box.items.where((item) { editing: editing,
return item.boxItemId != editing.boxItemId; boxNo: boxNo,
}).toList(); quantity: quantity,
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,
zongpaiNo: _zongpaiNo!, zongpaiNo: _zongpaiNo!,
workOrderNo: _workOrderNo, workOrderNo: _workOrderNo,
quantity: quantity,
totalQuantity: _erpQuantity, totalQuantity: _erpQuantity,
); );
if (targetIndex >= 0) { _existingBoxes = result.boxes;
final target = nextBoxes[targetIndex]; _maxBoxNo = result.maxBoxNo;
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<int>(
0,
(max, box) => box.boxNo > max ? box.boxNo : max,
);
} }
void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) { void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) {
final item = BoxItemData( final result = box_mutations.addExistingBoxItem(
existingBoxes: _existingBoxes,
boxNo: boxNo,
quantity: quantity,
boxItemId: boxItemId, boxItemId: boxItemId,
zongpaiNo: _zongpaiNo!, zongpaiNo: _zongpaiNo!,
workOrderNo: _workOrderNo, workOrderNo: _workOrderNo,
quantity: quantity,
totalQuantity: _erpQuantity, totalQuantity: _erpQuantity,
); );
final index = _existingBoxes.indexWhere((box) => box.boxNo == boxNo); _existingBoxes = result.boxes;
if (index < 0) { _maxBoxNo = result.maxBoxNo;
_existingBoxes = [
..._existingBoxes,
BoxDetailData(boxNo: boxNo, items: [item]),
]..sort((a, b) => a.boxNo.compareTo(b.boxNo));
return;
}
final next = List<BoxDetailData>.from(_existingBoxes);
final box = next[index];
next[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]);
_existingBoxes = next;
} }
void _finishManyToOneBox() { void _finishManyToOneBox() {
@@ -990,7 +813,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
_editingPackedQuantity = ''; _editingPackedQuantity = '';
_deletingPackedItemId = null; _deletingPackedItemId = null;
_zongpaiNo = null; _zongpaiNo = null;
_phase = _Phase.waiting; _phase = BoxingPhase.waiting;
_quantityController.clear(); _quantityController.clear();
_boxNoController.text = (_maxBoxNo + 1).toString(); _boxNoController.text = (_maxBoxNo + 1).toString();
_statusOverrideText = null; _statusOverrideText = null;
@@ -1016,9 +839,11 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
} }
int _maxAssignedQuantity(CurrentZongpaiBoxData item) { int _maxAssignedQuantity(CurrentZongpaiBoxData item) {
final total = _erpQuantity ?? 0; return boxing_calculations.maxAssignedQuantity(
final packedWithoutItem = _packedQuantity - item.quantity; totalQuantity: _erpQuantity,
return total - packedWithoutItem; packedQuantity: _packedQuantity,
itemQuantity: item.quantity,
);
} }
void _refreshSingleCodeNewInput({bool focusQuantity = true}) { void _refreshSingleCodeNewInput({bool focusQuantity = true}) {
@@ -1246,39 +1071,14 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
int boxNo, int boxNo,
int quantity, int quantity,
) { ) {
final nextBoxes = <BoxDetailData>[]; final result = box_mutations.replaceExistingPackedItem(
for (final box in _existingBoxes) { existingBoxes: _existingBoxes,
final items = box.items.where((boxItem) { item: item,
return boxItem.boxItemId != item.boxItemId; boxNo: boxNo,
}).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, 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<int>(
0,
(max, box) => box.boxNo > max ? box.boxNo : max,
); );
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
} }
Future<void> _deletePackedItem(ManyToOnePackedItem item) async { Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
@@ -1331,20 +1131,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
} }
void _removeExistingBoxItem(int boxItemId) { void _removeExistingBoxItem(int boxItemId) {
final nextBoxes = <BoxDetailData>[]; final result = box_mutations.removeExistingBoxItem(
for (final box in _existingBoxes) { existingBoxes: _existingBoxes,
final items = box.items boxItemId: boxItemId,
.where((item) => item.boxItemId != boxItemId)
.toList();
if (items.isNotEmpty) {
nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items));
}
}
_existingBoxes = nextBoxes;
_maxBoxNo = nextBoxes.fold<int>(
0,
(max, box) => box.boxNo > max ? box.boxNo : max,
); );
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
} }
// === Status bar management === // === Status bar management ===
@@ -1392,69 +1184,22 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
// === 状态文字 === // === 状态文字 ===
void _updateBaseStatus() { void _updateBaseStatus() {
if (_isAutoProcessing) { final status = boxingStatusFor(
_statusDot = StatusDotColor.orange; isAutoProcessing: _isAutoProcessing,
_statusText = '正在同步转运数据...'; isSubmitting: _isSubmitting,
return; isDuplicateBoxNo: _isDuplicateBoxNo,
} quantityTooHigh: _quantityTooHigh,
if (_isSubmitting) { isEditingAssigned: _editingAssignedItemId != null,
_statusDot = StatusDotColor.orange; isDeletingAssigned: _deletingAssignedItemId != null,
_statusText = '正在提交…'; hasPaichanSwitchNotice: _paichanSwitchNotice != null,
return; phase: _phase,
} isMultiCode: _mode == BoxingMode.multiCode,
if (_isDuplicateBoxNo && _phase == _Phase.scanned) { hasVisibleManyToOneItems: _visibleManyToOnePackedItems.isNotEmpty,
_statusDot = StatusDotColor.amber; remainingQuantity: _remainingQuantity,
_statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; boxNoText: _boxNoController.text,
return; );
} _statusDot = status.dot;
if (_quantityTooHigh && _phase == _Phase.scanned) { _statusText = status.text;
_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 = '请扫描下一个总排号';
}
}
} }
// === Build === // === Build ===
@@ -1487,12 +1232,25 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
), ),
body: Column( body: Column(
children: [ 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( Expanded(
child: _mode == BoxingMode.multiCode child: _mode == BoxingMode.multiCode
? MultiCodeBody( ? MultiCodeBody(
hasScan: _zongpaiNo != null && _phase == _Phase.scanned, hasScan:
_zongpaiNo != null && _phase == BoxingPhase.scanned,
zongpaiNo: _zongpaiNo, zongpaiNo: _zongpaiNo,
paichanNo: _paichanNo, paichanNo: _paichanNo,
workOrderNo: _workOrderNo, workOrderNo: _workOrderNo,
@@ -1532,9 +1290,10 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
setState(() => _deletingPackedItemId = null), setState(() => _deletingPackedItemId = null),
) )
: SingleCodeBody( : SingleCodeBody(
isWaiting: _phase == _Phase.waiting && _zongpaiNo == null, isWaiting:
_phase == BoxingPhase.waiting && _zongpaiNo == null,
isFinished: isFinished:
_phase == _Phase.scanned && _phase == BoxingPhase.scanned &&
_zongpaiNo != null && _zongpaiNo != null &&
_remainingQuantity <= 0, _remainingQuantity <= 0,
zongpaiNo: _zongpaiNo, zongpaiNo: _zongpaiNo,
@@ -1586,139 +1345,4 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
), ),
); );
} }
List<Widget> _buildNoticeBanners() {
final banners = <Widget>[];
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,
),
),
],
),
);
}
} }

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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,
'请扫描下一个总排号',
);
});
});
}