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 {
final int boxItemId;
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/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<String> 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<BoxingPage> with WidgetsBindingObserver {
BoxingMode _mode = BoxingMode.singleCode;
// 阶段
_Phase _phase = _Phase.waiting;
BoxingPhase _phase = BoxingPhase.waiting;
// 当前总排号
String? _zongpaiNo;
@@ -221,7 +206,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
}
void _resetState() {
_phase = _Phase.waiting;
_phase = BoxingPhase.waiting;
_zongpaiNo = null;
_paichanNo = null;
_workOrderNo = null;
@@ -334,7 +319,7 @@ class _BoxingPageState extends State<BoxingPage> 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<BoxingPage> with WidgetsBindingObserver {
// === 跨排产号确认对话框 ===
void _showCrossPaichaDialog() {
Future<void> _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<BoxingPage> 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<BoxingPage> with WidgetsBindingObserver {
// === 提交 ===
int get _packedQuantity {
return _currentZongpaiBoxes.fold<int>(
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<BoxingPage> 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<ManyToOnePackedItem> get _visibleManyToOnePackedItems {
final boxNo = _currentManyToOneBoxNo;
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 _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<bool> _submit() async {
@@ -839,7 +693,7 @@ class _BoxingPageState extends State<BoxingPage> 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<BoxingPage> with WidgetsBindingObserver {
),
);
}
_phase = _Phase.waiting;
_phase = BoxingPhase.waiting;
_zongpaiNo = null;
_editingPackedItemId = null;
_editingPackedQuantity = '';
@@ -925,62 +779,31 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
int boxNo,
int quantity,
) {
final nextBoxes = <BoxDetailData>[];
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<int>(
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<BoxDetailData>.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<BoxingPage> 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<BoxingPage> 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<BoxingPage> with WidgetsBindingObserver {
int boxNo,
int quantity,
) {
final nextBoxes = <BoxDetailData>[];
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<int>(
0,
(max, box) => box.boxNo > max ? box.boxNo : max,
);
_existingBoxes = result.boxes;
_maxBoxNo = result.maxBoxNo;
}
Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
@@ -1331,20 +1131,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
}
void _removeExistingBoxItem(int boxItemId) {
final nextBoxes = <BoxDetailData>[];
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<int>(
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<BoxingPage> 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<BoxingPage> 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<BoxingPage> 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<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,
),
),
],
),
);
}
}