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:
135
lib/pages/boxing/boxing_box_mutations.dart
Normal file
135
lib/pages/boxing/boxing_box_mutations.dart
Normal 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));
|
||||||
|
}
|
||||||
101
lib/pages/boxing/boxing_calculations.dart
Normal file
101
lib/pages/boxing/boxing_calculations.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
97
lib/pages/boxing/boxing_status_presenter.dart
Normal file
97
lib/pages/boxing/boxing_status_presenter.dart
Normal 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 ? '请扫描下一个总排号' : '装箱成功,可继续扫码',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/pages/boxing/dialogs/cross_paichan_dialog.dart
Normal file
126
lib/pages/boxing/dialogs/cross_paichan_dialog.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
lib/pages/boxing/widgets/boxing_notice_banners.dart
Normal file
178
lib/pages/boxing/widgets/boxing_notice_banners.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
),
|
confirmed ? _confirmPaichanSwitch() : _cancelPaichanSwitch();
|
||||||
);
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
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,37 +584,17 @@ 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) {
|
|
||||||
final boxItemId = item.boxItemId;
|
|
||||||
if (boxItemId == null) continue;
|
|
||||||
byItemId[boxItemId] = ManyToOnePackedItem(
|
|
||||||
boxItemId: boxItemId,
|
|
||||||
zongpaiNo: item.zongpaiNo,
|
|
||||||
paichanNo: _paichanNo,
|
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 {
|
||||||
if (!_canSubmit) return false;
|
if (!_canSubmit) return false;
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
88
test/pages/boxing/boxing_box_mutations_test.dart
Normal file
88
test/pages/boxing/boxing_box_mutations_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
123
test/pages/boxing/boxing_calculations_test.dart
Normal file
123
test/pages/boxing/boxing_calculations_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
81
test/pages/boxing/boxing_status_presenter_test.dart
Normal file
81
test/pages/boxing/boxing_status_presenter_test.dart
Normal 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,
|
||||||
|
'请扫描下一个总排号',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user