Compare commits
24 Commits
44da2ac6b9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc714dbeb | ||
|
|
1416cff52c | ||
|
|
12cab16888 | ||
|
|
9aa5d86fde | ||
|
|
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`
|
- All new feature development must be done on a `dev` branch, created from `master`
|
||||||
- After verification, merge `dev` back into `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
|
## App Installation Rules
|
||||||
|
|
||||||
**Always use `adb install -r` to install the app. Never use `flutter install`.**
|
**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)
|
# 2. Install (in-place upgrade, preserves config)
|
||||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
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
|
||||||
|
```
|
||||||
|
|||||||
1157
lib/pages/accessory_page.dart
Normal file
1157
lib/pages/accessory_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
154
lib/pages/boxing/boxing_api_actions.dart
Normal file
154
lib/pages/boxing/boxing_api_actions.dart
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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,
|
||||||
|
String itemType = 'product',
|
||||||
|
int? accessoryId,
|
||||||
|
}) async {
|
||||||
|
final baseUrl = await _requireBaseUrl();
|
||||||
|
if (baseUrl == null) return _missingApiUrl();
|
||||||
|
|
||||||
|
final result = await _apiService.saveBoxRecord(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
boxNo: boxNo,
|
||||||
|
quantity: quantity,
|
||||||
|
itemType: itemType,
|
||||||
|
accessoryId: accessoryId,
|
||||||
|
);
|
||||||
|
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: '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
272
lib/pages/boxing/parts/boxing_scan_part.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// 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;
|
||||||
|
_pendingAccessories = result.pendingAccessories;
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
204
lib/pages/boxing/parts/boxing_submit_part.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitAccessory(PendingAccessory accessory) async {
|
||||||
|
if (_zongpaiNo == null || _isSubmitting) return;
|
||||||
|
|
||||||
|
final boxNo = int.tryParse(_boxNoController.text);
|
||||||
|
if (boxNo == null || boxNo <= 0) {
|
||||||
|
this._showStatusOverride(
|
||||||
|
'请先输入有效箱号',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
|
final action = await _apiActions.saveBoxRecord(
|
||||||
|
zongpaiNo: _zongpaiNo!,
|
||||||
|
boxNo: boxNo,
|
||||||
|
quantity: accessory.quantity,
|
||||||
|
itemType: 'accessory',
|
||||||
|
accessoryId: accessory.accessoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
|
if (action.success) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
|
// Refresh box info to update pending accessories
|
||||||
|
await this._queryBoxInfo(_zongpaiNo!);
|
||||||
|
if (mounted) {
|
||||||
|
this._showStatusOverride(
|
||||||
|
'${accessory.accessoryType} 已加入箱号 $boxNo',
|
||||||
|
StatusDotColor.green,
|
||||||
|
const Duration(milliseconds: 1500),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._showActionError(action, fallback: '附件装箱失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
689
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
689
lib/pages/boxing/widgets/multi_code_body.dart
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
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;
|
||||||
|
final List<PendingAccessory> pendingAccessories;
|
||||||
|
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||||
|
|
||||||
|
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,
|
||||||
|
required this.pendingAccessories,
|
||||||
|
required this.onSubmitAccessory,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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 && pendingAccessories.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'尚未装入任何物料',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: visibleItems.length + pendingAccessories.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < visibleItems.length) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final accIndex = index - visibleItems.length;
|
||||||
|
final acc = pendingAccessories[accIndex];
|
||||||
|
return _MultiAccessoryRow(
|
||||||
|
accessory: acc,
|
||||||
|
isSubmitting: isSubmitting,
|
||||||
|
onSubmit: () => onSubmitAccessory(acc),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MultiAccessoryRow extends StatelessWidget {
|
||||||
|
final PendingAccessory accessory;
|
||||||
|
final bool isSubmitting;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
const _MultiAccessoryRow({
|
||||||
|
required this.accessory,
|
||||||
|
required this.isSubmitting,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 40,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.build, size: 14, color: Colors.orange.shade600),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
flex: 40,
|
||||||
|
child: Text(
|
||||||
|
accessory.accessoryType,
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 20,
|
||||||
|
child: Text(
|
||||||
|
'\u00d7${accessory.quantity}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isSubmitting ? null : onSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isSubmitting
|
||||||
|
? Colors.grey.shade300
|
||||||
|
: Colors.orange.shade600,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'加入本箱',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
699
lib/pages/boxing/widgets/single_code_body.dart
Normal file
699
lib/pages/boxing/widgets/single_code_body.dart
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
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;
|
||||||
|
final List<PendingAccessory> pendingAccessories;
|
||||||
|
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||||
|
|
||||||
|
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,
|
||||||
|
required this.pendingAccessories,
|
||||||
|
required this.onSubmitAccessory,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
if (pendingAccessories.isNotEmpty && !isWaiting)
|
||||||
|
_PendingAccessoriesSection(
|
||||||
|
pendingAccessories: pendingAccessories,
|
||||||
|
isSubmitting: isSubmitting,
|
||||||
|
onSubmitAccessory: onSubmitAccessory,
|
||||||
|
),
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingAccessoriesSection extends StatelessWidget {
|
||||||
|
final List<PendingAccessory> pendingAccessories;
|
||||||
|
final bool isSubmitting;
|
||||||
|
final ValueChanged<PendingAccessory> onSubmitAccessory;
|
||||||
|
|
||||||
|
const _PendingAccessoriesSection({
|
||||||
|
required this.pendingAccessories,
|
||||||
|
required this.isSubmitting,
|
||||||
|
required this.onSubmitAccessory,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 28,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
border: Border(bottom: BorderSide(color: Colors.orange.shade200)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.build, size: 14, color: Colors.orange.shade700),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'待装箱附件(${pendingAccessories.length} 项)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...pendingAccessories.map(
|
||||||
|
(acc) => _AccessoryRow(
|
||||||
|
accessory: acc,
|
||||||
|
isSubmitting: isSubmitting,
|
||||||
|
onSubmit: () => onSubmitAccessory(acc),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccessoryRow extends StatelessWidget {
|
||||||
|
final PendingAccessory accessory;
|
||||||
|
final bool isSubmitting;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
const _AccessoryRow({
|
||||||
|
required this.accessory,
|
||||||
|
required this.isSubmitting,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 40,
|
||||||
|
child: Text(
|
||||||
|
accessory.accessoryType,
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 20,
|
||||||
|
child: Text(
|
||||||
|
'\u00d7${accessory.quantity}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: isSubmitting ? null : onSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isSubmitting
|
||||||
|
? Colors.grey.shade300
|
||||||
|
: Colors.orange.shade600,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'加入本箱',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,15 +103,40 @@ class BoxingDetailPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: box.items.map((item) {
|
children: box.items.map((item) {
|
||||||
return Padding(
|
final isAccessory = item.itemType == 'accessory';
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 4,
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 1),
|
||||||
|
decoration: isAccessory
|
||||||
|
? BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (isAccessory)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.build,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Text(
|
child: Text(
|
||||||
item.zongpaiNo,
|
item.zongpaiNo,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isAccessory
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@ import 'package:pad_scanner/services/app_config_service.dart';
|
|||||||
import 'package:pad_scanner/pages/settings_page.dart';
|
import 'package:pad_scanner/pages/settings_page.dart';
|
||||||
import 'package:pad_scanner/pages/registration_page.dart';
|
import 'package:pad_scanner/pages/registration_page.dart';
|
||||||
import 'package:pad_scanner/pages/boxing_page.dart';
|
import 'package:pad_scanner/pages/boxing_page.dart';
|
||||||
|
import 'package:pad_scanner/pages/accessory_page.dart';
|
||||||
import 'package:pad_scanner/services/api_service.dart';
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
|
||||||
/// Module type enum for each available feature card
|
/// Module type enum for each available feature card
|
||||||
enum ModuleType { registration, boxing, shelfQuery }
|
enum ModuleType { registration, boxing, accessory, shelfQuery }
|
||||||
|
|
||||||
/// Module status for PRD state tracking
|
/// Module status for PRD state tracking
|
||||||
enum ModuleStatus { online, developing, planning }
|
enum ModuleStatus { online, developing, planning }
|
||||||
@@ -39,10 +40,6 @@ class HomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
static const _androidKeyCode1 = 8;
|
|
||||||
static const _androidKeyCode2 = 9;
|
|
||||||
static const _androidKeyCode3 = 10;
|
|
||||||
|
|
||||||
final _apiService = ApiService();
|
final _apiService = ApiService();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
bool _isCheckingConnection = false;
|
bool _isCheckingConnection = false;
|
||||||
@@ -68,8 +65,16 @@ class _HomePageState extends State<HomePage> {
|
|||||||
route: '/boxing',
|
route: '/boxing',
|
||||||
),
|
),
|
||||||
FeatureCard(
|
FeatureCard(
|
||||||
type: ModuleType.shelfQuery,
|
type: ModuleType.accessory,
|
||||||
shortcut: '3',
|
shortcut: '3',
|
||||||
|
icon: Icons.build,
|
||||||
|
title: '附件登记',
|
||||||
|
status: ModuleStatus.developing,
|
||||||
|
route: '/accessory',
|
||||||
|
),
|
||||||
|
FeatureCard(
|
||||||
|
type: ModuleType.shelfQuery,
|
||||||
|
shortcut: '4',
|
||||||
icon: Icons.search_outlined,
|
icon: Icons.search_outlined,
|
||||||
title: '货架查询',
|
title: '货架查询',
|
||||||
status: ModuleStatus.planning,
|
status: ModuleStatus.planning,
|
||||||
@@ -105,17 +110,11 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
FeatureCard? _moduleFromShortcut(KeyEvent event) {
|
FeatureCard? _moduleFromShortcut(KeyEvent event) {
|
||||||
final shortcut = _shortcutNumberFromKeyEvent(event);
|
final shortcut = _shortcutNumberFromKeyEvent(event);
|
||||||
if (shortcut == 1) {
|
if (shortcut == null || shortcut < 1 || shortcut > _modules.length) {
|
||||||
return _modules[0];
|
|
||||||
}
|
|
||||||
if (shortcut == 2) {
|
|
||||||
return _modules[1];
|
|
||||||
}
|
|
||||||
if (shortcut == 3) {
|
|
||||||
return _modules[2];
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return _modules[shortcut - 1];
|
||||||
|
}
|
||||||
|
|
||||||
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
|
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
@@ -134,22 +133,10 @@ class _HomePageState extends State<HomePage> {
|
|||||||
event.character == '3') {
|
event.character == '3') {
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
if (key == LogicalKeyboardKey.digit4 ||
|
||||||
// Android keyCode fallback for scanner devices: 1/2/3 => 8/9/10.
|
key == LogicalKeyboardKey.numpad4 ||
|
||||||
final androidKeyCode = _androidKeyCodeFromLogicalKey(key);
|
event.character == '4') {
|
||||||
return switch (androidKeyCode) {
|
return 4;
|
||||||
_androidKeyCode1 => 1,
|
|
||||||
_androidKeyCode2 => 2,
|
|
||||||
_androidKeyCode3 => 3,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _androidKeyCodeFromLogicalKey(LogicalKeyboardKey key) {
|
|
||||||
final keyId = key.keyId;
|
|
||||||
const androidPlane = LogicalKeyboardKey.androidPlane;
|
|
||||||
if (keyId >= androidPlane && keyId < androidPlane + 0x100000000) {
|
|
||||||
return keyId - androidPlane;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -202,6 +189,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
case ModuleType.boxing:
|
case ModuleType.boxing:
|
||||||
targetPage = const BoxingPage();
|
targetPage = const BoxingPage();
|
||||||
break;
|
break;
|
||||||
|
case ModuleType.accessory:
|
||||||
|
targetPage = const AccessoryPage();
|
||||||
|
break;
|
||||||
case ModuleType.shelfQuery:
|
case ModuleType.shelfQuery:
|
||||||
return; // 规划中,不导航
|
return; // 规划中,不导航
|
||||||
}
|
}
|
||||||
|
|||||||
333
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
333
lib/pages/registration/dialogs/registration_dialogs_part.dart
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
// 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,
|
||||||
|
}) {
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/pages/registration/parts/registration_overview_part.dart
Normal file
80
lib/pages/registration/parts/registration_overview_part.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/pages/registration/parts/registration_scan_part.dart
Normal file
128
lib/pages/registration/parts/registration_scan_part.dart
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// 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:
|
||||||
|
_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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/pages/registration/parts/registration_status_part.dart
Normal file
55
lib/pages/registration/parts/registration_status_part.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
317
lib/pages/registration/parts/registration_submit_part.dart
Normal file
317
lib/pages/registration/parts/registration_submit_part.dart
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
_locationCode = null;
|
||||||
|
_locationType = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
final msg = result.isOffShelfSuccess
|
||||||
|
? '下架成功'
|
||||||
|
: (_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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
lib/pages/registration/registration_calculations.dart
Normal file
163
lib/pages/registration/registration_calculations.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
|
||||||
|
class RegistrationOverviewStats {
|
||||||
|
final int totalCount;
|
||||||
|
final int shelvedCount;
|
||||||
|
final int transferredCount;
|
||||||
|
|
||||||
|
const RegistrationOverviewStats({
|
||||||
|
required this.totalCount,
|
||||||
|
required this.shelvedCount,
|
||||||
|
required this.transferredCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var shelvedCount = 0;
|
||||||
|
var transferredCount = 0;
|
||||||
|
for (final item in overview!.items) {
|
||||||
|
switch (item.status) {
|
||||||
|
case 'on_shelf':
|
||||||
|
shelvedCount++;
|
||||||
|
case 'transferred':
|
||||||
|
transferredCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegistrationOverviewStats(
|
||||||
|
totalCount: overview.totalCount,
|
||||||
|
shelvedCount: shelvedCount,
|
||||||
|
transferredCount: transferredCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color registrationBarColor({
|
||||||
|
required String status,
|
||||||
|
required bool isScanned,
|
||||||
|
required bool isTransitTarget,
|
||||||
|
}) {
|
||||||
|
if (isScanned) {
|
||||||
|
final isConflict = isTransitTarget
|
||||||
|
? status == 'transferred'
|
||||||
|
: (status == 'on_shelf' || status == 'transferred');
|
||||||
|
return isConflict ? Colors.red : const Color(0xFF43A047);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'on_shelf':
|
||||||
|
return const Color(0xFF2196F3);
|
||||||
|
case 'transferred':
|
||||||
|
return const Color(0xFFFF9800);
|
||||||
|
default:
|
||||||
|
return Colors.grey.shade400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color registrationRowBgColor({
|
||||||
|
required String status,
|
||||||
|
required bool isScanned,
|
||||||
|
required bool isTransitTarget,
|
||||||
|
}) {
|
||||||
|
if (isScanned) {
|
||||||
|
final isConflict = isTransitTarget
|
||||||
|
? status == 'transferred'
|
||||||
|
: (status == 'on_shelf' || status == 'transferred');
|
||||||
|
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'on_shelf':
|
||||||
|
return const Color(0xFFE3F2FD);
|
||||||
|
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();
|
||||||
|
}
|
||||||
453
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
453
lib/pages/registration/widgets/registration_widgets_part.dart
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
// 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 '转运区域';
|
||||||
|
return '普通货架';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _locationLabelColor(CodeType? type) {
|
||||||
|
if (type == CodeType.locationTransit) return Colors.orange;
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
final bgColor = registration_calculations.registrationRowBgColor(
|
||||||
|
status: item.status,
|
||||||
|
isScanned: isScanned,
|
||||||
|
isTransitTarget: _isTransitTarget,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 configService = AppConfigService();
|
||||||
final config = await configService.loadConfig();
|
final config = await configService.loadConfig();
|
||||||
config['api_url'] = url;
|
config['api_url'] = url;
|
||||||
if (_successPath != null) config['sound_success'] = _successPath;
|
if (_successPath != null) {
|
||||||
else config.remove('sound_success');
|
config['sound_success'] = _successPath;
|
||||||
if (_failurePath != null) config['sound_failure'] = _failurePath;
|
} else {
|
||||||
else config.remove('sound_failure');
|
config.remove('sound_success');
|
||||||
if (_beepPath != null) config['sound_beep'] = _beepPath;
|
}
|
||||||
else config.remove('sound_beep');
|
if (_failurePath != null) {
|
||||||
if (_errorPath != null) config['sound_error'] = _errorPath;
|
config['sound_failure'] = _failurePath;
|
||||||
else config.remove('sound_error');
|
} else {
|
||||||
if (_alertPath != null) config['sound_alert'] = _alertPath;
|
config.remove('sound_failure');
|
||||||
else config.remove('sound_alert');
|
}
|
||||||
|
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);
|
await configService.saveConfig(config);
|
||||||
setState(() => _saving = false);
|
setState(() => _saving = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -118,6 +118,30 @@ class PaichaOverviewResult {
|
|||||||
|
|
||||||
// === 装箱模块数据类 ===
|
// === 装箱模块数据类 ===
|
||||||
|
|
||||||
|
/// 待装箱附件
|
||||||
|
class PendingAccessory {
|
||||||
|
final int accessoryId;
|
||||||
|
final String accessoryType;
|
||||||
|
final int quantity;
|
||||||
|
final String? locationCode;
|
||||||
|
|
||||||
|
PendingAccessory({
|
||||||
|
required this.accessoryId,
|
||||||
|
required this.accessoryType,
|
||||||
|
required this.quantity,
|
||||||
|
this.locationCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PendingAccessory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PendingAccessory(
|
||||||
|
accessoryId: json['accessory_id'] as int,
|
||||||
|
accessoryType: json['accessory_type'] as String,
|
||||||
|
quantity: json['quantity'] as int,
|
||||||
|
locationCode: json['location_code']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 箱号内单个总排号明细
|
/// 箱号内单个总排号明细
|
||||||
class BoxItemData {
|
class BoxItemData {
|
||||||
final int? boxItemId;
|
final int? boxItemId;
|
||||||
@@ -125,6 +149,7 @@ class BoxItemData {
|
|||||||
final String? workOrderNo;
|
final String? workOrderNo;
|
||||||
final int quantity;
|
final int quantity;
|
||||||
final int? totalQuantity;
|
final int? totalQuantity;
|
||||||
|
final String itemType;
|
||||||
|
|
||||||
BoxItemData({
|
BoxItemData({
|
||||||
this.boxItemId,
|
this.boxItemId,
|
||||||
@@ -132,6 +157,7 @@ class BoxItemData {
|
|||||||
this.workOrderNo,
|
this.workOrderNo,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
this.totalQuantity,
|
this.totalQuantity,
|
||||||
|
this.itemType = 'product',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BoxItemData.fromJson(Map<String, dynamic> json) {
|
factory BoxItemData.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -141,6 +167,7 @@ class BoxItemData {
|
|||||||
workOrderNo: json['work_order_no']?.toString(),
|
workOrderNo: json['work_order_no']?.toString(),
|
||||||
quantity: json['quantity'] as int,
|
quantity: json['quantity'] as int,
|
||||||
totalQuantity: json['total_quantity'] as int?,
|
totalQuantity: json['total_quantity'] as int?,
|
||||||
|
itemType: json['item_type'] as String? ?? 'product',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,11 +194,13 @@ class CurrentZongpaiBoxData {
|
|||||||
final int boxItemId;
|
final int boxItemId;
|
||||||
final int boxNo;
|
final int boxNo;
|
||||||
final int quantity;
|
final int quantity;
|
||||||
|
final String itemType;
|
||||||
|
|
||||||
CurrentZongpaiBoxData({
|
CurrentZongpaiBoxData({
|
||||||
required this.boxItemId,
|
required this.boxItemId,
|
||||||
required this.boxNo,
|
required this.boxNo,
|
||||||
required this.quantity,
|
required this.quantity,
|
||||||
|
this.itemType = 'product',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
|
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -179,6 +208,7 @@ class CurrentZongpaiBoxData {
|
|||||||
boxItemId: json['box_item_id'] as int,
|
boxItemId: json['box_item_id'] as int,
|
||||||
boxNo: json['box_no'] as int,
|
boxNo: json['box_no'] as int,
|
||||||
quantity: json['quantity'] as int,
|
quantity: json['quantity'] as int,
|
||||||
|
itemType: json['item_type'] as String? ?? 'product',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +225,7 @@ class BoxInfoResult {
|
|||||||
final List<BoxDetailData> existingBoxes;
|
final List<BoxDetailData> existingBoxes;
|
||||||
final int maxBoxNo;
|
final int maxBoxNo;
|
||||||
final int suggestedBoxNo;
|
final int suggestedBoxNo;
|
||||||
|
final List<PendingAccessory> pendingAccessories;
|
||||||
|
|
||||||
BoxInfoResult({
|
BoxInfoResult({
|
||||||
required this.success,
|
required this.success,
|
||||||
@@ -207,6 +238,7 @@ class BoxInfoResult {
|
|||||||
this.existingBoxes = const [],
|
this.existingBoxes = const [],
|
||||||
this.maxBoxNo = 0,
|
this.maxBoxNo = 0,
|
||||||
this.suggestedBoxNo = 1,
|
this.suggestedBoxNo = 1,
|
||||||
|
this.pendingAccessories = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||||
@@ -222,6 +254,11 @@ class BoxInfoResult {
|
|||||||
)
|
)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
|
final pendingAccessories =
|
||||||
|
(json['pending_accessories'] as List<dynamic>?)
|
||||||
|
?.map((a) => PendingAccessory.fromJson(a as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
return BoxInfoResult(
|
return BoxInfoResult(
|
||||||
success: true,
|
success: true,
|
||||||
zongpaiNo: json['zongpai_no'] as String?,
|
zongpaiNo: json['zongpai_no'] as String?,
|
||||||
@@ -232,6 +269,7 @@ class BoxInfoResult {
|
|||||||
existingBoxes: boxes,
|
existingBoxes: boxes,
|
||||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||||
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
||||||
|
pendingAccessories: pendingAccessories,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +337,163 @@ class BoxDeleteResult {
|
|||||||
BoxDeleteResult({required this.success, this.errorMessage});
|
BoxDeleteResult({required this.success, this.errorMessage});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 附件模块数据类 ===
|
||||||
|
|
||||||
|
/// 工令号分组(工令号 → 总排号列表)
|
||||||
|
class WorkOrderGroup {
|
||||||
|
final String workOrderNo;
|
||||||
|
final List<String> zongpaiNos;
|
||||||
|
WorkOrderGroup({required this.workOrderNo, required this.zongpaiNos});
|
||||||
|
factory WorkOrderGroup.fromJson(Map<String, dynamic> json) => WorkOrderGroup(
|
||||||
|
workOrderNo: json['work_order_no'] as String,
|
||||||
|
zongpaiNos: (json['zongpai_nos'] as List).map((e) => e as String).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件类型预设
|
||||||
|
class AccessoryTypeInfo {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final int sortOrder;
|
||||||
|
AccessoryTypeInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.sortOrder,
|
||||||
|
});
|
||||||
|
factory AccessoryTypeInfo.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AccessoryTypeInfo(
|
||||||
|
id: json['id'] as int,
|
||||||
|
name: json['name'] as String,
|
||||||
|
sortOrder: json['sort_order'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件记录
|
||||||
|
class AccessoryRecord {
|
||||||
|
final int id;
|
||||||
|
final String paichanNo;
|
||||||
|
final String zongpaiNo;
|
||||||
|
final String accessoryType;
|
||||||
|
final int quantity;
|
||||||
|
final String? locationCode;
|
||||||
|
final bool isBoxed;
|
||||||
|
final String createdAt;
|
||||||
|
AccessoryRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.paichanNo,
|
||||||
|
required this.zongpaiNo,
|
||||||
|
required this.accessoryType,
|
||||||
|
required this.quantity,
|
||||||
|
this.locationCode,
|
||||||
|
required this.isBoxed,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
factory AccessoryRecord.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AccessoryRecord(
|
||||||
|
id: json['id'] as int,
|
||||||
|
paichanNo: json['paichan_no'] as String,
|
||||||
|
zongpaiNo: json['zongpai_no'] as String,
|
||||||
|
accessoryType: json['accessory_type'] as String,
|
||||||
|
quantity: json['quantity'] as int,
|
||||||
|
locationCode: json['location_code'] as String?,
|
||||||
|
isBoxed: json['is_boxed'] as bool,
|
||||||
|
createdAt: json['created_at'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 附件模块结果类 ===
|
||||||
|
|
||||||
|
class WorkOrderQueryResult {
|
||||||
|
final bool success;
|
||||||
|
final String? paichanNo;
|
||||||
|
final List<WorkOrderGroup> workOrders;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
WorkOrderQueryResult({
|
||||||
|
required this.success,
|
||||||
|
this.paichanNo,
|
||||||
|
this.workOrders = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryListResult {
|
||||||
|
final bool success;
|
||||||
|
final List<AccessoryRecord> items;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryListResult({
|
||||||
|
required this.success,
|
||||||
|
this.items = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryCreateResult {
|
||||||
|
final bool success;
|
||||||
|
final AccessoryRecord? record;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryCreateResult({
|
||||||
|
required this.success,
|
||||||
|
this.record,
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryUpdateResult {
|
||||||
|
final bool success;
|
||||||
|
final AccessoryRecord? record;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryUpdateResult({
|
||||||
|
required this.success,
|
||||||
|
this.record,
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryDeleteResult {
|
||||||
|
final bool success;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryDeleteResult({
|
||||||
|
required this.success,
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryTypeListResult {
|
||||||
|
final bool success;
|
||||||
|
final List<AccessoryTypeInfo> types;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryTypeListResult({
|
||||||
|
required this.success,
|
||||||
|
this.types = const [],
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccessoryTypeCreateResult {
|
||||||
|
final bool success;
|
||||||
|
final AccessoryTypeInfo? type;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
AccessoryTypeCreateResult({
|
||||||
|
required this.success,
|
||||||
|
this.type,
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final Duration timeout;
|
final Duration timeout;
|
||||||
@@ -423,18 +618,25 @@ class ApiService {
|
|||||||
required String zongpaiNo,
|
required String zongpaiNo,
|
||||||
required int boxNo,
|
required int boxNo,
|
||||||
required int quantity,
|
required int quantity,
|
||||||
|
String itemType = 'product',
|
||||||
|
int? accessoryId,
|
||||||
}) async {
|
}) async {
|
||||||
final uri = Uri.parse('$baseUrl/CargoTrace/box');
|
final uri = Uri.parse('$baseUrl/CargoTrace/box');
|
||||||
try {
|
try {
|
||||||
|
final bodyMap = <String, dynamic>{
|
||||||
|
'zongpai_no': zongpaiNo,
|
||||||
|
'box_no': boxNo,
|
||||||
|
'quantity': quantity,
|
||||||
|
'item_type': itemType,
|
||||||
|
};
|
||||||
|
if (accessoryId != null) {
|
||||||
|
bodyMap['accessory_id'] = accessoryId;
|
||||||
|
}
|
||||||
final response = await _client
|
final response = await _client
|
||||||
.post(
|
.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode(bodyMap),
|
||||||
'zongpai_no': zongpaiNo,
|
|
||||||
'box_no': boxNo,
|
|
||||||
'quantity': quantity,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.timeout(timeout);
|
.timeout(timeout);
|
||||||
|
|
||||||
@@ -524,6 +726,403 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 附件模块 API 方法 ===
|
||||||
|
|
||||||
|
/// 工令号查询 — GET /CargoTrace/accessory/work-orders
|
||||||
|
Future<WorkOrderQueryResult> queryWorkOrders({
|
||||||
|
required String baseUrl,
|
||||||
|
required String paichanNo,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$baseUrl/CargoTrace/accessory/work-orders',
|
||||||
|
).replace(queryParameters: {'paichan_no': paichanNo});
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final workOrders =
|
||||||
|
(body['work_orders'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(w) => WorkOrderGroup.fromJson(w as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return WorkOrderQueryResult(
|
||||||
|
success: true,
|
||||||
|
paichanNo: body['paichan_no'] as String?,
|
||||||
|
workOrders: workOrders,
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return WorkOrderQueryResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return WorkOrderQueryResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '未找到该排产号对应的工令号信息',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return WorkOrderQueryResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '查询失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return WorkOrderQueryResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件列表查询 — GET /CargoTrace/accessory
|
||||||
|
Future<AccessoryListResult> listAccessories({
|
||||||
|
required String baseUrl,
|
||||||
|
required String paichanNo,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$baseUrl/CargoTrace/accessory',
|
||||||
|
).replace(queryParameters: {'paichan_no': paichanNo});
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final items =
|
||||||
|
(body['items'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(i) => AccessoryRecord.fromJson(i as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return AccessoryListResult(success: true, items: items);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return AccessoryListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '未找到该排产号信息',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '查询失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryListResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件创建(上架) — POST /CargoTrace/accessory
|
||||||
|
Future<AccessoryCreateResult> createAccessory({
|
||||||
|
required String baseUrl,
|
||||||
|
required String paichanNo,
|
||||||
|
required String zongpaiNo,
|
||||||
|
required String accessoryType,
|
||||||
|
required int quantity,
|
||||||
|
String? locationCode,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory');
|
||||||
|
try {
|
||||||
|
final bodyMap = <String, dynamic>{
|
||||||
|
'paichan_no': paichanNo,
|
||||||
|
'zongpai_no': zongpaiNo,
|
||||||
|
'accessory_type': accessoryType,
|
||||||
|
'quantity': quantity,
|
||||||
|
};
|
||||||
|
if (locationCode != null) {
|
||||||
|
bodyMap['location_code'] = locationCode;
|
||||||
|
}
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(bodyMap),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: true,
|
||||||
|
record: AccessoryRecord.fromJson(body),
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '未找到该排产号对应的工令号信息',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
case 409:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '冲突',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '创建失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件更新 — PATCH /CargoTrace/accessory/{id}
|
||||||
|
Future<AccessoryUpdateResult> updateAccessory({
|
||||||
|
required String baseUrl,
|
||||||
|
required int id,
|
||||||
|
String? accessoryType,
|
||||||
|
int? quantity,
|
||||||
|
String? locationCode,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
|
||||||
|
try {
|
||||||
|
final bodyMap = <String, dynamic>{};
|
||||||
|
if (accessoryType != null) bodyMap['accessory_type'] = accessoryType;
|
||||||
|
if (quantity != null) bodyMap['quantity'] = quantity;
|
||||||
|
if (locationCode != null) bodyMap['location_code'] = locationCode;
|
||||||
|
|
||||||
|
final response = await _client
|
||||||
|
.patch(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(bodyMap),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryUpdateResult(
|
||||||
|
success: true,
|
||||||
|
record: AccessoryRecord.fromJson(body),
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryUpdateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return AccessoryUpdateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '指定附件记录不存在',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryUpdateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '更新失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryUpdateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件删除 — DELETE /CargoTrace/accessory/{id}
|
||||||
|
Future<AccessoryDeleteResult> deleteAccessory({
|
||||||
|
required String baseUrl,
|
||||||
|
required int id,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
return AccessoryDeleteResult(success: true);
|
||||||
|
case 404:
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '指定附件记录不存在',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '删除失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件类型列表 — GET /CargoTrace/accessory-type
|
||||||
|
Future<AccessoryTypeListResult> listAccessoryTypes({
|
||||||
|
required String baseUrl,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final types =
|
||||||
|
(body['types'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(t) =>
|
||||||
|
AccessoryTypeInfo.fromJson(t as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return AccessoryTypeListResult(success: true, types: types);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryTypeListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryTypeListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '查询失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryTypeListResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件类型创建 — POST /CargoTrace/accessory-type
|
||||||
|
Future<AccessoryTypeCreateResult> createAccessoryType({
|
||||||
|
required String baseUrl,
|
||||||
|
required String name,
|
||||||
|
int sortOrder = 0,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'name': name, 'sort_order': sortOrder}),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryTypeCreateResult(
|
||||||
|
success: true,
|
||||||
|
type: AccessoryTypeInfo.fromJson(body),
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryTypeCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
case 409:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryTypeCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '附件类型已存在',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryTypeCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '创建失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryTypeCreateResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件类型删除 — DELETE /CargoTrace/accessory-type/{id}
|
||||||
|
Future<AccessoryDeleteResult> deleteAccessoryType({
|
||||||
|
required String baseUrl,
|
||||||
|
required int id,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type/$id');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
return AccessoryDeleteResult(success: true);
|
||||||
|
case 404:
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '指定附件类型不存在',
|
||||||
|
errorCode: 'NOT_FOUND',
|
||||||
|
);
|
||||||
|
case 409:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '该类型下存在附件记录,无法删除',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '删除失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AccessoryDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Test connectivity by making a HEAD request to the base URL.
|
/// Test connectivity by making a HEAD request to the base URL.
|
||||||
Future<bool> testConnection(String baseUrl) async {
|
Future<bool> testConnection(String baseUrl) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
|||||||
|
|
||||||
String registrationSubmitLabel({
|
String registrationSubmitLabel({
|
||||||
required bool isTransitTarget,
|
required bool isTransitTarget,
|
||||||
required bool isLocked,
|
required bool isMultiCode,
|
||||||
required int zongpaiCount,
|
required int zongpaiCount,
|
||||||
}) {
|
}) {
|
||||||
if (!isTransitTarget) {
|
if (!isTransitTarget) {
|
||||||
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
return isMultiCode && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||||
}
|
}
|
||||||
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||||
}
|
}
|
||||||
|
|
||||||
Color registrationSubmitColor({
|
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(
|
expect(
|
||||||
registrationSubmitLabel(
|
registrationSubmitLabel(
|
||||||
isTransitTarget: false,
|
isTransitTarget: false,
|
||||||
isLocked: false,
|
isMultiCode: false,
|
||||||
zongpaiCount: 1,
|
zongpaiCount: 1,
|
||||||
),
|
),
|
||||||
'确 认 上 架',
|
'确 认 上 架',
|
||||||
@@ -24,7 +24,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
registrationSubmitLabel(
|
registrationSubmitLabel(
|
||||||
isTransitTarget: false,
|
isTransitTarget: false,
|
||||||
isLocked: true,
|
isMultiCode: true,
|
||||||
zongpaiCount: 3,
|
zongpaiCount: 3,
|
||||||
),
|
),
|
||||||
'批量上架(3 条)',
|
'批量上架(3 条)',
|
||||||
@@ -32,7 +32,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
registrationSubmitLabel(
|
registrationSubmitLabel(
|
||||||
isTransitTarget: true,
|
isTransitTarget: true,
|
||||||
isLocked: false,
|
isMultiCode: false,
|
||||||
zongpaiCount: 1,
|
zongpaiCount: 1,
|
||||||
),
|
),
|
||||||
'转运并装箱',
|
'转运并装箱',
|
||||||
@@ -40,7 +40,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
registrationSubmitLabel(
|
registrationSubmitLabel(
|
||||||
isTransitTarget: true,
|
isTransitTarget: true,
|
||||||
isLocked: true,
|
isMultiCode: true,
|
||||||
zongpaiCount: 3,
|
zongpaiCount: 3,
|
||||||
),
|
),
|
||||||
'批量转运并凑箱(3 条)',
|
'批量转运并凑箱(3 条)',
|
||||||
|
|||||||
Reference in New Issue
Block a user