Compare commits
21 Commits
928e7296b1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ed3bb9ec | ||
|
|
f6bfd8cb10 | ||
|
|
9db50af1e6 | ||
|
|
4e8be6a684 | ||
|
|
5fc5c7de2b | ||
|
|
4891fe4dfd | ||
|
|
305083bf3f | ||
|
|
a601858225 | ||
|
|
9ab7a51dfb | ||
|
|
950effc164 | ||
|
|
e87d29e696 | ||
|
|
b9f17d5d49 | ||
|
|
9eb1bde645 | ||
|
|
c2d9f5fd56 | ||
|
|
f2b2f41929 | ||
|
|
0ae4017eee | ||
|
|
b17e0e7e62 | ||
|
|
3fd47f4d7b | ||
|
|
eb833c830d | ||
|
|
b1a76287d6 | ||
|
|
f587194267 |
35
CLAUDE.md
35
CLAUDE.md
@@ -6,6 +6,28 @@
|
||||
- All new feature development must be done on a `dev` branch, created from `master`
|
||||
- After verification, merge `dev` back into `master`
|
||||
|
||||
## Pre-Commit Checks
|
||||
|
||||
Before committing Flutter app changes, run these checks:
|
||||
|
||||
```bash
|
||||
dart format --set-exit-if-changed .
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
|
||||
|
||||
### Handling Check Failures
|
||||
|
||||
- If a check fails because of the current change, fix the issue before committing.
|
||||
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
|
||||
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
|
||||
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
|
||||
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
|
||||
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
|
||||
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
|
||||
|
||||
## App Installation Rules
|
||||
|
||||
**Always use `adb install -r` to install the app. Never use `flutter install`.**
|
||||
@@ -21,3 +43,16 @@ flutter build apk --release
|
||||
# 2. Install (in-place upgrade, preserves config)
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
## Flutter Test Rules
|
||||
|
||||
Before running `flutter test`, temporarily remove proxy environment variables for the current shell. The Flutter tester uses a localhost WebSocket, and proxy settings can break that connection with `Invalid WebSocket upgrade request`.
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY=''
|
||||
$env:HTTPS_PROXY=''
|
||||
$env:NO_PROXY='localhost,127.0.0.1,::1'
|
||||
flutter test
|
||||
```
|
||||
|
||||
150
lib/pages/boxing/boxing_api_actions.dart
Normal file
150
lib/pages/boxing/boxing_api_actions.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
|
||||
enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend }
|
||||
|
||||
class BoxingActionResult<T> {
|
||||
final bool success;
|
||||
final T? data;
|
||||
final BoxingActionErrorKind? errorKind;
|
||||
final String? errorMessage;
|
||||
final int? boxNo;
|
||||
|
||||
const BoxingActionResult._({
|
||||
required this.success,
|
||||
this.data,
|
||||
this.errorKind,
|
||||
this.errorMessage,
|
||||
this.boxNo,
|
||||
});
|
||||
|
||||
const BoxingActionResult.ok(T data) : this._(success: true, data: data);
|
||||
|
||||
const BoxingActionResult.error({
|
||||
required BoxingActionErrorKind kind,
|
||||
required String message,
|
||||
int? boxNo,
|
||||
}) : this._(
|
||||
success: false,
|
||||
errorKind: kind,
|
||||
errorMessage: message,
|
||||
boxNo: boxNo,
|
||||
);
|
||||
}
|
||||
|
||||
typedef ApiUrlLoader = Future<String?> Function();
|
||||
|
||||
class BoxingApiActions {
|
||||
static const networkErrorMessage = '网络异常,请检查网络连接';
|
||||
static const missingApiUrlMessage = '未配置 API 地址,请前往设置';
|
||||
|
||||
final ApiService _apiService;
|
||||
final ApiUrlLoader _loadApiUrl;
|
||||
|
||||
BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl})
|
||||
: _apiService = apiService,
|
||||
_loadApiUrl =
|
||||
loadApiUrl ?? (() => AppConfigService().getString('api_url'));
|
||||
|
||||
Future<BoxingActionResult<BoxInfoResult>> fetchBoxInfo(String zongpai) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.fetchBoxInfo(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpai,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '查询失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> saveBoxRecord({
|
||||
required String zongpaiNo,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.saveBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '提交失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxSaveResult>> updateBoxRecord({
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.updateBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
return _saveResult(result, fallbackMessage: '修改失败');
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<BoxDeleteResult>> deleteBoxRecord({
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
|
||||
final result = await _apiService.deleteBoxRecord(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '删除失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _requireBaseUrl() async {
|
||||
final baseUrl = await _loadApiUrl() ?? '';
|
||||
if (baseUrl.isEmpty) return null;
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
BoxingActionResult<T> _missingApiUrl<T>() {
|
||||
return const BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.missingApiUrl,
|
||||
message: missingApiUrlMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionResult<BoxSaveResult> _saveResult(
|
||||
BoxSaveResult result, {
|
||||
required String fallbackMessage,
|
||||
}) {
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
if (result.isDuplicate) {
|
||||
return BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.duplicate,
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
boxNo: result.boxNo,
|
||||
);
|
||||
}
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
BoxingActionErrorKind _kindForMessage(String? message) {
|
||||
if (message == networkErrorMessage) return BoxingActionErrorKind.network;
|
||||
return BoxingActionErrorKind.backend;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
56
lib/pages/boxing/boxing_models.dart
Normal file
56
lib/pages/boxing/boxing_models.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
|
||||
const ManyToOnePackedItem({
|
||||
required this.boxItemId,
|
||||
required this.zongpaiNo,
|
||||
this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
});
|
||||
|
||||
ManyToOnePackedItem copyWith({
|
||||
int? boxNo,
|
||||
int? quantity,
|
||||
int? totalQuantity,
|
||||
}) {
|
||||
return ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: zongpaiNo,
|
||||
paichanNo: paichanNo,
|
||||
workOrderNo: workOrderNo,
|
||||
boxNo: boxNo ?? this.boxNo,
|
||||
quantity: quantity ?? this.quantity,
|
||||
totalQuantity: totalQuantity ?? this.totalQuantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
lib/pages/boxing/parts/boxing_box_mutation_part.dart
Normal file
59
lib/pages/boxing/parts/boxing_box_mutation_part.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingBoxMutationPart on _BoxingPageState {
|
||||
void _replaceExistingBoxItem(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
editing: editing,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) {
|
||||
final result = box_mutations.addExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
workOrderNo: _workOrderNo,
|
||||
totalQuantity: _erpQuantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _replaceExistingPackedItem(
|
||||
ManyToOnePackedItem item,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
final result = box_mutations.replaceExistingPackedItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
item: item,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
|
||||
void _removeExistingBoxItem(int boxItemId) {
|
||||
final result = box_mutations.removeExistingBoxItem(
|
||||
existingBoxes: _existingBoxes,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
_existingBoxes = result.boxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
}
|
||||
}
|
||||
112
lib/pages/boxing/parts/boxing_multi_ops_part.dart
Normal file
112
lib/pages/boxing/parts/boxing_multi_ops_part.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingMultiOpsPart on _BoxingPageState {
|
||||
void _finishManyToOneBox() {
|
||||
setState(() {
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_zongpaiNo = null;
|
||||
_phase = BoxingPhase.waiting;
|
||||
_quantityController.clear();
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _startEditPackedItem(ManyToOnePackedItem item) {
|
||||
setState(() {
|
||||
_editingPackedItemId = item.boxItemId;
|
||||
_editingPackedQuantity = item.quantity.toString();
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditPackedItem() {
|
||||
setState(() {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _savePackedItem(ManyToOnePackedItem item) async {
|
||||
final quantity = int.tryParse(_editingPackedQuantity);
|
||||
final boxNo = int.tryParse(_boxNoController.text.trim());
|
||||
if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效箱号和数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
final index = _manyToOnePackedItems.indexWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
_manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith(
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}
|
||||
this._replaceExistingPackedItem(item, boxNo, quantity);
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePackedItem(ManyToOnePackedItem item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_manyToOnePackedItems.removeWhere(
|
||||
(packed) => packed.boxItemId == item.boxItemId,
|
||||
);
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingPackedItemId == item.boxItemId) {
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
}
|
||||
_deletingPackedItemId = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
271
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
271
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingScanPart on _BoxingPageState {
|
||||
void _onScan(ScanResult result) {
|
||||
if (_isAutoProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_crossPaichaPending) {
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
return;
|
||||
}
|
||||
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
|
||||
if (parsed.type != CodeType.zongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
this._showStatusOverride(
|
||||
'无效码,请重新扫描',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final zongpai = parsed.value;
|
||||
|
||||
// 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。
|
||||
this._queryBoxInfo(zongpai);
|
||||
}
|
||||
|
||||
Future<bool> _queryBoxInfo(String zongpai) async {
|
||||
final action = await _apiActions.fetchBoxInfo(zongpai);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
if (!action.success) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
|
||||
_ => FeedbackEvent.scanInvalid,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? '查询失败',
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = action.data!;
|
||||
// 判断该总牌号是否已全部装箱完毕
|
||||
final packedQuantity = result.currentZongpaiBoxes.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
final totalQuantity = result.quantity ?? 0;
|
||||
final alreadyCompleted =
|
||||
totalQuantity > 0 && packedQuantity >= totalQuantity;
|
||||
|
||||
_feedbackService.trigger(
|
||||
alreadyCompleted
|
||||
? FeedbackEvent.alreadyCompleted
|
||||
: FeedbackEvent.scanValid,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
_workOrderNo = result.workOrderNo;
|
||||
_erpQuantity = result.quantity;
|
||||
_currentZongpaiBoxes = result.currentZongpaiBoxes;
|
||||
_existingBoxes = result.existingBoxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
_phase = BoxingPhase.scanned;
|
||||
_isDuplicateBoxNo = false;
|
||||
_editingBox = null;
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
_deletingAssignedItemId = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
|
||||
// 根据模式自动填充
|
||||
this._applyAutoFill();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = true;
|
||||
_autoWarnings.clear();
|
||||
_statusOverrideText = '正在同步转运数据...';
|
||||
_statusOverrideDot = StatusDotColor.orange;
|
||||
});
|
||||
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
final code = codes.first;
|
||||
final ok = await this._queryBoxInfo(code);
|
||||
if (!ok && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
} else {
|
||||
for (final code in codes) {
|
||||
if (!mounted) return;
|
||||
final queried = await this._queryBoxInfo(code);
|
||||
if (!queried) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!_canSubmit) {
|
||||
if (mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
final saved = await this._submit();
|
||||
if (!saved && mounted) {
|
||||
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isAutoProcessing = false;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
_requestFocus();
|
||||
}
|
||||
|
||||
void _applyAutoFill() {
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_boxNoController.clear();
|
||||
_quantityController.clear();
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.singleCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
case BoxingMode.multiCode:
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: _lastScannedPaichanNo,
|
||||
currentPaichanNo: _paichanNo,
|
||||
currentBoxNoText: _boxNoController.text,
|
||||
maxBoxNo: _maxBoxNo,
|
||||
);
|
||||
if (decision.isSwitched) {
|
||||
_crossPaichaPending = true;
|
||||
_feedbackService.trigger(FeedbackEvent.alreadyCompleted);
|
||||
this._showCrossPaichaDialog();
|
||||
return;
|
||||
} else {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
final boxNoToApply = decision.boxNoToApply;
|
||||
if (boxNoToApply != null) {
|
||||
_boxNoController.text = boxNoToApply.toString();
|
||||
}
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty &&
|
||||
_currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog() async {
|
||||
final currentPaicha = _lastScannedPaichanNo ?? '--';
|
||||
final newPaicha = _paichanNo ?? '--';
|
||||
final confirmed = await showCrossPaichanDialog(
|
||||
context: context,
|
||||
currentPaicha: currentPaicha,
|
||||
newPaicha: newPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
confirmed ? this._confirmPaichanSwitch() : this._cancelPaichanSwitch();
|
||||
}
|
||||
|
||||
void _confirmPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_manyToOnePackedItems.clear();
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
|
||||
// Continue with remaining quantity flow
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
_quantityController.clear();
|
||||
if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) {
|
||||
_completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo;
|
||||
}
|
||||
setState(() {
|
||||
_statusOverrideText = '该总排号已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = _boxNoIsDuplicate();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _mode == BoxingMode.multiCode) {
|
||||
_quantityFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelPaichanSwitch() {
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNo = null;
|
||||
_paichanNo = _lastScannedPaichanNo;
|
||||
_phase = BoxingPhase.waiting;
|
||||
});
|
||||
}
|
||||
}
|
||||
133
lib/pages/boxing/parts/boxing_single_ops_part.dart
Normal file
133
lib/pages/boxing/parts/boxing_single_ops_part.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSingleOpsPart on _BoxingPageState {
|
||||
void _startEditAssigned(CurrentZongpaiBoxData item) {
|
||||
setState(() {
|
||||
_editingAssignedItemId = item.boxItemId;
|
||||
_editingAssignedQuantity = item.quantity.toString();
|
||||
_deletingAssignedItemId = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEditAssigned() {
|
||||
setState(() {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
});
|
||||
}
|
||||
|
||||
int _maxAssignedQuantity(CurrentZongpaiBoxData item) {
|
||||
return boxing_calculations.maxAssignedQuantity(
|
||||
totalQuantity: _erpQuantity,
|
||||
packedQuantity: _packedQuantity,
|
||||
itemQuantity: item.quantity,
|
||||
);
|
||||
}
|
||||
|
||||
void _refreshSingleCodeNewInput({bool focusQuantity = true}) {
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
if (focusQuantity) {
|
||||
_focusQuantityInput();
|
||||
}
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
}
|
||||
|
||||
Future<void> _saveAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
final quantity = int.tryParse(_editingAssignedQuantity);
|
||||
if (quantity == null || quantity <= 0) {
|
||||
this._showStatusOverride(
|
||||
'请输入有效数量',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (quantity > this._maxAssignedQuantity(item)) {
|
||||
this._showStatusOverride(
|
||||
'超出可装数量上限',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.updateBoxRecord(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: item.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
if (action.success) {
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((box) {
|
||||
if (box.boxItemId != item.boxItemId) return box;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: box.boxItemId,
|
||||
boxNo: box.boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(item, item.boxNo, quantity);
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
this._refreshSingleCodeNewInput();
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '修改失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAssignedItem(CurrentZongpaiBoxData item) async {
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
if (action.success) {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes
|
||||
.where((box) => box.boxItemId != item.boxItemId)
|
||||
.toList();
|
||||
this._removeExistingBoxItem(item.boxItemId);
|
||||
if (_editingAssignedItemId == item.boxItemId) {
|
||||
_editingAssignedItemId = null;
|
||||
_editingAssignedQuantity = '';
|
||||
}
|
||||
_deletingAssignedItemId = null;
|
||||
this._refreshSingleCodeNewInput();
|
||||
}
|
||||
});
|
||||
|
||||
if (action.success) {
|
||||
this._showStatusOverride(
|
||||
'删除成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
this._showActionError(action, fallback: '删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/pages/boxing/parts/boxing_status_part.dart
Normal file
86
lib/pages/boxing/parts/boxing_status_part.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingStatusPart on _BoxingPageState {
|
||||
StatusDotColor _dotForActionError(BoxingActionErrorKind? kind) {
|
||||
return switch (kind) {
|
||||
BoxingActionErrorKind.network => StatusDotColor.yellow,
|
||||
BoxingActionErrorKind.duplicate => StatusDotColor.amber,
|
||||
_ => StatusDotColor.red,
|
||||
};
|
||||
}
|
||||
|
||||
void _showActionError<T>(
|
||||
BoxingActionResult<T> action, {
|
||||
required String fallback,
|
||||
}) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.duplicate => FeedbackEvent.duplicateBoxNo,
|
||||
_ => FeedbackEvent.submitFailure,
|
||||
});
|
||||
this._showStatusOverride(
|
||||
action.errorMessage ?? fallback,
|
||||
this._dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _jumpToCompletedBox() {
|
||||
final boxNo = _completedJumpBoxNo;
|
||||
if (boxNo == null) return;
|
||||
setState(() {
|
||||
_boxNoController.text = boxNo.toString();
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDetail() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingDetailPage(
|
||||
paichanNo: _paichanNo ?? '',
|
||||
existingBoxes: _existingBoxes,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
161
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
161
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member, unnecessary_this
|
||||
|
||||
part of '../../boxing_page.dart';
|
||||
|
||||
extension _BoxingSubmitPart on _BoxingPageState {
|
||||
Future<bool> _submit() async {
|
||||
if (!_canSubmit) return false;
|
||||
|
||||
final boxNo = int.parse(_boxNoController.text);
|
||||
final quantity = int.parse(_quantityController.text);
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final editing = _editingBox;
|
||||
final action = editing == null
|
||||
? await _apiActions.saveBoxRecord(
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
)
|
||||
: await _apiActions.updateBoxRecord(
|
||||
boxItemId: editing.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (action.success) {
|
||||
if (editing == null) {
|
||||
this._onSubmitSuccess(boxNo, quantity, action.data!.boxItemId);
|
||||
} else {
|
||||
this._onUpdateSuccess(editing, boxNo, quantity);
|
||||
}
|
||||
return true;
|
||||
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||
this._showStatusOverride(
|
||||
'该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入',
|
||||
StatusDotColor.amber,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
this._showActionError(action, fallback: '提交失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
if (_mode == BoxingMode.singleCode && boxItemId != null) {
|
||||
_currentZongpaiBoxes = List.from(_currentZongpaiBoxes)
|
||||
..add(
|
||||
CurrentZongpaiBoxData(
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
this._addExistingBoxItem(boxNo, quantity, boxItemId);
|
||||
_maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo;
|
||||
});
|
||||
|
||||
switch (_mode) {
|
||||
case BoxingMode.singleCode:
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining <= 0) {
|
||||
this._showStatusOverride(
|
||||
'装箱成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_phase = BoxingPhase.scanned;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
_focusQuantityInput();
|
||||
this._showStatusOverride(
|
||||
'请继续完成剩余数量装箱',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
|
||||
case BoxingMode.multiCode:
|
||||
_lastScannedPaichanNo = _paichanNo;
|
||||
setState(() {
|
||||
if (boxItemId != null) {
|
||||
_manyToOnePackedItems.add(
|
||||
ManyToOnePackedItem(
|
||||
boxItemId: boxItemId,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
paichanNo: _paichanNo,
|
||||
workOrderNo: _workOrderNo,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
totalQuantity: _erpQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
_phase = BoxingPhase.waiting;
|
||||
_zongpaiNo = null;
|
||||
_editingPackedItemId = null;
|
||||
_editingPackedQuantity = '';
|
||||
_deletingPackedItemId = null;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'装箱成功,请继续扫码',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onUpdateSuccess(
|
||||
CurrentZongpaiBoxData editing,
|
||||
int boxNo,
|
||||
int quantity,
|
||||
) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
|
||||
setState(() {
|
||||
_currentZongpaiBoxes = _currentZongpaiBoxes.map((item) {
|
||||
if (item.boxItemId != editing.boxItemId) return item;
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: item.boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
}).toList();
|
||||
this._replaceExistingBoxItem(editing, boxNo, quantity);
|
||||
_editingBox = null;
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
final remaining = _remainingQuantity;
|
||||
if (remaining > 0) {
|
||||
_quantityController.text = remaining.toString();
|
||||
} else {
|
||||
_quantityController.clear();
|
||||
}
|
||||
_isDuplicateBoxNo = false;
|
||||
});
|
||||
this._showStatusOverride(
|
||||
'修改成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
lib/pages/boxing/widgets/boxing_shared_widgets.dart
Normal file
161
lib/pages/boxing/widgets/boxing_shared_widgets.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const boxingHeaderStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
|
||||
class BoxingDetailButton extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const BoxingDetailButton({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foreground = enabled ? colorScheme.primary : Colors.grey.shade400;
|
||||
final background = enabled ? Colors.blue.shade50 : Colors.grey.shade100;
|
||||
final border = enabled ? colorScheme.primary : Colors.grey.shade300;
|
||||
|
||||
return SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
icon: Icon(Icons.receipt_long, size: 15, color: foreground),
|
||||
label: Text(
|
||||
'详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: background,
|
||||
side: BorderSide(color: border),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BoxingModePill extends StatelessWidget {
|
||||
final bool isSingle;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const BoxingModePill({
|
||||
super.key,
|
||||
required this.isSingle,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bgColor = isSingle ? Colors.blue.shade700 : Colors.orange.shade700;
|
||||
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: bgColor,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.swap_horiz, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'P2',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.88),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompactIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const CompactIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
color: color,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration compactInputDecoration({
|
||||
required bool disabled,
|
||||
required Color borderColor,
|
||||
}) {
|
||||
final disabledBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
);
|
||||
final activeBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: borderColor, width: 1.5),
|
||||
);
|
||||
return InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: activeBorder,
|
||||
enabledBorder: activeBorder,
|
||||
focusedBorder: activeBorder,
|
||||
disabledBorder: disabledBorder,
|
||||
filled: disabled,
|
||||
fillColor: Colors.grey.shade100,
|
||||
);
|
||||
}
|
||||
606
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
606
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
@@ -0,0 +1,606 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/multi_packed_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class MultiCodeBody extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final List<ManyToOnePackedItem> visibleItems;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingPackedItemId;
|
||||
final String editingPackedQuantity;
|
||||
final int? deletingPackedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
final ValueChanged<String> onEditingPackedQuantityChanged;
|
||||
final ValueChanged<ManyToOnePackedItem> onSavePackedItem;
|
||||
final VoidCallback onCancelEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartEditPackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
|
||||
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
|
||||
final VoidCallback onCancelDeletePackedItem;
|
||||
|
||||
const MultiCodeBody({
|
||||
super.key,
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.visibleItems,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingPackedItemId,
|
||||
required this.editingPackedQuantity,
|
||||
required this.deletingPackedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
required this.onEditingPackedQuantityChanged,
|
||||
required this.onSavePackedItem,
|
||||
required this.onCancelEditPackedItem,
|
||||
required this.onStartEditPackedItem,
|
||||
required this.onStartDeletePackedItem,
|
||||
required this.onDeletePackedItem,
|
||||
required this.onCancelDeletePackedItem,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_MultiHeaderBar(
|
||||
paichanNo: paichanNo,
|
||||
maxBoxNo: maxBoxNo,
|
||||
existingBoxes: existingBoxes,
|
||||
boxNoController: boxNoController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
const _MultiListHeader(),
|
||||
Expanded(
|
||||
child: visibleItems.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'尚未装入任何物料',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
return MultiPackedRow(
|
||||
item: item,
|
||||
editing: editingPackedItemId == item.boxItemId,
|
||||
deleting: deletingPackedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingPackedQuantity,
|
||||
onEditingQuantityChanged: onEditingPackedQuantityChanged,
|
||||
onSave: () => onSavePackedItem(item),
|
||||
onCancelEdit: onCancelEditPackedItem,
|
||||
onStartEdit: () => onStartEditPackedItem(item),
|
||||
onStartDelete: () => onStartDeletePackedItem(item),
|
||||
onDelete: () => onDeletePackedItem(item),
|
||||
onCancelDelete: onCancelDeletePackedItem,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_MultiBottomArea(
|
||||
hasScan: hasScan,
|
||||
hasItems: visibleItems.isNotEmpty,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiHeaderBar extends StatelessWidget {
|
||||
final String? paichanNo;
|
||||
final int maxBoxNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final bool isSubmitting;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _MultiHeaderBar({
|
||||
required this.paichanNo,
|
||||
required this.maxBoxNo,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDetail = existingBoxes.isNotEmpty;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 32,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade400),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Colors.blue, width: 1.5),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
paichanNo ?? '--',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'已有 ${existingBoxes.length} 箱 · 最大箱号 $maxBoxNo',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: hasDetail,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiListHeader extends StatelessWidget {
|
||||
const _MultiListHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiBottomArea extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiBottomArea({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_MultiScanBar(
|
||||
hasScan: hasScan,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
),
|
||||
_MultiSubmitRow(
|
||||
hasScan: hasScan,
|
||||
hasItems: hasItems,
|
||||
quantityController: quantityController,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
onFinishBox: onFinishBox,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiScanBar extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
|
||||
const _MultiScanBar({
|
||||
required this.hasScan,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!hasScan) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
color: Colors.grey.shade50,
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 15),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${zongpaiNo ?? ""} · ${workOrderNo ?? "--"} · ${erpQuantity ?? "--"}件',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MultiSubmitRow extends StatelessWidget {
|
||||
final bool hasScan;
|
||||
final bool hasItems;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final VoidCallback onFinishBox;
|
||||
|
||||
const _MultiSubmitRow({
|
||||
required this.hasScan,
|
||||
required this.hasItems,
|
||||
required this.quantityController,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onFinishBox,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: hasScan ? Colors.black54 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: hasScan && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: quantityTooHigh ? Colors.red : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
filled: !hasScan,
|
||||
fillColor: Colors.grey.shade100,
|
||||
),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade200,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: OutlinedButton(
|
||||
onPressed: hasItems ? onFinishBox : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: hasItems
|
||||
? Colors.grey.shade600
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'完成本箱',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasItems
|
||||
? Colors.grey.shade200
|
||||
: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'P3',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasItems
|
||||
? Colors.black54
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/pages/boxing/widgets/multi_packed_row.dart
Normal file
239
lib/pages/boxing/widgets/multi_packed_row.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
|
||||
class MultiPackedRow extends StatelessWidget {
|
||||
final ManyToOnePackedItem item;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const MultiPackedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalText = item.totalQuantity?.toString() ?? '--';
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 26,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 26,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} / $totalText',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除?',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red.shade100,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
231
lib/pages/boxing/widgets/single_assigned_row.dart
Normal file
231
lib/pages/boxing/widgets/single_assigned_row.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleAssignedRow extends StatelessWidget {
|
||||
final CurrentZongpaiBoxData item;
|
||||
final String? workOrderNo;
|
||||
final bool editing;
|
||||
final bool deleting;
|
||||
final bool isSubmitting;
|
||||
final String editingQuantity;
|
||||
final ValueChanged<String> onEditingQuantityChanged;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancelEdit;
|
||||
final VoidCallback onStartEdit;
|
||||
final VoidCallback onStartDelete;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onCancelDelete;
|
||||
|
||||
const SingleAssignedRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.workOrderNo,
|
||||
required this.editing,
|
||||
required this.deleting,
|
||||
required this.isSubmitting,
|
||||
required this.editingQuantity,
|
||||
required this.onEditingQuantityChanged,
|
||||
required this.onSave,
|
||||
required this.onCancelEdit,
|
||||
required this.onStartEdit,
|
||||
required this.onStartDelete,
|
||||
required this.onDelete,
|
||||
required this.onCancelDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barColor = editing
|
||||
? Colors.amber
|
||||
: (deleting ? Colors.red : Colors.green);
|
||||
|
||||
final Color bgColor;
|
||||
if (editing) {
|
||||
bgColor = Colors.yellow.shade50;
|
||||
} else if (deleting) {
|
||||
bgColor = Colors.red.shade50;
|
||||
} else {
|
||||
bgColor = Colors.transparent;
|
||||
}
|
||||
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'${item.boxNo} 号箱',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: editing
|
||||
? SizedBox(
|
||||
width: 52,
|
||||
height: 28,
|
||||
child: TextField(
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
controller:
|
||||
TextEditingController(text: editingQuantity)
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: editingQuantity.length,
|
||||
),
|
||||
onChanged: onEditingQuantityChanged,
|
||||
onSubmitted: (_) => onSave(),
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.amber,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'${item.quantity} 件',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: deleting ? Colors.red : Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (editing) ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.check,
|
||||
color: Colors.green,
|
||||
onTap: isSubmitting ? null : onSave,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.close,
|
||||
color: Colors.grey,
|
||||
onTap: isSubmitting ? null : onCancelEdit,
|
||||
),
|
||||
] else ...[
|
||||
CompactIconButton(
|
||||
icon: Icons.edit,
|
||||
color: Colors.grey.shade700,
|
||||
onTap: isSubmitting ? null : onStartEdit,
|
||||
),
|
||||
CompactIconButton(
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
onTap: isSubmitting ? null : onStartDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (deleting)
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 15, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.red.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'确认删除 ${item.boxNo} 号箱记录?',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton(
|
||||
onPressed: isSubmitting ? null : onCancelDelete,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 2,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
578
lib/pages/boxing/widgets/single_code_body.dart
Normal file
578
lib/pages/boxing/widgets/single_code_body.dart
Normal file
@@ -0,0 +1,578 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/single_assigned_row.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class SingleCodeBody extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final String? zongpaiNo;
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
final int maxBoxNo;
|
||||
final List<CurrentZongpaiBoxData> assignedItems;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final VoidCallback onOpenDetail;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
|
||||
const SingleCodeBody({
|
||||
super.key,
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.zongpaiNo,
|
||||
required this.paichanNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
required this.maxBoxNo,
|
||||
required this.assignedItems,
|
||||
required this.existingBoxes,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onOpenDetail,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_SingleInfoBar(
|
||||
isWaiting: isWaiting,
|
||||
paichanNo: paichanNo,
|
||||
existingBoxes: existingBoxes,
|
||||
maxBoxNo: maxBoxNo,
|
||||
colorScheme: colorScheme,
|
||||
onOpenDetail: onOpenDetail,
|
||||
),
|
||||
_SingleScanBar(
|
||||
isWaiting: isWaiting,
|
||||
zongpaiNo: zongpaiNo,
|
||||
workOrderNo: workOrderNo,
|
||||
erpQuantity: erpQuantity,
|
||||
packedQuantity: packedQuantity,
|
||||
),
|
||||
_SingleAssignedList(
|
||||
isWaiting: isWaiting,
|
||||
isFinished: isFinished,
|
||||
items: assignedItems,
|
||||
workOrderNo: workOrderNo,
|
||||
isSubmitting: isSubmitting,
|
||||
editingAssignedItemId: editingAssignedItemId,
|
||||
editingAssignedQuantity: editingAssignedQuantity,
|
||||
deletingAssignedItemId: deletingAssignedItemId,
|
||||
onEditingAssignedQuantityChanged: onEditingAssignedQuantityChanged,
|
||||
onSaveAssignedItem: onSaveAssignedItem,
|
||||
onCancelEditAssigned: onCancelEditAssigned,
|
||||
onStartEditAssigned: onStartEditAssigned,
|
||||
onStartDeleteAssigned: onStartDeleteAssigned,
|
||||
onDeleteAssignedItem: onDeleteAssignedItem,
|
||||
onCancelDeleteAssigned: onCancelDeleteAssigned,
|
||||
),
|
||||
_SingleBottomArea(
|
||||
disabled: isWaiting || isFinished,
|
||||
boxNoController: boxNoController,
|
||||
quantityController: quantityController,
|
||||
boxNoFocusNode: boxNoFocusNode,
|
||||
quantityFocusNode: quantityFocusNode,
|
||||
isSubmitting: isSubmitting,
|
||||
isDuplicateBoxNo: isDuplicateBoxNo,
|
||||
quantityTooHigh: quantityTooHigh,
|
||||
canSubmit: canSubmit,
|
||||
onCheckDuplicateBoxNo: onCheckDuplicateBoxNo,
|
||||
onSubmitFromKeyboard: onSubmitFromKeyboard,
|
||||
onSubmit: onSubmit,
|
||||
onQuantityChanged: onQuantityChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleScanBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int? erpQuantity;
|
||||
final int packedQuantity;
|
||||
|
||||
const _SingleScanBar({
|
||||
required this.isWaiting,
|
||||
required this.zongpaiNo,
|
||||
required this.workOrderNo,
|
||||
required this.erpQuantity,
|
||||
required this.packedQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isWaiting) {
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code_scanner, color: Colors.grey, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请扫描执行卡二维码',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 35,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.green.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
zongpaiNo ?? '',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${workOrderNo ?? "--"} / ${erpQuantity ?? 0}件 / 已装$packedQuantity',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleInfoBar extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final String? paichanNo;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onOpenDetail;
|
||||
|
||||
const _SingleInfoBar({
|
||||
required this.isWaiting,
|
||||
required this.paichanNo,
|
||||
required this.existingBoxes,
|
||||
required this.maxBoxNo,
|
||||
required this.colorScheme,
|
||||
required this.onOpenDetail,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detailEnabled = !isWaiting && existingBoxes.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
isWaiting ? '排产号 --' : (paichanNo ?? '--'),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isWaiting
|
||||
? '已有 -- 箱'
|
||||
: '已有 ${existingBoxes.length} 箱',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isWaiting
|
||||
? '最大箱号 --'
|
||||
: '最大箱号 $maxBoxNo',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isWaiting
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
BoxingDetailButton(
|
||||
enabled: detailEnabled,
|
||||
colorScheme: colorScheme,
|
||||
onPressed: onOpenDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedList extends StatelessWidget {
|
||||
final bool isWaiting;
|
||||
final bool isFinished;
|
||||
final List<CurrentZongpaiBoxData> items;
|
||||
final String? workOrderNo;
|
||||
final bool isSubmitting;
|
||||
final int? editingAssignedItemId;
|
||||
final String editingAssignedQuantity;
|
||||
final int? deletingAssignedItemId;
|
||||
final ValueChanged<String> onEditingAssignedQuantityChanged;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onSaveAssignedItem;
|
||||
final VoidCallback onCancelEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartEditAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
|
||||
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
|
||||
final VoidCallback onCancelDeleteAssigned;
|
||||
|
||||
const _SingleAssignedList({
|
||||
required this.isWaiting,
|
||||
required this.isFinished,
|
||||
required this.items,
|
||||
required this.workOrderNo,
|
||||
required this.isSubmitting,
|
||||
required this.editingAssignedItemId,
|
||||
required this.editingAssignedQuantity,
|
||||
required this.deletingAssignedItemId,
|
||||
required this.onEditingAssignedQuantityChanged,
|
||||
required this.onSaveAssignedItem,
|
||||
required this.onCancelEditAssigned,
|
||||
required this.onStartEditAssigned,
|
||||
required this.onStartDeleteAssigned,
|
||||
required this.onDeleteAssignedItem,
|
||||
required this.onCancelDeleteAssigned,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const _SingleAssignedHeader(),
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
isFinished
|
||||
? '该总排号已全部装箱完毕'
|
||||
: (isWaiting ? '扫码后显示已分配箱号记录' : '本次扫码尚未分配箱号'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isFinished
|
||||
? Colors.green.shade700
|
||||
: Colors.grey.shade500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return SingleAssignedRow(
|
||||
item: item,
|
||||
workOrderNo: workOrderNo,
|
||||
editing: editingAssignedItemId == item.boxItemId,
|
||||
deleting: deletingAssignedItemId == item.boxItemId,
|
||||
isSubmitting: isSubmitting,
|
||||
editingQuantity: editingAssignedQuantity,
|
||||
onEditingQuantityChanged:
|
||||
onEditingAssignedQuantityChanged,
|
||||
onSave: () => onSaveAssignedItem(item),
|
||||
onCancelEdit: onCancelEditAssigned,
|
||||
onStartEdit: () => onStartEditAssigned(item),
|
||||
onStartDelete: () => onStartDeleteAssigned(item),
|
||||
onDelete: () => onDeleteAssignedItem(item),
|
||||
onCancelDelete: onCancelDeleteAssigned,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleAssignedHeader extends StatelessWidget {
|
||||
const _SingleAssignedHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'箱号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 23,
|
||||
child: Text(
|
||||
'数量',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 54,
|
||||
child: Text(
|
||||
'操作',
|
||||
textAlign: TextAlign.center,
|
||||
style: boxingHeaderStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomArea extends StatelessWidget {
|
||||
final bool disabled;
|
||||
final TextEditingController boxNoController;
|
||||
final TextEditingController quantityController;
|
||||
final FocusNode boxNoFocusNode;
|
||||
final FocusNode quantityFocusNode;
|
||||
final bool isSubmitting;
|
||||
final bool isDuplicateBoxNo;
|
||||
final bool quantityTooHigh;
|
||||
final bool canSubmit;
|
||||
final VoidCallback onCheckDuplicateBoxNo;
|
||||
final VoidCallback onSubmitFromKeyboard;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onQuantityChanged;
|
||||
|
||||
const _SingleBottomArea({
|
||||
required this.disabled,
|
||||
required this.boxNoController,
|
||||
required this.quantityController,
|
||||
required this.boxNoFocusNode,
|
||||
required this.quantityFocusNode,
|
||||
required this.isSubmitting,
|
||||
required this.isDuplicateBoxNo,
|
||||
required this.quantityTooHigh,
|
||||
required this.canSubmit,
|
||||
required this.onCheckDuplicateBoxNo,
|
||||
required this.onSubmitFromKeyboard,
|
||||
required this.onSubmit,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'箱号',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: boxNoController,
|
||||
focusNode: boxNoFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onCheckDuplicateBoxNo(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: isDuplicateBoxNo
|
||||
? Colors.amber
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'数量',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? Colors.grey.shade400 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 34,
|
||||
child: TextField(
|
||||
controller: quantityController,
|
||||
focusNode: quantityFocusNode,
|
||||
enabled: !disabled && !isSubmitting,
|
||||
keyboardType: TextInputType.none,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (_) => onQuantityChanged(),
|
||||
onSubmitted: (_) => onSubmitFromKeyboard(),
|
||||
decoration: compactInputDecoration(
|
||||
disabled: disabled || isSubmitting,
|
||||
borderColor: quantityTooHigh
|
||||
? Colors.red
|
||||
: Colors.blue.shade700,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: canSubmit ? onSubmit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canSubmit
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
foregroundColor: canSubmit
|
||||
? Colors.white
|
||||
: Colors.grey.shade600,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确认',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
357
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
357
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
@@ -0,0 +1,357 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationDialogsPart on _RegistrationPageState {
|
||||
void _showLocationSwitchDialog(
|
||||
String newLocationCode,
|
||||
CodeType newLocationType,
|
||||
) {
|
||||
final count = _zongpaiNos.length;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => 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: Text(
|
||||
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = newLocationCode;
|
||||
_locationType = newLocationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('确认切换'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('重复上架', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('总排号:$zongpaiNo'),
|
||||
const SizedBox(height: 4),
|
||||
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
|
||||
const SizedBox(height: 4),
|
||||
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationConflictDialog({
|
||||
required List<PaichaOverviewItem> onShelfItems,
|
||||
required List<PaichaOverviewItem> transferredItems,
|
||||
List<PaichaOverviewItem> tempStoredItems = const [],
|
||||
}) {
|
||||
final contentParts = <Widget>[];
|
||||
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(
|
||||
Text(
|
||||
'重复上架',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in onShelfItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('已登记货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (transferredItems.isNotEmpty) {
|
||||
if (onShelfItems.isNotEmpty) {
|
||||
contentParts.add(const Divider());
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
}
|
||||
contentParts.add(
|
||||
Text(
|
||||
'货物已转运',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in transferredItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('转运货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempStoredItems.isNotEmpty) {
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
contentParts.add(const Divider());
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
}
|
||||
contentParts.add(
|
||||
Text(
|
||||
'暂存中',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF7B1FA2),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final item in tempStoredItems) {
|
||||
contentParts.addAll([
|
||||
Text('总排号:${item.zongpaiNo}'),
|
||||
Text('暂存货位:${item.locationCode ?? "未知"}'),
|
||||
const SizedBox(height: 6),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
contentParts.add(const SizedBox(height: 4));
|
||||
contentParts.add(
|
||||
const Text(
|
||||
'请核查实物,确认是否操作错误。',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.red.shade50,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('操作冲突', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: contentParts,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_feedbackService.stopAlert();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
|
||||
final currentPaicha = _overview?.paichaNo ?? '--';
|
||||
final shouldSwitch = await _confirmCrossPaichaSwitch(
|
||||
newZongpaiNo: newZongpaiNo,
|
||||
currentPaicha: currentPaicha,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (shouldSwitch) {
|
||||
_switchToNewPaicha(newZongpaiNo);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _confirmCrossPaichaSwitch({
|
||||
required String newZongpaiNo,
|
||||
required String currentPaicha,
|
||||
}) async {
|
||||
final dialogFocus = FocusNode();
|
||||
var dismissed = false;
|
||||
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
|
||||
|
||||
void dismissAsNo() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
|
||||
void confirmSwitch() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
final dialogFuture = showDialog<bool>(
|
||||
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(
|
||||
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha),'
|
||||
'是否切换到新的排产号?\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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
dialogFocus.requestFocus();
|
||||
});
|
||||
final shouldSwitch = await dialogFuture;
|
||||
return shouldSwitch ?? false;
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchToNewPaicha(String newZongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
|
||||
setState(() {
|
||||
_crossPaichaPending = false;
|
||||
_zongpaiNos.clear();
|
||||
_zongpaiNos.add(newZongpaiNo);
|
||||
_overview = null;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
_loadOverview(newZongpaiNo);
|
||||
}
|
||||
}
|
||||
21
lib/pages/registration/parts/registration_mode_part.dart
Normal file
21
lib/pages/registration/parts/registration_mode_part.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationModePart on _RegistrationPageState {
|
||||
void _cycleMode() {
|
||||
_feedbackService.trigger(FeedbackEvent.modeSwitch);
|
||||
setState(() {
|
||||
switch (_mode) {
|
||||
case RegistrationMode.singleCode:
|
||||
_mode = RegistrationMode.multiCode;
|
||||
case RegistrationMode.multiCode:
|
||||
_mode = RegistrationMode.singleCode;
|
||||
// 切换到单码时,只保留最后一个总排号
|
||||
if (_zongpaiNos.length > 1) {
|
||||
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
88
lib/pages/registration/parts/registration_overview_part.dart
Normal file
88
lib/pages/registration/parts/registration_overview_part.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationOverviewPart on _RegistrationPageState {
|
||||
/// Find all scanned items that are already on shelf.
|
||||
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
|
||||
return registration_calculations.findOnShelfItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find all scanned items that are already transferred.
|
||||
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
|
||||
return registration_calculations.findTransferredItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find all scanned items that are in temp storage.
|
||||
List<PaichaOverviewItem> _findTempStoredItemsInScanned() {
|
||||
return registration_calculations.findTempStoredItemsInScanned(
|
||||
overview: _overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _baseUrl() async {
|
||||
final configService = AppConfigService();
|
||||
final baseUrl = await configService.getString('api_url') ?? '';
|
||||
if (baseUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
Future<void> _loadOverview(String zongpaiNo, {bool force = false}) async {
|
||||
if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) {
|
||||
return;
|
||||
}
|
||||
final requestId = ++_overviewRequestId;
|
||||
setState(() {
|
||||
_overviewZongpaiNo = zongpaiNo;
|
||||
_overviewLoading = true;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
});
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
if (baseUrl == null) {
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
_overviewError = '未配置 API 地址,请前往设置';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!mounted || requestId != _overviewRequestId) return;
|
||||
|
||||
setState(() {
|
||||
_overviewLoading = false;
|
||||
if (result.success) {
|
||||
_overview = result;
|
||||
_overviewNotFound = false;
|
||||
_overviewError = null;
|
||||
} else if (result.notFound) {
|
||||
_overview = null;
|
||||
_overviewNotFound = true;
|
||||
_overviewError = null;
|
||||
} else {
|
||||
_overviewError = result.errorMessage ?? '加载失败,点击重试';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshCurrentOverview() async {
|
||||
final zongpaiNo = _overviewZongpaiNo;
|
||||
if (zongpaiNo == null) return;
|
||||
await _loadOverview(zongpaiNo, force: true);
|
||||
}
|
||||
}
|
||||
129
lib/pages/registration/parts/registration_scan_part.dart
Normal file
129
lib/pages/registration/parts/registration_scan_part.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationScanPart on _RegistrationPageState {
|
||||
void _startScanListening() {
|
||||
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
|
||||
}
|
||||
|
||||
Future<void> _stopScanListening() async {
|
||||
final subscription = _scanSubscription;
|
||||
_scanSubscription = null;
|
||||
await subscription?.cancel();
|
||||
}
|
||||
|
||||
void _restartScanListening() {
|
||||
_scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
_startScanListening();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return;
|
||||
if (_isLockToggleKey(event)) {
|
||||
_cycleMode();
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLockToggleKey(KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.gameButtonRight1;
|
||||
}
|
||||
|
||||
void _onScan(ScanResult result) {
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
case CodeType.locationTempStorage:
|
||||
_handleLocationScan(parsed.value, parsed.type);
|
||||
case CodeType.invalid:
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
'无效码:${result.barcode}',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLocationScan(String locationCode, CodeType locationType) {
|
||||
// In singleCode mode or no existing data or no previous shelf, switch directly
|
||||
if (_mode == RegistrationMode.singleCode ||
|
||||
_zongpaiNos.isEmpty ||
|
||||
_locationCode == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
_locationCode = locationCode;
|
||||
_locationType = locationType;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, confirm before switching
|
||||
_showLocationSwitchDialog(locationCode, locationType);
|
||||
}
|
||||
|
||||
void _handleZongpaiScan(String zongpaiNo) {
|
||||
// If cross-paicha dialog is showing, just vibrate and discard
|
||||
if (_crossPaichaPending) {
|
||||
_triggerDoubleVibration();
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiCode mode with existing data, check for cross-paicha scan
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_zongpaiNos.isNotEmpty &&
|
||||
_overview?.success == true &&
|
||||
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
|
||||
_crossPaichaPending = true;
|
||||
_triggerDoubleVibration();
|
||||
_showCrossPaichaDialog(zongpaiNo);
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||
setState(() {
|
||||
if (_mode == RegistrationMode.multiCode) {
|
||||
if (!_zongpaiNos.contains(zongpaiNo)) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
if (_zongpaiNos.isEmpty) {
|
||||
_zongpaiNos.add(zongpaiNo);
|
||||
} else {
|
||||
_zongpaiNos[0] = zongpaiNo;
|
||||
}
|
||||
}
|
||||
_clearStatusOverride();
|
||||
});
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
Vibration.vibrate(pattern: [0, 200, 100, 200]);
|
||||
}
|
||||
|
||||
void _removeZongpai(String zongpaiNo) {
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
});
|
||||
}
|
||||
}
|
||||
58
lib/pages/registration/parts/registration_status_part.dart
Normal file
58
lib/pages/registration/parts/registration_status_part.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationStatusPart on _RegistrationPageState {
|
||||
void _clearStatusOverride() {
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
}
|
||||
|
||||
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
|
||||
setState(() {
|
||||
_statusOverrideText = text;
|
||||
_statusOverrideDot = dot;
|
||||
});
|
||||
Future.delayed(duration, () {
|
||||
if (mounted) setState(() => _clearStatusOverride());
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBaseStatus() {
|
||||
if (_isSubmitting) {
|
||||
_statusDot = StatusDotColor.orange;
|
||||
_statusText = '正在提交…';
|
||||
return;
|
||||
}
|
||||
if (_mode == RegistrationMode.multiCode &&
|
||||
_locationCode != null &&
|
||||
_zongpaiNos.isEmpty) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '多码上架模式,请扫描下一张执行卡';
|
||||
return;
|
||||
}
|
||||
final hasZongpai = _zongpaiNos.isNotEmpty;
|
||||
final hasLocation = _locationCode != null;
|
||||
if (hasZongpai && hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请确认信息并提交';
|
||||
} else if (hasZongpai) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描目标货位号';
|
||||
} else if (hasLocation) {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '请扫描执行卡';
|
||||
} else {
|
||||
_statusDot = StatusDotColor.blue;
|
||||
_statusText = '等待扫描总排号或货位号…';
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||
|
||||
bool get _isTempStorageTarget =>
|
||||
isTempStorageTarget(_locationType, _locationCode);
|
||||
}
|
||||
336
lib/pages/registration/parts/registration_submit_part.dart
Normal file
336
lib/pages/registration/parts/registration_submit_part.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'未配置 API 地址,请前往设置',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
// For transit target: block if any scanned item is already transferred
|
||||
// (on_shelf items are allowed — transit registration acts as off-shelf)
|
||||
if (_isTransitTarget) {
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: [],
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For temp storage target: block if any scanned item is transferred or temp_stored
|
||||
// (on_shelf items are allowed — temp storage acts as shelf change)
|
||||
if (_isTempStorageTarget) {
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
final tempStoredItems = _findTempStoredItemsInScanned();
|
||||
if (transferredItems.isNotEmpty || tempStoredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: [],
|
||||
transferredItems: transferredItems,
|
||||
tempStoredItems: tempStoredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
|
||||
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
batchPaichaMismatchMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Pre-check: block entire batch if any item already has a location binding
|
||||
if (!_isTransitTarget && !_isTempStorageTarget) {
|
||||
final onShelfItems = _findOnShelfItemsInScanned();
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
setState(() => _isSubmitting = false);
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showLocationConflictDialog(
|
||||
onShelfItems: onShelfItems,
|
||||
transferredItems: transferredItems,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _submitBatch(baseUrl);
|
||||
} else {
|
||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
|
||||
final paichaByCode = <String, String>{};
|
||||
final cachedOverview = _overview;
|
||||
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
|
||||
for (final item in cachedOverview.items) {
|
||||
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
for (final zongpaiNo in _zongpaiNos) {
|
||||
if (paichaByCode.containsKey(zongpaiNo)) continue;
|
||||
final result = await _apiService.fetchPaichaOverview(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!result.success || result.paichaNo == null) {
|
||||
return false;
|
||||
}
|
||||
for (final item in result.items) {
|
||||
paichaByCode[item.zongpaiNo] = result.paichaNo!;
|
||||
}
|
||||
}
|
||||
|
||||
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (result.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
if (_isTransitTarget) {
|
||||
setState(() => _isSubmitting = false);
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.singleCode,
|
||||
codes: [zongpaiNo],
|
||||
codesToClear: [zongpaiNo],
|
||||
clearLocation: _mode == RegistrationMode.singleCode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
final msg = result.isOffShelfSuccess
|
||||
? '下架成功'
|
||||
: _isTempStorageTarget
|
||||
? '变更货架成功'
|
||||
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||
);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '提交失败',
|
||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitBatch(String baseUrl) async {
|
||||
int successCount = 0;
|
||||
int offShelfCount = 0;
|
||||
final failed = <String>[];
|
||||
final toRemove = <String>[];
|
||||
final transitCodes = <String>[];
|
||||
|
||||
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
if (result.isOffShelfSuccess) {
|
||||
offShelfCount++;
|
||||
}
|
||||
toRemove.add(zongpaiNo);
|
||||
if (_isTransitTarget) {
|
||||
transitCodes.add(zongpaiNo);
|
||||
}
|
||||
} else {
|
||||
failed.add(zongpaiNo);
|
||||
if (result.isDuplicate) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
return;
|
||||
}
|
||||
if (result.isAlreadyOffShelf) {
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: false,
|
||||
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||
await _navigateToBoxing(
|
||||
mode: BoxingMode.multiCode,
|
||||
codes: transitCodes,
|
||||
codesToClear: toRemove,
|
||||
clearLocation: failed.isEmpty,
|
||||
returnMessage: failed.isEmpty
|
||||
? null
|
||||
: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
final msg = offShelfCount == successCount
|
||||
? '批量下架成功($successCount 条)'
|
||||
: '批量上架成功($successCount 条)';
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
'成功 $successCount 条,失败 ${failed.length} 条',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _navigateToBoxing({
|
||||
required BoxingMode mode,
|
||||
required List<String> codes,
|
||||
required List<String> codesToClear,
|
||||
required bool clearLocation,
|
||||
String? returnMessage,
|
||||
}) async {
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await _stopScanListening();
|
||||
await navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BoxingPage(
|
||||
arguments: BoxingPageArguments(
|
||||
initialMode: mode,
|
||||
autoScanCodes: codes,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
if (!mounted) return;
|
||||
_restartScanListening();
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
|
||||
if (clearLocation) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
await _refreshCurrentOverview();
|
||||
if (!mounted) return;
|
||||
if (returnMessage != null) {
|
||||
_showStatusOverride(
|
||||
returnMessage,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
193
lib/pages/registration/registration_calculations.dart
Normal file
193
lib/pages/registration/registration_calculations.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class RegistrationOverviewStats {
|
||||
final int totalCount;
|
||||
final int shelvedCount;
|
||||
final int transferredCount;
|
||||
final int tempStoredCount;
|
||||
|
||||
const RegistrationOverviewStats({
|
||||
required this.totalCount,
|
||||
required this.shelvedCount,
|
||||
required this.transferredCount,
|
||||
required this.tempStoredCount,
|
||||
});
|
||||
}
|
||||
|
||||
class RegistrationOverviewSections {
|
||||
final List<PaichaOverviewItem> scanned;
|
||||
final List<PaichaOverviewItem> unscanned;
|
||||
|
||||
const RegistrationOverviewSections({
|
||||
required this.scanned,
|
||||
required this.unscanned,
|
||||
});
|
||||
|
||||
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
|
||||
}
|
||||
|
||||
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
||||
if (overview?.success != true) {
|
||||
return const RegistrationOverviewStats(
|
||||
totalCount: 0,
|
||||
shelvedCount: 0,
|
||||
transferredCount: 0,
|
||||
tempStoredCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
var shelvedCount = 0;
|
||||
var transferredCount = 0;
|
||||
var tempStoredCount = 0;
|
||||
for (final item in overview!.items) {
|
||||
switch (item.status) {
|
||||
case 'on_shelf':
|
||||
shelvedCount++;
|
||||
case 'transferred':
|
||||
transferredCount++;
|
||||
case 'temp_stored':
|
||||
tempStoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return RegistrationOverviewStats(
|
||||
totalCount: overview.totalCount,
|
||||
shelvedCount: shelvedCount,
|
||||
transferredCount: transferredCount,
|
||||
tempStoredCount: tempStoredCount,
|
||||
);
|
||||
}
|
||||
|
||||
RegistrationOverviewSections splitOverviewItems({
|
||||
required PaichaOverviewResult overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
final scanned = <PaichaOverviewItem>[];
|
||||
final unscanned = <PaichaOverviewItem>[];
|
||||
for (final item in overview.items) {
|
||||
if (scannedSet.contains(item.zongpaiNo)) {
|
||||
scanned.add(item);
|
||||
} else {
|
||||
unscanned.add(item);
|
||||
}
|
||||
}
|
||||
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findOnShelfItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'on_shelf',
|
||||
);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findTransferredItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'transferred',
|
||||
);
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> findTempStoredItemsInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
return _findScannedItemsByStatus(
|
||||
overview: overview,
|
||||
zongpaiNos: zongpaiNos,
|
||||
status: 'temp_stored',
|
||||
);
|
||||
}
|
||||
|
||||
bool hasAnyLocatedInScanned({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
}) {
|
||||
if (overview?.success != true) return false;
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items.any(
|
||||
(item) =>
|
||||
scannedSet.contains(item.zongpaiNo) &&
|
||||
(item.status == 'on_shelf' ||
|
||||
item.status == 'transferred' ||
|
||||
item.status == 'temp_stored'),
|
||||
);
|
||||
}
|
||||
|
||||
Color registrationBarColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
bool isTempStorageTarget = false,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: isTempStorageTarget
|
||||
? (status == 'transferred' || status == 'temp_stored')
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red : const Color(0xFF43A047);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFF2196F3);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFF7B1FA2);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFF9800);
|
||||
default:
|
||||
return Colors.grey.shade400;
|
||||
}
|
||||
}
|
||||
|
||||
Color registrationRowBgColor({
|
||||
required String status,
|
||||
required bool isScanned,
|
||||
required bool isTransitTarget,
|
||||
bool isTempStorageTarget = false,
|
||||
}) {
|
||||
if (isScanned) {
|
||||
final isConflict = isTransitTarget
|
||||
? status == 'transferred'
|
||||
: isTempStorageTarget
|
||||
? (status == 'transferred' || status == 'temp_stored')
|
||||
: (status == 'on_shelf' || status == 'transferred');
|
||||
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFFE3F2FD);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFFF3E5F5);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFFF3E0);
|
||||
default:
|
||||
return const Color(0xFFF5F5F5);
|
||||
}
|
||||
}
|
||||
|
||||
List<PaichaOverviewItem> _findScannedItemsByStatus({
|
||||
required PaichaOverviewResult? overview,
|
||||
required Iterable<String> zongpaiNos,
|
||||
required String status,
|
||||
}) {
|
||||
if (overview?.success != true) return [];
|
||||
final scannedSet = zongpaiNos.toSet();
|
||||
return overview!.items
|
||||
.where(
|
||||
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
463
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
463
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||
String _locationLabel(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return '转运区域';
|
||||
if (type == CodeType.locationTempStorage) return '暂存货架';
|
||||
return '普通货架';
|
||||
}
|
||||
|
||||
Color _locationLabelColor(CodeType? type) {
|
||||
if (type == CodeType.locationTransit) return Colors.orange;
|
||||
if (type == CodeType.locationTempStorage) return const Color(0xFF7B1FA2);
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
Widget _buildModePill() {
|
||||
return BoxingModePill(
|
||||
isSingle: _mode == RegistrationMode.singleCode,
|
||||
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
|
||||
enabled: !_isSubmitting,
|
||||
onTap: _cycleMode,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationChip() {
|
||||
final color = _locationLabelColor(_locationType);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_locationLabel(_locationType),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Paicha Header ===
|
||||
|
||||
Widget _buildPaichaHeader() {
|
||||
final overview = _overview;
|
||||
final hasData = overview?.success == true;
|
||||
final hasLoc = _locationCode != null;
|
||||
final stats = registration_calculations.overviewStats(overview);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hasData ? (overview!.paichaNo ?? '--') : '--',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: hasData ? Colors.black87 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (hasLoc)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_locationCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
_buildLocationChip(),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatTag(
|
||||
'共 ${stats.totalCount}',
|
||||
const Color(0xFFE3F2FD),
|
||||
const Color(0xFF1565C0),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'上架 ${stats.shelvedCount}',
|
||||
const Color(0xFFE8F5E9),
|
||||
const Color(0xFF2E7D32),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'转运 ${stats.transferredCount}',
|
||||
const Color(0xFFFFF3E0),
|
||||
const Color(0xFFE65100),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'暂存 ${stats.tempStoredCount}',
|
||||
const Color(0xFFF3E5F5),
|
||||
const Color(0xFF7B1FA2),
|
||||
hasData,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
||||
margin: const EdgeInsets.only(left: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: hasData ? bg : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: hasData ? fg : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Table Head ===
|
||||
|
||||
Widget _buildTableHead() {
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
'总排号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
'工令号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
'货位号',
|
||||
textAlign: TextAlign.center,
|
||||
style: _headerStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === List Body ===
|
||||
|
||||
Widget _buildListBody() {
|
||||
if (_overviewZongpaiNo == null) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
if (_overviewLoading && _overview == null) {
|
||||
return _buildListMessage('正在加载排产号上架情况…');
|
||||
}
|
||||
if (_overviewNotFound) {
|
||||
return _buildListMessage('暂无排产信息');
|
||||
}
|
||||
if (_overviewError != null && _overview == null) {
|
||||
return _buildListError(_overviewError!);
|
||||
}
|
||||
final overview = _overview;
|
||||
if (overview == null || !overview.success) {
|
||||
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
|
||||
}
|
||||
|
||||
final sections = registration_calculations.splitOverviewItems(
|
||||
overview: overview,
|
||||
zongpaiNos: _zongpaiNos,
|
||||
);
|
||||
final scanned = sections.scanned;
|
||||
final unscanned = sections.unscanned;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (_overviewError != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
color: Colors.red.shade50,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_overviewError!,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount:
|
||||
scanned.length +
|
||||
(sections.hasDivider ? 1 : 0) +
|
||||
unscanned.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < scanned.length) {
|
||||
return _buildListRow(scanned[index], isScanned: true);
|
||||
}
|
||||
if (index == scanned.length && sections.hasDivider) {
|
||||
return _buildSectionDivider(unscanned.length);
|
||||
}
|
||||
final unscannedIdx =
|
||||
index - scanned.length - (sections.hasDivider ? 1 : 0);
|
||||
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionDivider(int count) {
|
||||
return Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'以下 $count 项未操作',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider(height: 0)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
|
||||
final rowStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
);
|
||||
|
||||
final barColor = registration_calculations.registrationBarColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isTempStorageTarget: _isTempStorageTarget,
|
||||
);
|
||||
final bgColor = registration_calculations.registrationRowBgColor(
|
||||
status: item.status,
|
||||
isScanned: isScanned,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isTempStorageTarget: _isTempStorageTarget,
|
||||
);
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
left: BorderSide(color: barColor, width: 3),
|
||||
bottom: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 22,
|
||||
child: Text(
|
||||
item.zongpaiNo,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 18,
|
||||
child: Text(
|
||||
item.workOrderNo ?? '--',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 12,
|
||||
child: Text(
|
||||
item.quantity.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 28,
|
||||
child: Text(
|
||||
item.locationCode ?? '\u2014',
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: rowStyle,
|
||||
),
|
||||
),
|
||||
if (isScanned && _mode == RegistrationMode.multiCode) ...[
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 16),
|
||||
color: Colors.red,
|
||||
onPressed: () => _removeZongpai(item.zongpaiNo),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListMessage(String text) {
|
||||
return Center(
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListError(String text) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextButton(
|
||||
onPressed: _overviewZongpaiNo == null
|
||||
? null
|
||||
: () => _loadOverview(_overviewZongpaiNo!, force: true),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// === Bottom Bar ===
|
||||
|
||||
Widget _buildBottomBar(ColorScheme colorScheme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: registrationSubmitColor(
|
||||
canSubmit: _canSubmit,
|
||||
isTransitTarget: _isTransitTarget,
|
||||
primaryColor: colorScheme.primary,
|
||||
),
|
||||
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: _isTransitTarget,
|
||||
isMultiCode: _mode == RegistrationMode.multiCode,
|
||||
zongpaiCount: _zongpaiNos.length,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,16 +67,31 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final configService = AppConfigService();
|
||||
final config = await configService.loadConfig();
|
||||
config['api_url'] = url;
|
||||
if (_successPath != null) config['sound_success'] = _successPath;
|
||||
else config.remove('sound_success');
|
||||
if (_failurePath != null) config['sound_failure'] = _failurePath;
|
||||
else config.remove('sound_failure');
|
||||
if (_beepPath != null) config['sound_beep'] = _beepPath;
|
||||
else config.remove('sound_beep');
|
||||
if (_errorPath != null) config['sound_error'] = _errorPath;
|
||||
else config.remove('sound_error');
|
||||
if (_alertPath != null) config['sound_alert'] = _alertPath;
|
||||
else config.remove('sound_alert');
|
||||
if (_successPath != null) {
|
||||
config['sound_success'] = _successPath;
|
||||
} else {
|
||||
config.remove('sound_success');
|
||||
}
|
||||
if (_failurePath != null) {
|
||||
config['sound_failure'] = _failurePath;
|
||||
} else {
|
||||
config.remove('sound_failure');
|
||||
}
|
||||
if (_beepPath != null) {
|
||||
config['sound_beep'] = _beepPath;
|
||||
} else {
|
||||
config.remove('sound_beep');
|
||||
}
|
||||
if (_errorPath != null) {
|
||||
config['sound_error'] = _errorPath;
|
||||
} else {
|
||||
config.remove('sound_error');
|
||||
}
|
||||
if (_alertPath != null) {
|
||||
config['sound_alert'] = _alertPath;
|
||||
} else {
|
||||
config.remove('sound_alert');
|
||||
}
|
||||
await configService.saveConfig(config);
|
||||
setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// lib/services/code_parser.dart
|
||||
class CodeParser {
|
||||
static final _zongpaiRegex = RegExp(
|
||||
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
|
||||
);
|
||||
static final _locationRegex = RegExp(
|
||||
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
|
||||
);
|
||||
static final _transitRegex = RegExp(
|
||||
r'^TRANS-',
|
||||
);
|
||||
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
|
||||
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
||||
static final _transitRegex = RegExp(r'^TRANS-');
|
||||
|
||||
static ParseResult parse(String code) {
|
||||
final trimmed = code.trim();
|
||||
@@ -23,16 +17,30 @@ class CodeParser {
|
||||
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
|
||||
}
|
||||
if (_locationRegex.hasMatch(normalized)) {
|
||||
if (normalized.startsWith('B')) {
|
||||
return ParseResult(
|
||||
type: CodeType.locationTempStorage,
|
||||
value: normalized,
|
||||
);
|
||||
}
|
||||
return ParseResult(type: CodeType.locationNormal, value: normalized);
|
||||
}
|
||||
return ParseResult(type: CodeType.invalid, value: code);
|
||||
}
|
||||
|
||||
static bool isLocation(CodeType type) =>
|
||||
type == CodeType.locationNormal || type == CodeType.locationTransit;
|
||||
type == CodeType.locationNormal ||
|
||||
type == CodeType.locationTransit ||
|
||||
type == CodeType.locationTempStorage;
|
||||
}
|
||||
|
||||
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
|
||||
enum CodeType {
|
||||
zongpaiNo,
|
||||
locationNormal,
|
||||
locationTransit,
|
||||
locationTempStorage,
|
||||
invalid,
|
||||
}
|
||||
|
||||
class ParseResult {
|
||||
final CodeType type;
|
||||
|
||||
@@ -8,15 +8,22 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
||||
(locationCode?.startsWith('TRANS-') ?? false);
|
||||
}
|
||||
|
||||
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
|
||||
return locationType == CodeType.locationTempStorage ||
|
||||
(locationCode?.startsWith('B') ?? false);
|
||||
}
|
||||
|
||||
String registrationSubmitLabel({
|
||||
required bool isTransitTarget,
|
||||
required bool isLocked,
|
||||
required bool isMultiCode,
|
||||
required int zongpaiCount,
|
||||
}) {
|
||||
if (!isTransitTarget) {
|
||||
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||
return isMultiCode && zongpaiCount > 1
|
||||
? '批量上架($zongpaiCount 条)'
|
||||
: '确 认 上 架';
|
||||
}
|
||||
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||
}
|
||||
|
||||
Color registrationSubmitColor({
|
||||
|
||||
159
test/pages/boxing/boxing_api_actions_test.dart
Normal file
159
test/pages/boxing/boxing_api_actions_test.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('BoxingApiActions', () {
|
||||
test('returns missingApiUrl when API URL is empty', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
fail('ApiService should not be called without a base URL');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => '',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.missingApiUrl);
|
||||
expect(result.errorMessage, BoxingApiActions.missingApiUrlMessage);
|
||||
});
|
||||
|
||||
test('fetchBoxInfo passes through successful data', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((request) async {
|
||||
expect(request.url.path, '/CargoTrace/box/info');
|
||||
expect(request.url.queryParameters['zongpai_no'], '26B1');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'paichan_no': 'W00009',
|
||||
'work_order_no': 'WO001',
|
||||
'quantity': 8,
|
||||
'current_zongpai_boxes': [
|
||||
{'box_item_id': 1, 'box_no': 2, 'quantity': 3},
|
||||
],
|
||||
'existing_boxes': [],
|
||||
'max_box_no': 2,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.fetchBoxInfo('26B1');
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.data?.paichanNo, 'W00009');
|
||||
expect(result.data?.currentZongpaiBoxes.single.boxNo, 2);
|
||||
});
|
||||
|
||||
test('saveBoxRecord maps duplicate and preserves box number', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_BOX_ITEM',
|
||||
'message': '重复装箱',
|
||||
'box_no': 7,
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.saveBoxRecord(
|
||||
zongpaiNo: '26B1',
|
||||
boxNo: 7,
|
||||
quantity: 2,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.duplicate);
|
||||
expect(result.boxNo, 7);
|
||||
});
|
||||
|
||||
test('updateBoxRecord maps network errors', () async {
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
throw Exception('Connection refused');
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final result = await actions.updateBoxRecord(
|
||||
boxItemId: 1,
|
||||
boxNo: 2,
|
||||
quantity: 3,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorKind, BoxingActionErrorKind.network);
|
||||
expect(result.errorMessage, BoxingApiActions.networkErrorMessage);
|
||||
});
|
||||
|
||||
test('deleteBoxRecord passes through success and backend errors', () async {
|
||||
var calls = 0;
|
||||
final actions = BoxingApiActions(
|
||||
apiService: ApiService(
|
||||
client: _MockClient((_) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 1, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({'message': '指定装箱明细不存在'}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
),
|
||||
loadApiUrl: () async => 'http://localhost',
|
||||
);
|
||||
|
||||
final ok = await actions.deleteBoxRecord(boxItemId: 1);
|
||||
final missing = await actions.deleteBoxRecord(boxItemId: 999);
|
||||
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorKind, BoxingActionErrorKind.backend);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
final Future<http.Response> Function(http.BaseRequest) _handler;
|
||||
|
||||
_MockClient(this._handler);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final response = await _handler(request);
|
||||
return http.StreamedResponse(
|
||||
http.ByteStream.fromBytes(response.bodyBytes),
|
||||
response.statusCode,
|
||||
headers: response.headers,
|
||||
reasonPhrase: response.reasonPhrase,
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
'请扫描下一个总排号',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
95
test/pages/registration/registration_calculations_test.dart
Normal file
95
test/pages/registration/registration_calculations_test.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
void main() {
|
||||
group('registration calculations', () {
|
||||
test('calculates overview stats by item status', () {
|
||||
final stats = overviewStats(
|
||||
_overview([
|
||||
_item('R1', status: 'not_shelved'),
|
||||
_item('R2', status: 'on_shelf'),
|
||||
_item('R3', status: 'transferred'),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(stats.totalCount, 3);
|
||||
expect(stats.shelvedCount, 1);
|
||||
expect(stats.transferredCount, 1);
|
||||
});
|
||||
|
||||
test('splits overview items into scanned and unscanned groups', () {
|
||||
final sections = splitOverviewItems(
|
||||
overview: _overview([_item('R1'), _item('R2'), _item('R3')]),
|
||||
zongpaiNos: ['R1', 'R3'],
|
||||
);
|
||||
|
||||
expect(sections.scanned.map((item) => item.zongpaiNo), ['R1', 'R3']);
|
||||
expect(sections.unscanned.map((item) => item.zongpaiNo), ['R2']);
|
||||
expect(sections.hasDivider, isTrue);
|
||||
});
|
||||
|
||||
test('finds located scanned items', () {
|
||||
final overview = _overview([
|
||||
_item('R1', status: 'on_shelf'),
|
||||
_item('R2', status: 'transferred'),
|
||||
_item('R3', status: 'on_shelf'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
findOnShelfItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R1'],
|
||||
);
|
||||
expect(
|
||||
findTransferredItemsInScanned(
|
||||
overview: overview,
|
||||
zongpaiNos: ['R1', 'R2'],
|
||||
).map((item) => item.zongpaiNo),
|
||||
['R2'],
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R1']),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R4']),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps row colors for scanned and unscanned states', () {
|
||||
expect(
|
||||
registrationBarColor(
|
||||
status: 'on_shelf',
|
||||
isScanned: true,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
Colors.red,
|
||||
);
|
||||
expect(
|
||||
registrationRowBgColor(
|
||||
status: 'transferred',
|
||||
isScanned: false,
|
||||
isTransitTarget: false,
|
||||
),
|
||||
const Color(0xFFFFF3E0),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PaichaOverviewResult _overview(List<PaichaOverviewItem> items) {
|
||||
return PaichaOverviewResult(
|
||||
success: true,
|
||||
totalCount: items.length,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
|
||||
PaichaOverviewItem _item(String zongpaiNo, {String status = 'not_shelved'}) {
|
||||
return PaichaOverviewItem(zongpaiNo: zongpaiNo, quantity: 1, status: status);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: false,
|
||||
isLocked: false,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'确 认 上 架',
|
||||
@@ -24,7 +24,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: false,
|
||||
isLocked: true,
|
||||
isMultiCode: true,
|
||||
zongpaiCount: 3,
|
||||
),
|
||||
'批量上架(3 条)',
|
||||
@@ -32,7 +32,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isLocked: false,
|
||||
isMultiCode: false,
|
||||
zongpaiCount: 1,
|
||||
),
|
||||
'转运并装箱',
|
||||
@@ -40,7 +40,7 @@ void main() {
|
||||
expect(
|
||||
registrationSubmitLabel(
|
||||
isTransitTarget: true,
|
||||
isLocked: true,
|
||||
isMultiCode: true,
|
||||
zongpaiCount: 3,
|
||||
),
|
||||
'批量转运并凑箱(3 条)',
|
||||
|
||||
Reference in New Issue
Block a user