Compare commits
7 Commits
f6bfd8cb10
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf974cee92 | ||
|
|
15c29bceb8 | ||
|
|
6b05d6c0e3 | ||
|
|
b7484915c6 | ||
|
|
b383328aed | ||
|
|
a702f80116 | ||
|
|
99ed3bb9ec |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,6 +46,10 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
/android/build/
|
/android/build/
|
||||||
|
|
||||||
|
# Release signing keystore (do NOT commit — contains signing identity)
|
||||||
|
/android/key.properties
|
||||||
|
/android/app/key.jks
|
||||||
|
|
||||||
# AI Agents
|
# AI Agents
|
||||||
.claude/
|
.claude/
|
||||||
.agents/
|
.agents/
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,11 +8,26 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.pad_scanner"
|
namespace = "com.example.pad_scanner"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -32,9 +50,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,85 @@ class BoxingApiActions {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<BoxingActionResult<AttachmentBoxResult>> saveAttachmentBox({
|
||||||
|
required String zongpaiNo,
|
||||||
|
required int categoryId,
|
||||||
|
required int boxNo,
|
||||||
|
required int quantity,
|
||||||
|
}) async {
|
||||||
|
final baseUrl = await _requireBaseUrl();
|
||||||
|
if (baseUrl == null) return _missingApiUrl();
|
||||||
|
final result = await _apiService.saveAttachmentBox(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
categoryId: categoryId,
|
||||||
|
boxNo: boxNo,
|
||||||
|
quantity: quantity,
|
||||||
|
);
|
||||||
|
if (result.success) return BoxingActionResult.ok(result);
|
||||||
|
if (result.isDuplicate) {
|
||||||
|
return BoxingActionResult.error(
|
||||||
|
kind: BoxingActionErrorKind.duplicate,
|
||||||
|
message: result.errorMessage ?? '提交失败',
|
||||||
|
boxNo: result.boxNo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BoxingActionResult.error(
|
||||||
|
kind: _kindForMessage(result.errorMessage),
|
||||||
|
message: result.errorMessage ?? '提交失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BoxingActionResult<AttachmentBoxResult>> updateAttachmentBox({
|
||||||
|
required int boxItemId,
|
||||||
|
required int boxNo,
|
||||||
|
required int quantity,
|
||||||
|
}) async {
|
||||||
|
final baseUrl = await _requireBaseUrl();
|
||||||
|
if (baseUrl == null) return _missingApiUrl();
|
||||||
|
final result = await _apiService.updateAttachmentBox(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
boxItemId: boxItemId,
|
||||||
|
boxNo: boxNo,
|
||||||
|
quantity: quantity,
|
||||||
|
);
|
||||||
|
if (result.success) return BoxingActionResult.ok(result);
|
||||||
|
return BoxingActionResult.error(
|
||||||
|
kind: _kindForMessage(result.errorMessage),
|
||||||
|
message: result.errorMessage ?? '修改失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BoxingActionResult<AttachmentBoxDeleteResult>> deleteAttachmentBox({
|
||||||
|
required int boxItemId,
|
||||||
|
}) async {
|
||||||
|
final baseUrl = await _requireBaseUrl();
|
||||||
|
if (baseUrl == null) return _missingApiUrl();
|
||||||
|
final result = await _apiService.deleteAttachmentBox(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
boxItemId: boxItemId,
|
||||||
|
);
|
||||||
|
if (result.success) return BoxingActionResult.ok(result);
|
||||||
|
return BoxingActionResult.error(
|
||||||
|
kind: _kindForMessage(result.errorMessage),
|
||||||
|
message: result.errorMessage ?? '删除失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BoxingActionResult<AttachmentCategoryResult>>
|
||||||
|
fetchAttachmentCategories() async {
|
||||||
|
final baseUrl = await _requireBaseUrl();
|
||||||
|
if (baseUrl == null) return _missingApiUrl();
|
||||||
|
final result = await _apiService.fetchAttachmentCategories(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
);
|
||||||
|
if (result.success) return BoxingActionResult.ok(result);
|
||||||
|
return BoxingActionResult.error(
|
||||||
|
kind: _kindForMessage(result.errorMessage),
|
||||||
|
message: result.errorMessage ?? '加载失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _requireBaseUrl() async {
|
Future<String?> _requireBaseUrl() async {
|
||||||
final baseUrl = await _loadApiUrl() ?? '';
|
final baseUrl = await _loadApiUrl() ?? '';
|
||||||
if (baseUrl.isEmpty) return null;
|
if (baseUrl.isEmpty) return null;
|
||||||
|
|||||||
86
lib/pages/boxing/dialogs/attachment_category_picker.dart
Normal file
86
lib/pages/boxing/dialogs/attachment_category_picker.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Show a single-category picker for attachment boxing.
|
||||||
|
/// Returns the selected category, or null.
|
||||||
|
Future<AttachmentCategoryData?> showBoxingAttachmentCategoryPicker({
|
||||||
|
required BuildContext context,
|
||||||
|
required List<AttachmentCategoryData> categories,
|
||||||
|
}) {
|
||||||
|
return showDialog<AttachmentCategoryData>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _BoxingCategoryPickerDialog(categories: categories),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoxingCategoryPickerDialog extends StatefulWidget {
|
||||||
|
final List<AttachmentCategoryData> categories;
|
||||||
|
const _BoxingCategoryPickerDialog({required this.categories});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BoxingCategoryPickerDialog> createState() =>
|
||||||
|
_BoxingCategoryPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoxingCategoryPickerDialogState
|
||||||
|
extends State<_BoxingCategoryPickerDialog> {
|
||||||
|
int? _selectedId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('选择附件类型'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: RadioGroup<int>(
|
||||||
|
groupValue: _selectedId,
|
||||||
|
onChanged: (v) => setState(() => _selectedId = v),
|
||||||
|
child: widget.categories.length <= 6
|
||||||
|
? Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.categories
|
||||||
|
.map(
|
||||||
|
(cat) => RadioListTile<int>(
|
||||||
|
dense: true,
|
||||||
|
title: Text(cat.name),
|
||||||
|
value: cat.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.categories
|
||||||
|
.map(
|
||||||
|
(cat) => RadioListTile<int>(
|
||||||
|
dense: true,
|
||||||
|
title: Text(cat.name),
|
||||||
|
value: cat.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _selectedId != null
|
||||||
|
? () {
|
||||||
|
final cat = widget.categories.firstWhere(
|
||||||
|
(c) => c.id == _selectedId,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, cat);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ extension _BoxingScanPart on _BoxingPageState {
|
|||||||
|
|
||||||
final parsed = CodeParser.parse(result.barcode);
|
final parsed = CodeParser.parse(result.barcode);
|
||||||
|
|
||||||
|
if (parsed.type == CodeType.attachmentCode) {
|
||||||
|
this._handleAttachmentBoxScan(parsed.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.type != CodeType.zongpaiNo) {
|
if (parsed.type != CodeType.zongpaiNo) {
|
||||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
this._showStatusOverride(
|
this._showStatusOverride(
|
||||||
@@ -66,6 +71,13 @@ extension _BoxingScanPart on _BoxingPageState {
|
|||||||
: FeedbackEvent.scanValid,
|
: FeedbackEvent.scanValid,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final attSummary = result.attachmentSummary;
|
||||||
|
final pending =
|
||||||
|
attSummary != null &&
|
||||||
|
attSummary['determination'] == 'has' &&
|
||||||
|
attSummary['all_complete'] != true;
|
||||||
|
final pendingCount = attSummary?['pending_count'] as int? ?? 0;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNo = zongpai;
|
_zongpaiNo = zongpai;
|
||||||
_paichanNo = result.paichanNo;
|
_paichanNo = result.paichanNo;
|
||||||
@@ -82,6 +94,8 @@ extension _BoxingScanPart on _BoxingPageState {
|
|||||||
_deletingAssignedItemId = null;
|
_deletingAssignedItemId = null;
|
||||||
_completedJumpBoxNo = null;
|
_completedJumpBoxNo = null;
|
||||||
_statusOverrideText = null;
|
_statusOverrideText = null;
|
||||||
|
_attachmentPending = pending;
|
||||||
|
_attachmentPendingCount = pendingCount;
|
||||||
if (_mode == BoxingMode.singleCode) {
|
if (_mode == BoxingMode.singleCode) {
|
||||||
_paichanSwitchNotice = null;
|
_paichanSwitchNotice = null;
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,119 @@ extension _BoxingScanPart on _BoxingPageState {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAttachmentBoxScan(String zongpai) async {
|
||||||
|
if (_isAutoProcessing || _crossPaichaPending) return;
|
||||||
|
|
||||||
|
// 1. Fetch categories
|
||||||
|
final catAction = await _apiActions.fetchAttachmentCategories();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!catAction.success) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
|
_showStatusOverride(
|
||||||
|
catAction.errorMessage ?? '加载附件类型失败',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pick category
|
||||||
|
final picked = await showBoxingAttachmentCategoryPicker(
|
||||||
|
context: context,
|
||||||
|
categories: catAction.data!.categories,
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_attachmentCategoryId = picked.id;
|
||||||
|
_attachmentCategoryName = picked.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fetch box info (server-extended: includes attachment_summary)
|
||||||
|
final action = await _apiActions.fetchBoxInfo(zongpai);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!action.success) {
|
||||||
|
_feedbackService.trigger(switch (action.errorKind) {
|
||||||
|
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||||
|
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
|
||||||
|
_ => FeedbackEvent.scanInvalid,
|
||||||
|
});
|
||||||
|
_showStatusOverride(
|
||||||
|
action.errorMessage ?? '查询失败',
|
||||||
|
_dotForActionError(action.errorKind),
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = action.data!;
|
||||||
|
final totalQuantity = result.quantity ?? 0;
|
||||||
|
|
||||||
|
// 4. Derive attachment qty context from the box-info attachment_summary
|
||||||
|
// or from a separate status call
|
||||||
|
final baseUrl = await _baseUrl();
|
||||||
|
int expectedQty = totalQuantity;
|
||||||
|
int boxedQty = 0;
|
||||||
|
|
||||||
|
if (baseUrl != null) {
|
||||||
|
final statusResult = await _apiService.fetchAttachmentStatus(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpai,
|
||||||
|
);
|
||||||
|
if (statusResult.success) {
|
||||||
|
for (final item in statusResult.items) {
|
||||||
|
if (item.categoryId == _attachmentCategoryId) {
|
||||||
|
expectedQty = item.expectedQty;
|
||||||
|
boxedQty = item.boxedQty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = expectedQty - boxedQty;
|
||||||
|
final alreadyCompleted = remaining <= 0;
|
||||||
|
|
||||||
|
_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;
|
||||||
|
_attachmentExpectedQty = expectedQty;
|
||||||
|
_attachmentBoxedQty = boxedQty;
|
||||||
|
_phase = BoxingPhase.scanned;
|
||||||
|
_isDuplicateBoxNo = false;
|
||||||
|
_editingBox = null;
|
||||||
|
_completedJumpBoxNo = null;
|
||||||
|
_statusOverrideText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-fill: box_no = maxBoxNo+1, quantity = remaining
|
||||||
|
if (alreadyCompleted) {
|
||||||
|
_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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _processAutoScanCodes(List<String> codes) async {
|
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -6,6 +6,53 @@ extension _BoxingSubmitPart on _BoxingPageState {
|
|||||||
Future<bool> _submit() async {
|
Future<bool> _submit() async {
|
||||||
if (!_canSubmit) return false;
|
if (!_canSubmit) return false;
|
||||||
|
|
||||||
|
final attachCatId = _attachmentCategoryId;
|
||||||
|
if (attachCatId != null) {
|
||||||
|
// Attachment boxing submit
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
final action = await _apiActions.saveAttachmentBox(
|
||||||
|
zongpaiNo: _zongpaiNo!,
|
||||||
|
categoryId: attachCatId,
|
||||||
|
boxNo: int.parse(_boxNoController.text),
|
||||||
|
quantity: int.parse(_quantityController.text),
|
||||||
|
);
|
||||||
|
if (!mounted) return false;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
|
if (action.success) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
|
// Reset to waiting for next scan
|
||||||
|
setState(() {
|
||||||
|
_zongpaiNo = null;
|
||||||
|
_phase = BoxingPhase.waiting;
|
||||||
|
_attachmentCategoryId = null;
|
||||||
|
_attachmentCategoryName = null;
|
||||||
|
});
|
||||||
|
_showStatusOverride(
|
||||||
|
'附件装箱成功',
|
||||||
|
StatusDotColor.green,
|
||||||
|
const Duration(milliseconds: 1500),
|
||||||
|
);
|
||||||
|
_requestFocus();
|
||||||
|
return true;
|
||||||
|
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||||
|
_showStatusOverride(
|
||||||
|
action.errorMessage ?? '该箱号已存在同类型附件',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||||
|
_showStatusOverride(
|
||||||
|
action.errorMessage ?? '提交失败',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final boxNo = int.parse(_boxNoController.text);
|
final boxNo = int.parse(_boxNoController.text);
|
||||||
final quantity = int.parse(_quantityController.text);
|
final quantity = int.parse(_quantityController.text);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
|||||||
final int remainingQuantity;
|
final int remainingQuantity;
|
||||||
final String? zongpaiNo;
|
final String? zongpaiNo;
|
||||||
final int? completedJumpBoxNo;
|
final int? completedJumpBoxNo;
|
||||||
|
final bool attachmentPending;
|
||||||
|
final int attachmentPendingCount;
|
||||||
final VoidCallback onJumpToCompletedBox;
|
final VoidCallback onJumpToCompletedBox;
|
||||||
|
|
||||||
const BoxingNoticeBanners({
|
const BoxingNoticeBanners({
|
||||||
@@ -26,6 +28,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
|||||||
required this.remainingQuantity,
|
required this.remainingQuantity,
|
||||||
required this.zongpaiNo,
|
required this.zongpaiNo,
|
||||||
required this.completedJumpBoxNo,
|
required this.completedJumpBoxNo,
|
||||||
|
this.attachmentPending = false,
|
||||||
|
this.attachmentPendingCount = 0,
|
||||||
required this.onJumpToCompletedBox,
|
required this.onJumpToCompletedBox,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +80,21 @@ class BoxingNoticeBanners extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (attachmentPending) {
|
||||||
|
banners.add(
|
||||||
|
_NoticeBanner(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.warning_amber,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.amber.shade900,
|
||||||
|
),
|
||||||
|
text: '本单有附件,尚未到齐(剩 $attachmentPendingCount 种)',
|
||||||
|
background: Colors.amber.shade50,
|
||||||
|
foreground: Colors.amber.shade900,
|
||||||
|
border: Colors.amber.shade200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||||
banners.add(
|
banners.add(
|
||||||
_NoticeBanner(
|
_NoticeBanner(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:pad_scanner/services/scanner_service.dart';
|
import 'package:pad_scanner/services/scanner_service.dart';
|
||||||
import 'package:pad_scanner/services/code_parser.dart';
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
import 'package:pad_scanner/services/api_service.dart';
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
import 'package:pad_scanner/services/app_config_service.dart';
|
||||||
import 'package:pad_scanner/services/boxing_context.dart';
|
import 'package:pad_scanner/services/boxing_context.dart';
|
||||||
import 'package:pad_scanner/services/feedback_service.dart';
|
import 'package:pad_scanner/services/feedback_service.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
||||||
@@ -16,6 +17,7 @@ import 'package:pad_scanner/pages/boxing/boxing_calculations.dart'
|
|||||||
as boxing_calculations;
|
as boxing_calculations;
|
||||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
||||||
|
import 'package:pad_scanner/pages/boxing/dialogs/attachment_category_picker.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart';
|
import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
|
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
|
||||||
@@ -107,6 +109,19 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
// 多码凑箱:已完成装箱时可跳转的箱号
|
// 多码凑箱:已完成装箱时可跳转的箱号
|
||||||
int? _completedJumpBoxNo;
|
int? _completedJumpBoxNo;
|
||||||
|
|
||||||
|
// 附件装箱上下文
|
||||||
|
int? _attachmentCategoryId; // currently selected attachment category
|
||||||
|
// ignore: unused_field -- for display in status bar (consumed by later task)
|
||||||
|
String? _attachmentCategoryName;
|
||||||
|
// ignore: unused_field -- expected_qty for this category (later task)
|
||||||
|
int? _attachmentExpectedQty;
|
||||||
|
// ignore: unused_field -- already boxed qty for this category (later task)
|
||||||
|
int? _attachmentBoxedQty;
|
||||||
|
|
||||||
|
/// Whether to show the attachment-pending banner.
|
||||||
|
bool _attachmentPending = false;
|
||||||
|
int _attachmentPendingCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -148,6 +163,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _baseUrl() async {
|
||||||
|
final url = await AppConfigService().getString('api_url');
|
||||||
|
if (url == null || url.isEmpty) return null;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
@@ -244,6 +265,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
_isDuplicateBoxNo = false;
|
_isDuplicateBoxNo = false;
|
||||||
_statusOverrideText = null;
|
_statusOverrideText = null;
|
||||||
_statusOverrideDot = null;
|
_statusOverrideDot = null;
|
||||||
|
_attachmentCategoryId = null;
|
||||||
|
_attachmentCategoryName = null;
|
||||||
|
_attachmentExpectedQty = null;
|
||||||
|
_attachmentBoxedQty = null;
|
||||||
|
_attachmentPending = false;
|
||||||
|
_attachmentPendingCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 重复箱号检测 ===
|
// === 重复箱号检测 ===
|
||||||
@@ -367,6 +394,8 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
remainingQuantity: _remainingQuantity,
|
remainingQuantity: _remainingQuantity,
|
||||||
zongpaiNo: _zongpaiNo,
|
zongpaiNo: _zongpaiNo,
|
||||||
completedJumpBoxNo: _completedJumpBoxNo,
|
completedJumpBoxNo: _completedJumpBoxNo,
|
||||||
|
attachmentPending: _attachmentPending,
|
||||||
|
attachmentPendingCount: _attachmentPendingCount,
|
||||||
onJumpToCompletedBox: () => this._jumpToCompletedBox(),
|
onJumpToCompletedBox: () => this._jumpToCompletedBox(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
|
||||||
|
/// Show a single-category picker. Returns the selected category, or null.
|
||||||
|
Future<AttachmentCategoryData?> showAttachmentCategoryPicker({
|
||||||
|
required BuildContext context,
|
||||||
|
required List<AttachmentCategoryData> categories,
|
||||||
|
}) {
|
||||||
|
return showDialog<AttachmentCategoryData>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _CategoryPickerDialog(categories: categories),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryPickerDialog extends StatefulWidget {
|
||||||
|
final List<AttachmentCategoryData> categories;
|
||||||
|
const _CategoryPickerDialog({required this.categories});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CategoryPickerDialog> createState() => _CategoryPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryPickerDialogState extends State<_CategoryPickerDialog> {
|
||||||
|
int? _selectedId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('选择附件类型'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: RadioGroup<int>(
|
||||||
|
groupValue: _selectedId,
|
||||||
|
onChanged: (v) => setState(() => _selectedId = v),
|
||||||
|
child: widget.categories.length <= 6
|
||||||
|
? Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.categories
|
||||||
|
.map(
|
||||||
|
(cat) => RadioListTile<int>(
|
||||||
|
dense: true,
|
||||||
|
title: Text(cat.name),
|
||||||
|
value: cat.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.categories
|
||||||
|
.map(
|
||||||
|
(cat) => RadioListTile<int>(
|
||||||
|
dense: true,
|
||||||
|
title: Text(cat.name),
|
||||||
|
value: cat.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _selectedId != null
|
||||||
|
? () {
|
||||||
|
final cat = widget.categories.firstWhere(
|
||||||
|
(c) => c.id == _selectedId,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, cat);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
|
||||||
|
class AttachmentConfigDialogResult {
|
||||||
|
final String determination; // 'has' or 'none'
|
||||||
|
final List<AttachmentConfigItemData> items; // empty if 'none'
|
||||||
|
|
||||||
|
const AttachmentConfigDialogResult({
|
||||||
|
required this.determination,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the attachment configuration dialog for an order.
|
||||||
|
///
|
||||||
|
/// [categories] — the full active category list from GET /attachment/categories.
|
||||||
|
/// [prefillItems] — existing attachment status items (for pre-fill when
|
||||||
|
/// determination is already 'has' due to earlier attachment arrival).
|
||||||
|
/// [erpQuantity] — default expected_qty per selected type.
|
||||||
|
Future<AttachmentConfigDialogResult?> showAttachmentConfigDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required String zongpaiNo,
|
||||||
|
required List<AttachmentCategoryData> categories,
|
||||||
|
required List<AttachmentStatusItemData> prefillItems,
|
||||||
|
required int erpQuantity,
|
||||||
|
}) {
|
||||||
|
return showDialog<AttachmentConfigDialogResult>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => _AttachmentConfigDialog(
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
categories: categories,
|
||||||
|
prefillItems: prefillItems,
|
||||||
|
erpQuantity: erpQuantity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttachmentConfigDialog extends StatefulWidget {
|
||||||
|
final String zongpaiNo;
|
||||||
|
final List<AttachmentCategoryData> categories;
|
||||||
|
final List<AttachmentStatusItemData> prefillItems;
|
||||||
|
final int erpQuantity;
|
||||||
|
|
||||||
|
const _AttachmentConfigDialog({
|
||||||
|
required this.zongpaiNo,
|
||||||
|
required this.categories,
|
||||||
|
required this.prefillItems,
|
||||||
|
required this.erpQuantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AttachmentConfigDialog> createState() =>
|
||||||
|
_AttachmentConfigDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttachmentConfigDialogState extends State<_AttachmentConfigDialog> {
|
||||||
|
late Map<int, bool> _selected; // category_id → checked
|
||||||
|
late Map<int, TextEditingController> _controllers; // category_id → qty
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selected = {};
|
||||||
|
_controllers = {};
|
||||||
|
final prefillSet = <int>{};
|
||||||
|
for (final item in widget.prefillItems) {
|
||||||
|
_selected[item.categoryId] = true;
|
||||||
|
prefillSet.add(item.categoryId);
|
||||||
|
}
|
||||||
|
for (final cat in widget.categories) {
|
||||||
|
if (!_selected.containsKey(cat.id)) {
|
||||||
|
_selected[cat.id] = prefillSet.contains(cat.id);
|
||||||
|
}
|
||||||
|
final ctrl = TextEditingController(
|
||||||
|
text: prefillSet.contains(cat.id)
|
||||||
|
? widget.prefillItems
|
||||||
|
.firstWhere((i) => i.categoryId == cat.id)
|
||||||
|
.expectedQty
|
||||||
|
.toString()
|
||||||
|
: widget.erpQuantity.toString(),
|
||||||
|
);
|
||||||
|
_controllers[cat.id] = ctrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final ctrl in _controllers.values) {
|
||||||
|
ctrl.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasAnyChecked = _selected.values.any((v) => v);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('${widget.zongpaiNo} — 附件配置'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 500,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'请勾选本订单携带的附件类型,并确认每种数量:',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...widget.categories.map(
|
||||||
|
(cat) => CheckboxListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(cat.name, style: const TextStyle(fontSize: 14)),
|
||||||
|
subtitle: Row(
|
||||||
|
children: [
|
||||||
|
const Text('应到: ', style: TextStyle(fontSize: 12)),
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: TextField(
|
||||||
|
controller: _controllers[cat.id],
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
enabled: _selected[cat.id] == true,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
vertical: 4,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
value: _selected[cat.id] ?? false,
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _selected[cat.id] = v ?? false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
AttachmentConfigDialogResult(determination: 'none', items: []),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('无附件'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: hasAnyChecked
|
||||||
|
? () {
|
||||||
|
final items = <AttachmentConfigItemData>[];
|
||||||
|
for (final cat in widget.categories) {
|
||||||
|
if (_selected[cat.id] == true) {
|
||||||
|
final qty =
|
||||||
|
int.tryParse(_controllers[cat.id]!.text.trim()) ??
|
||||||
|
widget.erpQuantity;
|
||||||
|
items.add(
|
||||||
|
AttachmentConfigItemData(
|
||||||
|
categoryId: cat.id,
|
||||||
|
expectedQty: qty,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
AttachmentConfigDialogResult(
|
||||||
|
determination: 'has',
|
||||||
|
items: items,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: const Text('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ extension _RegistrationDialogsPart on _RegistrationPageState {
|
|||||||
void _showLocationConflictDialog({
|
void _showLocationConflictDialog({
|
||||||
required List<PaichaOverviewItem> onShelfItems,
|
required List<PaichaOverviewItem> onShelfItems,
|
||||||
required List<PaichaOverviewItem> transferredItems,
|
required List<PaichaOverviewItem> transferredItems,
|
||||||
|
List<PaichaOverviewItem> tempStoredItems = const [],
|
||||||
}) {
|
}) {
|
||||||
final contentParts = <Widget>[];
|
final contentParts = <Widget>[];
|
||||||
|
|
||||||
@@ -142,6 +143,29 @@ extension _RegistrationDialogsPart on _RegistrationPageState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tempStoredItems.isNotEmpty) {
|
||||||
|
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||||
|
contentParts.add(const Divider());
|
||||||
|
contentParts.add(const SizedBox(height: 4));
|
||||||
|
}
|
||||||
|
contentParts.add(
|
||||||
|
Text(
|
||||||
|
'暂存中',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: const Color(0xFF7B1FA2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final item in tempStoredItems) {
|
||||||
|
contentParts.addAll([
|
||||||
|
Text('总排号:${item.zongpaiNo}'),
|
||||||
|
Text('暂存货位:${item.locationCode ?? "未知"}'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contentParts.add(const SizedBox(height: 4));
|
contentParts.add(const SizedBox(height: 4));
|
||||||
contentParts.add(
|
contentParts.add(
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// ignore_for_file: invalid_use_of_protected_member
|
||||||
|
|
||||||
|
part of '../../registration_page.dart';
|
||||||
|
|
||||||
|
extension _RegistrationAttachmentPart on _RegistrationPageState {
|
||||||
|
/// Handle FJ- attachment scan: pick category, then register location.
|
||||||
|
Future<void> _handleAttachmentScan(String zongpaiNo) async {
|
||||||
|
if (_crossPaichaPending) {
|
||||||
|
_triggerDoubleVibration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseUrl = await _baseUrl();
|
||||||
|
if (baseUrl == null || !mounted) return;
|
||||||
|
|
||||||
|
// 1. Fetch categories (cache in the page state)
|
||||||
|
final catResult = await _apiService.fetchAttachmentCategories(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
);
|
||||||
|
if (!catResult.success || !mounted) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
|
_showStatusOverride(
|
||||||
|
catResult.errorMessage ?? '加载附件类型失败',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final categories = catResult.categories;
|
||||||
|
|
||||||
|
// 2. Pick category (single choice)
|
||||||
|
final picked = await showAttachmentCategoryPicker(
|
||||||
|
context: context,
|
||||||
|
categories: categories,
|
||||||
|
);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
|
||||||
|
// 3. Now treat this zongpai as "scanned" with the picked category
|
||||||
|
// For the registration flow: append scanned zongpai (for location binding)
|
||||||
|
// and store category context for the submit path
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store the last-scanned attachment category for submit
|
||||||
|
_pendingAttachmentCategory = picked.id;
|
||||||
|
_clearStatusOverride();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Refresh the overview (the zongpai is now a product scan for display,
|
||||||
|
// attachment status will be shown via a separate banner/tag)
|
||||||
|
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||||
|
if (shouldRefresh) {
|
||||||
|
_loadOverview(zongpaiNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a product scan with attachment-config integration.
|
||||||
|
/// Called from the existing _handleZongpaiScan when a product code is scanned.
|
||||||
|
Future<void> _checkAndConfigureAttachment(String zongpaiNo) async {
|
||||||
|
final baseUrl = await _baseUrl();
|
||||||
|
if (baseUrl == null || !mounted) return;
|
||||||
|
|
||||||
|
// Fetch attachment status for this zongpai
|
||||||
|
final status = await _apiService.fetchAttachmentStatus(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!status.success) {
|
||||||
|
// Network error or other — defer to product scan flow without blocking
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.determination == 'none') {
|
||||||
|
// Already determined none — nothing to configure
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.determination == 'undetermined' ||
|
||||||
|
status.determination == 'has') {
|
||||||
|
// Fetch categories for the dialog
|
||||||
|
final catResult = await _apiService.fetchAttachmentCategories(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
);
|
||||||
|
if (!catResult.success || !mounted) return;
|
||||||
|
|
||||||
|
// Fetch ERP quantity for default expected_qty
|
||||||
|
final overview = _overview;
|
||||||
|
final erpQty = overview?.success == true
|
||||||
|
? overview!.items
|
||||||
|
.where((i) => i.zongpaiNo == zongpaiNo)
|
||||||
|
.fold<int>(0, (sum, i) => sum + i.quantity)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
final result = await showAttachmentConfigDialog(
|
||||||
|
context: context,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
categories: catResult.categories,
|
||||||
|
prefillItems: status.items,
|
||||||
|
erpQuantity: erpQty > 0 ? erpQty : 1,
|
||||||
|
);
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
|
||||||
|
// Submit config
|
||||||
|
final configResult = await _apiService.putAttachmentConfig(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
determination: result.determination,
|
||||||
|
items: result.items,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!configResult.success) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||||
|
_showStatusOverride(
|
||||||
|
configResult.errorMessage ?? '配置失败',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit attachment location for the given zongpai.
|
||||||
|
Future<bool> _submitAttachmentLocation(
|
||||||
|
String baseUrl,
|
||||||
|
String zongpaiNo,
|
||||||
|
int categoryId,
|
||||||
|
) async {
|
||||||
|
final result = await _apiService.registerAttachmentLocation(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
categoryId: categoryId,
|
||||||
|
locationCode: _locationCode!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return false;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return true;
|
||||||
|
} else if (result.isDuplicate) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||||
|
if (result.conflictInfo != null) {
|
||||||
|
final msg =
|
||||||
|
'附件 $zongpaiNo 已上架至 ${result.conflictInfo!['location_code']}';
|
||||||
|
_showStatusOverride(
|
||||||
|
msg,
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else if (result.isAlreadyOffShelf) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||||
|
_showStatusOverride(
|
||||||
|
result.errorMessage ?? '该附件已下架至转运区域,不可重新上架',
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||||
|
_feedbackService.trigger(
|
||||||
|
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||||
|
);
|
||||||
|
_showStatusOverride(
|
||||||
|
result.errorMessage ?? '提交失败',
|
||||||
|
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ extension _RegistrationOverviewPart on _RegistrationPageState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find all scanned items that are in temp storage.
|
||||||
|
List<PaichaOverviewItem> _findTempStoredItemsInScanned() {
|
||||||
|
return registration_calculations.findTempStoredItemsInScanned(
|
||||||
|
overview: _overview,
|
||||||
|
zongpaiNos: _zongpaiNos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _baseUrl() async {
|
Future<String?> _baseUrl() async {
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
|||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case CodeType.zongpaiNo:
|
case CodeType.zongpaiNo:
|
||||||
_handleZongpaiScan(parsed.value);
|
_handleZongpaiScan(parsed.value);
|
||||||
|
case CodeType.attachmentCode:
|
||||||
|
_handleAttachmentScan(parsed.value);
|
||||||
case CodeType.locationNormal:
|
case CodeType.locationNormal:
|
||||||
case CodeType.locationTransit:
|
case CodeType.locationTransit:
|
||||||
|
case CodeType.locationTempStorage:
|
||||||
_handleLocationScan(parsed.value, parsed.type);
|
_handleLocationScan(parsed.value, parsed.type);
|
||||||
case CodeType.invalid:
|
case CodeType.invalid:
|
||||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
@@ -114,6 +117,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
|||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
_loadOverview(zongpaiNo);
|
_loadOverview(zongpaiNo);
|
||||||
}
|
}
|
||||||
|
// Trigger attachment config check in background (modal dialog if needed)
|
||||||
|
unawaited(_checkAndConfigureAttachment(zongpaiNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _triggerDoubleVibration() {
|
void _triggerDoubleVibration() {
|
||||||
|
|||||||
@@ -52,4 +52,7 @@ extension _RegistrationStatusPart on _RegistrationPageState {
|
|||||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||||
|
|
||||||
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||||
|
|
||||||
|
bool get _isTempStorageTarget =>
|
||||||
|
isTempStorageTarget(_locationType, _locationCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,23 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For temp storage target: block if any scanned item is transferred or temp_stored
|
||||||
|
// (on_shelf items are allowed — temp storage acts as shelf change)
|
||||||
|
if (_isTempStorageTarget) {
|
||||||
|
final transferredItems = _findTransferredItemsInScanned();
|
||||||
|
final tempStoredItems = _findTempStoredItemsInScanned();
|
||||||
|
if (transferredItems.isNotEmpty || tempStoredItems.isNotEmpty) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||||
|
_showLocationConflictDialog(
|
||||||
|
onShelfItems: [],
|
||||||
|
transferredItems: transferredItems,
|
||||||
|
tempStoredItems: tempStoredItems,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
|
if (_mode == RegistrationMode.multiCode && _zongpaiNos.length > 1) {
|
||||||
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -47,7 +64,7 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pre-check: block entire batch if any item already has a location binding
|
// Pre-check: block entire batch if any item already has a location binding
|
||||||
if (!_isTransitTarget) {
|
if (!_isTransitTarget && !_isTempStorageTarget) {
|
||||||
final onShelfItems = _findOnShelfItemsInScanned();
|
final onShelfItems = _findOnShelfItemsInScanned();
|
||||||
final transferredItems = _findTransferredItemsInScanned();
|
final transferredItems = _findTransferredItemsInScanned();
|
||||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||||
@@ -93,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||||
|
// Attachment scan: submit via POST /attachment/location instead
|
||||||
|
final pendingCat = _pendingAttachmentCategory;
|
||||||
|
if (pendingCat != null) {
|
||||||
|
final ok = await _submitAttachmentLocation(
|
||||||
|
baseUrl,
|
||||||
|
zongpaiNo,
|
||||||
|
pendingCat,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
_pendingAttachmentCategory = null;
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
|
setState(() {
|
||||||
|
_zongpaiNos.remove(zongpaiNo);
|
||||||
|
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||||
|
_locationCode = null;
|
||||||
|
_locationType = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_showStatusOverride(
|
||||||
|
'附件上架成功',
|
||||||
|
StatusDotColor.green,
|
||||||
|
const Duration(milliseconds: 1500),
|
||||||
|
);
|
||||||
|
await _refreshCurrentOverview();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final result = await _apiService.registerLocation(
|
final result = await _apiService.registerLocation(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
zongpaiNo: zongpaiNo,
|
zongpaiNo: zongpaiNo,
|
||||||
@@ -117,13 +166,15 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNos.remove(zongpaiNo);
|
_zongpaiNos.remove(zongpaiNo);
|
||||||
if (_mode == RegistrationMode.singleCode) {
|
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||||
_locationCode = null;
|
_locationCode = null;
|
||||||
_locationType = null;
|
_locationType = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final msg = result.isOffShelfSuccess
|
final msg = result.isOffShelfSuccess
|
||||||
? '下架成功'
|
? '下架成功'
|
||||||
|
: _isTempStorageTarget
|
||||||
|
? '变更货架成功'
|
||||||
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
||||||
_showStatusOverride(
|
_showStatusOverride(
|
||||||
msg,
|
msg,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ class RegistrationOverviewStats {
|
|||||||
final int totalCount;
|
final int totalCount;
|
||||||
final int shelvedCount;
|
final int shelvedCount;
|
||||||
final int transferredCount;
|
final int transferredCount;
|
||||||
|
final int tempStoredCount;
|
||||||
|
|
||||||
const RegistrationOverviewStats({
|
const RegistrationOverviewStats({
|
||||||
required this.totalCount,
|
required this.totalCount,
|
||||||
required this.shelvedCount,
|
required this.shelvedCount,
|
||||||
required this.transferredCount,
|
required this.transferredCount,
|
||||||
|
required this.tempStoredCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,17 +33,21 @@ RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
|||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
shelvedCount: 0,
|
shelvedCount: 0,
|
||||||
transferredCount: 0,
|
transferredCount: 0,
|
||||||
|
tempStoredCount: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var shelvedCount = 0;
|
var shelvedCount = 0;
|
||||||
var transferredCount = 0;
|
var transferredCount = 0;
|
||||||
|
var tempStoredCount = 0;
|
||||||
for (final item in overview!.items) {
|
for (final item in overview!.items) {
|
||||||
switch (item.status) {
|
switch (item.status) {
|
||||||
case 'on_shelf':
|
case 'on_shelf':
|
||||||
shelvedCount++;
|
shelvedCount++;
|
||||||
case 'transferred':
|
case 'transferred':
|
||||||
transferredCount++;
|
transferredCount++;
|
||||||
|
case 'temp_stored':
|
||||||
|
tempStoredCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
|||||||
totalCount: overview.totalCount,
|
totalCount: overview.totalCount,
|
||||||
shelvedCount: shelvedCount,
|
shelvedCount: shelvedCount,
|
||||||
transferredCount: transferredCount,
|
transferredCount: transferredCount,
|
||||||
|
tempStoredCount: tempStoredCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +98,17 @@ List<PaichaOverviewItem> findTransferredItemsInScanned({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<PaichaOverviewItem> findTempStoredItemsInScanned({
|
||||||
|
required PaichaOverviewResult? overview,
|
||||||
|
required Iterable<String> zongpaiNos,
|
||||||
|
}) {
|
||||||
|
return _findScannedItemsByStatus(
|
||||||
|
overview: overview,
|
||||||
|
zongpaiNos: zongpaiNos,
|
||||||
|
status: 'temp_stored',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool hasAnyLocatedInScanned({
|
bool hasAnyLocatedInScanned({
|
||||||
required PaichaOverviewResult? overview,
|
required PaichaOverviewResult? overview,
|
||||||
required Iterable<String> zongpaiNos,
|
required Iterable<String> zongpaiNos,
|
||||||
@@ -100,7 +118,9 @@ bool hasAnyLocatedInScanned({
|
|||||||
return overview!.items.any(
|
return overview!.items.any(
|
||||||
(item) =>
|
(item) =>
|
||||||
scannedSet.contains(item.zongpaiNo) &&
|
scannedSet.contains(item.zongpaiNo) &&
|
||||||
(item.status == 'on_shelf' || item.status == 'transferred'),
|
(item.status == 'on_shelf' ||
|
||||||
|
item.status == 'transferred' ||
|
||||||
|
item.status == 'temp_stored'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +128,13 @@ Color registrationBarColor({
|
|||||||
required String status,
|
required String status,
|
||||||
required bool isScanned,
|
required bool isScanned,
|
||||||
required bool isTransitTarget,
|
required bool isTransitTarget,
|
||||||
|
bool isTempStorageTarget = false,
|
||||||
}) {
|
}) {
|
||||||
if (isScanned) {
|
if (isScanned) {
|
||||||
final isConflict = isTransitTarget
|
final isConflict = isTransitTarget
|
||||||
? status == 'transferred'
|
? status == 'transferred'
|
||||||
|
: isTempStorageTarget
|
||||||
|
? (status == 'transferred' || status == 'temp_stored')
|
||||||
: (status == 'on_shelf' || status == 'transferred');
|
: (status == 'on_shelf' || status == 'transferred');
|
||||||
return isConflict ? Colors.red : const Color(0xFF43A047);
|
return isConflict ? Colors.red : const Color(0xFF43A047);
|
||||||
}
|
}
|
||||||
@@ -119,6 +142,8 @@ Color registrationBarColor({
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'on_shelf':
|
case 'on_shelf':
|
||||||
return const Color(0xFF2196F3);
|
return const Color(0xFF2196F3);
|
||||||
|
case 'temp_stored':
|
||||||
|
return const Color(0xFF7B1FA2);
|
||||||
case 'transferred':
|
case 'transferred':
|
||||||
return const Color(0xFFFF9800);
|
return const Color(0xFFFF9800);
|
||||||
default:
|
default:
|
||||||
@@ -130,10 +155,13 @@ Color registrationRowBgColor({
|
|||||||
required String status,
|
required String status,
|
||||||
required bool isScanned,
|
required bool isScanned,
|
||||||
required bool isTransitTarget,
|
required bool isTransitTarget,
|
||||||
|
bool isTempStorageTarget = false,
|
||||||
}) {
|
}) {
|
||||||
if (isScanned) {
|
if (isScanned) {
|
||||||
final isConflict = isTransitTarget
|
final isConflict = isTransitTarget
|
||||||
? status == 'transferred'
|
? status == 'transferred'
|
||||||
|
: isTempStorageTarget
|
||||||
|
? (status == 'transferred' || status == 'temp_stored')
|
||||||
: (status == 'on_shelf' || status == 'transferred');
|
: (status == 'on_shelf' || status == 'transferred');
|
||||||
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
|
||||||
}
|
}
|
||||||
@@ -141,6 +169,8 @@ Color registrationRowBgColor({
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'on_shelf':
|
case 'on_shelf':
|
||||||
return const Color(0xFFE3F2FD);
|
return const Color(0xFFE3F2FD);
|
||||||
|
case 'temp_stored':
|
||||||
|
return const Color(0xFFF3E5F5);
|
||||||
case 'transferred':
|
case 'transferred':
|
||||||
return const Color(0xFFFFF3E0);
|
return const Color(0xFFFFF3E0);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ part of '../../registration_page.dart';
|
|||||||
extension _RegistrationWidgetsPart on _RegistrationPageState {
|
extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||||
String _locationLabel(CodeType? type) {
|
String _locationLabel(CodeType? type) {
|
||||||
if (type == CodeType.locationTransit) return '转运区域';
|
if (type == CodeType.locationTransit) return '转运区域';
|
||||||
|
if (type == CodeType.locationTempStorage) return '暂存货架';
|
||||||
return '普通货架';
|
return '普通货架';
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _locationLabelColor(CodeType? type) {
|
Color _locationLabelColor(CodeType? type) {
|
||||||
if (type == CodeType.locationTransit) return Colors.orange;
|
if (type == CodeType.locationTransit) return Colors.orange;
|
||||||
|
if (type == CodeType.locationTempStorage) return const Color(0xFF7B1FA2);
|
||||||
return Colors.blue;
|
return Colors.blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +110,12 @@ extension _RegistrationWidgetsPart on _RegistrationPageState {
|
|||||||
const Color(0xFFE65100),
|
const Color(0xFFE65100),
|
||||||
hasData,
|
hasData,
|
||||||
),
|
),
|
||||||
|
_buildStatTag(
|
||||||
|
'暂存 ${stats.tempStoredCount}',
|
||||||
|
const Color(0xFFF3E5F5),
|
||||||
|
const Color(0xFF7B1FA2),
|
||||||
|
hasData,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -293,11 +301,13 @@ extension _RegistrationWidgetsPart on _RegistrationPageState {
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
isScanned: isScanned,
|
isScanned: isScanned,
|
||||||
isTransitTarget: _isTransitTarget,
|
isTransitTarget: _isTransitTarget,
|
||||||
|
isTempStorageTarget: _isTempStorageTarget,
|
||||||
);
|
);
|
||||||
final bgColor = registration_calculations.registrationRowBgColor(
|
final bgColor = registration_calculations.registrationRowBgColor(
|
||||||
status: item.status,
|
status: item.status,
|
||||||
isScanned: isScanned,
|
isScanned: isScanned,
|
||||||
isTransitTarget: _isTransitTarget,
|
isTransitTarget: _isTransitTarget,
|
||||||
|
isTempStorageTarget: _isTempStorageTarget,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import 'package:pad_scanner/widgets/status_bar.dart';
|
|||||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart'
|
import 'package:pad_scanner/pages/registration/registration_calculations.dart'
|
||||||
as registration_calculations;
|
as registration_calculations;
|
||||||
|
import 'package:pad_scanner/pages/registration/dialogs/attachment_config_dialog.dart';
|
||||||
|
import 'package:pad_scanner/pages/registration/dialogs/attachment_category_picker.dart';
|
||||||
|
|
||||||
part 'registration/parts/registration_scan_part.dart';
|
part 'registration/parts/registration_scan_part.dart';
|
||||||
part 'registration/parts/registration_status_part.dart';
|
part 'registration/parts/registration_status_part.dart';
|
||||||
part 'registration/parts/registration_overview_part.dart';
|
part 'registration/parts/registration_overview_part.dart';
|
||||||
part 'registration/parts/registration_submit_part.dart';
|
part 'registration/parts/registration_submit_part.dart';
|
||||||
|
part 'registration/parts/registration_attachment_part.dart';
|
||||||
part 'registration/parts/registration_mode_part.dart';
|
part 'registration/parts/registration_mode_part.dart';
|
||||||
part 'registration/dialogs/registration_dialogs_part.dart';
|
part 'registration/dialogs/registration_dialogs_part.dart';
|
||||||
part 'registration/widgets/registration_widgets_part.dart';
|
part 'registration/widgets/registration_widgets_part.dart';
|
||||||
@@ -63,6 +66,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
/// Whether a cross-paicha warning dialog is currently showing.
|
/// Whether a cross-paicha warning dialog is currently showing.
|
||||||
bool _crossPaichaPending = false;
|
bool _crossPaichaPending = false;
|
||||||
|
|
||||||
|
/// Pending attachment category ID set by the FJ- scan branch.
|
||||||
|
/// Consumed by the submit path to call POST /attachment/location
|
||||||
|
/// instead of POST /location.
|
||||||
|
int? _pendingAttachmentCategory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ class BoxInfoResult {
|
|||||||
final List<BoxDetailData> existingBoxes;
|
final List<BoxDetailData> existingBoxes;
|
||||||
final int maxBoxNo;
|
final int maxBoxNo;
|
||||||
final int suggestedBoxNo;
|
final int suggestedBoxNo;
|
||||||
|
final Map<String, dynamic>? attachmentSummary;
|
||||||
|
|
||||||
BoxInfoResult({
|
BoxInfoResult({
|
||||||
required this.success,
|
required this.success,
|
||||||
@@ -207,6 +208,7 @@ class BoxInfoResult {
|
|||||||
this.existingBoxes = const [],
|
this.existingBoxes = const [],
|
||||||
this.maxBoxNo = 0,
|
this.maxBoxNo = 0,
|
||||||
this.suggestedBoxNo = 1,
|
this.suggestedBoxNo = 1,
|
||||||
|
this.attachmentSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||||
@@ -232,6 +234,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,
|
||||||
|
attachmentSummary: json['attachment_summary'] as Map<String, dynamic>?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +302,169 @@ class BoxDeleteResult {
|
|||||||
BoxDeleteResult({required this.success, this.errorMessage});
|
BoxDeleteResult({required this.success, this.errorMessage});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 附件模块数据类 ===
|
||||||
|
|
||||||
|
class AttachmentCategoryData {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final int sortOrder;
|
||||||
|
|
||||||
|
AttachmentCategoryData({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AttachmentCategoryData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AttachmentCategoryData(
|
||||||
|
id: json['id'] as int,
|
||||||
|
name: json['name'] as String,
|
||||||
|
sortOrder: json['sort_order'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentCategoryResult {
|
||||||
|
final bool success;
|
||||||
|
final String? errorMessage;
|
||||||
|
final List<AttachmentCategoryData> categories;
|
||||||
|
|
||||||
|
AttachmentCategoryResult({
|
||||||
|
required this.success,
|
||||||
|
this.errorMessage,
|
||||||
|
this.categories = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentStatusItemData {
|
||||||
|
final int categoryId;
|
||||||
|
final String name;
|
||||||
|
final int expectedQty;
|
||||||
|
final int boxedQty;
|
||||||
|
final String? locationCode;
|
||||||
|
final bool complete;
|
||||||
|
|
||||||
|
AttachmentStatusItemData({
|
||||||
|
required this.categoryId,
|
||||||
|
required this.name,
|
||||||
|
required this.expectedQty,
|
||||||
|
required this.boxedQty,
|
||||||
|
this.locationCode,
|
||||||
|
required this.complete,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AttachmentStatusItemData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AttachmentStatusItemData(
|
||||||
|
categoryId: json['category_id'] as int,
|
||||||
|
name: json['name'] as String,
|
||||||
|
expectedQty: json['expected_qty'] as int,
|
||||||
|
boxedQty: json['boxed_qty'] as int,
|
||||||
|
locationCode: json['location_code']?.toString(),
|
||||||
|
complete: json['complete'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentStatusResult {
|
||||||
|
final bool success;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String determination;
|
||||||
|
final bool allComplete;
|
||||||
|
final List<AttachmentStatusItemData> items;
|
||||||
|
|
||||||
|
AttachmentStatusResult({
|
||||||
|
required this.success,
|
||||||
|
this.errorMessage,
|
||||||
|
this.determination = 'undetermined',
|
||||||
|
this.allComplete = false,
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentConfigItemData {
|
||||||
|
final int categoryId;
|
||||||
|
final int expectedQty;
|
||||||
|
|
||||||
|
const AttachmentConfigItemData({
|
||||||
|
required this.categoryId,
|
||||||
|
required this.expectedQty,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'category_id': categoryId,
|
||||||
|
'expected_qty': expectedQty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentConfigResult {
|
||||||
|
final bool success;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? errorCode;
|
||||||
|
final String determination;
|
||||||
|
final bool allComplete;
|
||||||
|
final List<AttachmentStatusItemData> items;
|
||||||
|
|
||||||
|
AttachmentConfigResult({
|
||||||
|
required this.success,
|
||||||
|
this.errorMessage,
|
||||||
|
this.errorCode,
|
||||||
|
this.determination = 'undetermined',
|
||||||
|
this.allComplete = false,
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentLocationResult {
|
||||||
|
final bool success;
|
||||||
|
final bool isDuplicate;
|
||||||
|
final bool isAlreadyOffShelf;
|
||||||
|
final String? errorMessage;
|
||||||
|
final String? locationCode;
|
||||||
|
final String? previousLocation;
|
||||||
|
final Map<String, dynamic>? conflictInfo;
|
||||||
|
|
||||||
|
AttachmentLocationResult({
|
||||||
|
required this.success,
|
||||||
|
this.isDuplicate = false,
|
||||||
|
this.isAlreadyOffShelf = false,
|
||||||
|
this.errorMessage,
|
||||||
|
this.locationCode,
|
||||||
|
this.previousLocation,
|
||||||
|
this.conflictInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentBoxResult {
|
||||||
|
final bool success;
|
||||||
|
final bool isDuplicate;
|
||||||
|
final String? errorMessage;
|
||||||
|
final int? boxItemId;
|
||||||
|
final String? paichanNo;
|
||||||
|
final int? boxNo;
|
||||||
|
final String? zongpaiNo;
|
||||||
|
final int? categoryId;
|
||||||
|
final int? quantity;
|
||||||
|
|
||||||
|
AttachmentBoxResult({
|
||||||
|
required this.success,
|
||||||
|
this.isDuplicate = false,
|
||||||
|
this.errorMessage,
|
||||||
|
this.boxItemId,
|
||||||
|
this.paichanNo,
|
||||||
|
this.boxNo,
|
||||||
|
this.zongpaiNo,
|
||||||
|
this.categoryId,
|
||||||
|
this.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentBoxDeleteResult {
|
||||||
|
final bool success;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
AttachmentBoxDeleteResult({required this.success, this.errorMessage});
|
||||||
|
}
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final Duration timeout;
|
final Duration timeout;
|
||||||
@@ -534,4 +700,341 @@ class ApiService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询附件类型列表 — GET /CargoTrace/attachment/categories
|
||||||
|
Future<AttachmentCategoryResult> fetchAttachmentCategories({
|
||||||
|
required String baseUrl,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/categories');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final list = (jsonDecode(response.body) as List)
|
||||||
|
.map(
|
||||||
|
(e) => AttachmentCategoryData.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return AttachmentCategoryResult(success: true, categories: list);
|
||||||
|
}
|
||||||
|
return AttachmentCategoryResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '加载失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentCategoryResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询附件状态 — GET /CargoTrace/attachment/status
|
||||||
|
Future<AttachmentStatusResult> fetchAttachmentStatus({
|
||||||
|
required String baseUrl,
|
||||||
|
required String zongpaiNo,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$baseUrl/CargoTrace/attachment/status',
|
||||||
|
).replace(queryParameters: {'zongpai_no': zongpaiNo});
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final items =
|
||||||
|
(body['items'] as List?)
|
||||||
|
?.map(
|
||||||
|
(e) => AttachmentStatusItemData.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return AttachmentStatusResult(
|
||||||
|
success: true,
|
||||||
|
determination: body['determination']?.toString() ?? 'undetermined',
|
||||||
|
allComplete: body['all_complete'] as bool? ?? false,
|
||||||
|
items: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AttachmentStatusResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '查询失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentStatusResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 定稿附件清单 — PUT /CargoTrace/attachment/config
|
||||||
|
Future<AttachmentConfigResult> putAttachmentConfig({
|
||||||
|
required String baseUrl,
|
||||||
|
required String zongpaiNo,
|
||||||
|
required String determination,
|
||||||
|
required List<AttachmentConfigItemData> items,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/config');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.put(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'zongpai_no': zongpaiNo,
|
||||||
|
'determination': determination,
|
||||||
|
'items': items.map((i) => i.toJson()).toList(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final respItems =
|
||||||
|
(body['items'] as List?)
|
||||||
|
?.map(
|
||||||
|
(e) => AttachmentStatusItemData.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return AttachmentConfigResult(
|
||||||
|
success: true,
|
||||||
|
determination: body['determination']?.toString() ?? 'has',
|
||||||
|
allComplete: body['all_complete'] as bool? ?? false,
|
||||||
|
items: respItems,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentConfigResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '配置失败',
|
||||||
|
errorCode: body['error_code']?.toString(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentConfigResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件上架 — POST /CargoTrace/attachment/location
|
||||||
|
Future<AttachmentLocationResult> registerAttachmentLocation({
|
||||||
|
required String baseUrl,
|
||||||
|
required String zongpaiNo,
|
||||||
|
required int categoryId,
|
||||||
|
required String locationCode,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/location');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'zongpai_no': zongpaiNo,
|
||||||
|
'category_id': categoryId,
|
||||||
|
'location_code': locationCode,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: true,
|
||||||
|
locationCode: body['location_code']?.toString(),
|
||||||
|
previousLocation: body['previous_location']?.toString(),
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
);
|
||||||
|
case 409:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final errorCode = body['error_code']?.toString();
|
||||||
|
if (errorCode == 'DUPLICATE_LOCATION') {
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
isDuplicate: true,
|
||||||
|
conflictInfo: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (errorCode == 'ALREADY_OFF_SHELF') {
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
isAlreadyOffShelf: true,
|
||||||
|
errorMessage: body['message']?.toString(),
|
||||||
|
conflictInfo: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '提交失败',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '提交失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentLocationResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 附件装箱 — POST /CargoTrace/attachment/box
|
||||||
|
Future<AttachmentBoxResult> saveAttachmentBox({
|
||||||
|
required String baseUrl,
|
||||||
|
required String zongpaiNo,
|
||||||
|
required int categoryId,
|
||||||
|
required int boxNo,
|
||||||
|
required int quantity,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'zongpai_no': zongpaiNo,
|
||||||
|
'category_id': categoryId,
|
||||||
|
'box_no': boxNo,
|
||||||
|
'quantity': quantity,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: true,
|
||||||
|
boxItemId: body['box_item_id'] as int?,
|
||||||
|
paichanNo: body['paichan_no']?.toString(),
|
||||||
|
boxNo: body['box_no'] as int?,
|
||||||
|
zongpaiNo: body['zongpai_no']?.toString(),
|
||||||
|
categoryId: body['category_id'] as int?,
|
||||||
|
quantity: body['quantity'] as int?,
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '未找到该总排号或附件类型',
|
||||||
|
);
|
||||||
|
case 409:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
isDuplicate: true,
|
||||||
|
errorMessage: body['message']?.toString(),
|
||||||
|
paichanNo: body['paichan_no']?.toString(),
|
||||||
|
boxNo: body['box_no'] as int?,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '提交失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentBoxResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改附件装箱明细 — PATCH /CargoTrace/attachment/box/{itemId}
|
||||||
|
Future<AttachmentBoxResult> updateAttachmentBox({
|
||||||
|
required String baseUrl,
|
||||||
|
required int boxItemId,
|
||||||
|
required int boxNo,
|
||||||
|
required int quantity,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box/$boxItemId');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.patch(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: true,
|
||||||
|
boxItemId: body['box_item_id'] as int?,
|
||||||
|
paichanNo: body['paichan_no']?.toString(),
|
||||||
|
boxNo: body['box_no'] as int?,
|
||||||
|
zongpaiNo: body['zongpai_no']?.toString(),
|
||||||
|
categoryId: body['category_id'] as int?,
|
||||||
|
quantity: body['quantity'] as int?,
|
||||||
|
);
|
||||||
|
case 400:
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||||
|
);
|
||||||
|
case 404:
|
||||||
|
return AttachmentBoxResult(success: false, errorMessage: '指定装箱明细不存在');
|
||||||
|
default:
|
||||||
|
return AttachmentBoxResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '修改失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentBoxResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除附件装箱明细 — DELETE /CargoTrace/attachment/box/{itemId}
|
||||||
|
Future<AttachmentBoxDeleteResult> deleteAttachmentBox({
|
||||||
|
required String baseUrl,
|
||||||
|
required int boxItemId,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box/$boxItemId');
|
||||||
|
try {
|
||||||
|
final response = await _client
|
||||||
|
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||||
|
.timeout(timeout);
|
||||||
|
switch (response.statusCode) {
|
||||||
|
case 200:
|
||||||
|
return AttachmentBoxDeleteResult(success: true);
|
||||||
|
case 404:
|
||||||
|
return AttachmentBoxDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '指定装箱明细不存在',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return AttachmentBoxDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '删除失败 (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return AttachmentBoxDeleteResult(
|
||||||
|
success: false,
|
||||||
|
errorMessage: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
// lib/services/code_parser.dart
|
// lib/services/code_parser.dart
|
||||||
class CodeParser {
|
class CodeParser {
|
||||||
static final _zongpaiRegex = RegExp(
|
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
|
||||||
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
|
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
||||||
);
|
static final _transitRegex = RegExp(r'^TRANS-');
|
||||||
static final _locationRegex = RegExp(
|
static final _attachmentRegex = RegExp(
|
||||||
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
|
r'^FJ-(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$',
|
||||||
);
|
|
||||||
static final _transitRegex = RegExp(
|
|
||||||
r'^TRANS-',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
static ParseResult parse(String code) {
|
static ParseResult parse(String code) {
|
||||||
@@ -16,6 +13,10 @@ class CodeParser {
|
|||||||
return ParseResult(type: CodeType.invalid, value: code);
|
return ParseResult(type: CodeType.invalid, value: code);
|
||||||
}
|
}
|
||||||
final normalized = trimmed.toUpperCase();
|
final normalized = trimmed.toUpperCase();
|
||||||
|
if (_attachmentRegex.hasMatch(normalized)) {
|
||||||
|
final zongpai = normalized.substring(3); // strip "FJ-"
|
||||||
|
return ParseResult(type: CodeType.attachmentCode, value: zongpai);
|
||||||
|
}
|
||||||
if (_transitRegex.hasMatch(normalized)) {
|
if (_transitRegex.hasMatch(normalized)) {
|
||||||
return ParseResult(type: CodeType.locationTransit, value: normalized);
|
return ParseResult(type: CodeType.locationTransit, value: normalized);
|
||||||
}
|
}
|
||||||
@@ -23,16 +24,31 @@ class CodeParser {
|
|||||||
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
|
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
|
||||||
}
|
}
|
||||||
if (_locationRegex.hasMatch(normalized)) {
|
if (_locationRegex.hasMatch(normalized)) {
|
||||||
|
if (normalized.startsWith('B')) {
|
||||||
|
return ParseResult(
|
||||||
|
type: CodeType.locationTempStorage,
|
||||||
|
value: normalized,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ParseResult(type: CodeType.locationNormal, value: normalized);
|
return ParseResult(type: CodeType.locationNormal, value: normalized);
|
||||||
}
|
}
|
||||||
return ParseResult(type: CodeType.invalid, value: code);
|
return ParseResult(type: CodeType.invalid, value: code);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isLocation(CodeType type) =>
|
static bool isLocation(CodeType type) =>
|
||||||
type == CodeType.locationNormal || type == CodeType.locationTransit;
|
type == CodeType.locationNormal ||
|
||||||
|
type == CodeType.locationTransit ||
|
||||||
|
type == CodeType.locationTempStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
|
enum CodeType {
|
||||||
|
zongpaiNo,
|
||||||
|
attachmentCode,
|
||||||
|
locationNormal,
|
||||||
|
locationTransit,
|
||||||
|
locationTempStorage,
|
||||||
|
invalid,
|
||||||
|
}
|
||||||
|
|
||||||
class ParseResult {
|
class ParseResult {
|
||||||
final CodeType type;
|
final CodeType type;
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
|||||||
(locationCode?.startsWith('TRANS-') ?? false);
|
(locationCode?.startsWith('TRANS-') ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
|
||||||
|
return locationType == CodeType.locationTempStorage ||
|
||||||
|
(locationCode?.startsWith('B') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
String registrationSubmitLabel({
|
String registrationSubmitLabel({
|
||||||
required bool isTransitTarget,
|
required bool isTransitTarget,
|
||||||
required bool isMultiCode,
|
required bool isMultiCode,
|
||||||
required int zongpaiCount,
|
required int zongpaiCount,
|
||||||
}) {
|
}) {
|
||||||
if (!isTransitTarget) {
|
if (!isTransitTarget) {
|
||||||
return isMultiCode && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
return isMultiCode && zongpaiCount > 1
|
||||||
|
? '批量上架($zongpaiCount 条)'
|
||||||
|
: '确 认 上 架';
|
||||||
}
|
}
|
||||||
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,248 @@ void main() {
|
|||||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ApiService attachment APIs', () {
|
||||||
|
test('fetchAttachmentCategories parses list', () async {
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode([
|
||||||
|
{'id': 1, 'name': '检验证书', 'sort_order': 1},
|
||||||
|
{'id': 2, 'name': '增发', 'sort_order': 2},
|
||||||
|
]),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.fetchAttachmentCategories(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.categories.length, 2);
|
||||||
|
expect(result.categories.first.name, '检验证书');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchAttachmentCategories handles network failure', () async {
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
throw Exception('Connection refused');
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.fetchAttachmentCategories(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.errorMessage, '网络异常,请检查网络连接');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchAttachmentStatus parses undetermined zongpai', () async {
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'zongpai_no': '26BW0011',
|
||||||
|
'determination': 'undetermined',
|
||||||
|
'all_complete': false,
|
||||||
|
'items': [],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.fetchAttachmentStatus(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
zongpaiNo: '26BW0011',
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.determination, 'undetermined');
|
||||||
|
expect(result.items, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('putAttachmentConfig sends full payload', () async {
|
||||||
|
late Map<String, dynamic> body;
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
final req = request as http.Request;
|
||||||
|
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'zongpai_no': '26BW0011',
|
||||||
|
'determination': 'has',
|
||||||
|
'all_complete': false,
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'category_id': 1,
|
||||||
|
'name': '检验证书',
|
||||||
|
'expected_qty': 80,
|
||||||
|
'boxed_qty': 0,
|
||||||
|
'location_code': null,
|
||||||
|
'complete': false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.putAttachmentConfig(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
zongpaiNo: '26BW0011',
|
||||||
|
determination: 'has',
|
||||||
|
items: [AttachmentConfigItemData(categoryId: 1, expectedQty: 80)],
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.items.single.complete, isFalse);
|
||||||
|
expect(body['items'], [
|
||||||
|
{'category_id': 1, 'expected_qty': 80},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerAttachmentLocation returns 200 with data', () async {
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'zongpai_no': '26BW0011',
|
||||||
|
'category_id': 1,
|
||||||
|
'location_code': 'A01-01-01',
|
||||||
|
'created_at': '2026-08-12T10:00:00',
|
||||||
|
'previous_location': null,
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.registerAttachmentLocation(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
zongpaiNo: '26BW0011',
|
||||||
|
categoryId: 1,
|
||||||
|
locationCode: 'A01-01-01',
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.locationCode, 'A01-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveAttachmentBox sends request with category_id', () async {
|
||||||
|
late Map<String, dynamic> body;
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
final req = request as http.Request;
|
||||||
|
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'box_item_id': 1,
|
||||||
|
'paichan_no': 'W00009',
|
||||||
|
'box_no': 10,
|
||||||
|
'zongpai_no': '26BW0011',
|
||||||
|
'category_id': 1,
|
||||||
|
'quantity': 30,
|
||||||
|
'created_at': '2026-08-12T10:00:00',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.saveAttachmentBox(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
zongpaiNo: '26BW0011',
|
||||||
|
categoryId: 1,
|
||||||
|
boxNo: 10,
|
||||||
|
quantity: 30,
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.boxItemId, 1);
|
||||||
|
expect(body['category_id'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveAttachmentBox returns duplicate on 409', () async {
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'error_code': 'DUPLICATE_BOX_NO',
|
||||||
|
'message': '排产号 W00009 下箱号 10 已存在',
|
||||||
|
'paichan_no': 'W00009',
|
||||||
|
'box_no': 10,
|
||||||
|
}),
|
||||||
|
409,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.saveAttachmentBox(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
zongpaiNo: '26BW0011',
|
||||||
|
categoryId: 1,
|
||||||
|
boxNo: 10,
|
||||||
|
quantity: 30,
|
||||||
|
);
|
||||||
|
expect(result.success, isFalse);
|
||||||
|
expect(result.isDuplicate, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateAttachmentBox sends PATCH with box_no and quantity', () async {
|
||||||
|
late Map<String, dynamic> body;
|
||||||
|
final mockClient = _MockClient((request) async {
|
||||||
|
final req = request as http.Request;
|
||||||
|
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'box_item_id': 1,
|
||||||
|
'paichan_no': 'W00009',
|
||||||
|
'box_no': 11,
|
||||||
|
'zongpai_no': '26BW0011',
|
||||||
|
'category_id': 1,
|
||||||
|
'quantity': 20,
|
||||||
|
'updated_at': '2026-08-12T10:00:00',
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final result = await svc.updateAttachmentBox(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
boxItemId: 1,
|
||||||
|
boxNo: 11,
|
||||||
|
quantity: 20,
|
||||||
|
);
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(body, {'box_no': 11, 'quantity': 20});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteAttachmentBox handles success and not found', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final mockClient = _MockClient((request) 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({
|
||||||
|
'error_code': 'BOX_ITEM_NOT_FOUND',
|
||||||
|
'message': '指定装箱明细不存在',
|
||||||
|
}),
|
||||||
|
404,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final svc = ApiService(client: mockClient);
|
||||||
|
final ok = await svc.deleteAttachmentBox(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
boxItemId: 1,
|
||||||
|
);
|
||||||
|
final missing = await svc.deleteAttachmentBox(
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
boxItemId: 999,
|
||||||
|
);
|
||||||
|
expect(ok.success, isTrue);
|
||||||
|
expect(missing.success, isFalse);
|
||||||
|
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MockClient extends http.BaseClient {
|
class _MockClient extends http.BaseClient {
|
||||||
|
|||||||
@@ -62,4 +62,50 @@ void main() {
|
|||||||
expect(CodeParser.isLocation(CodeType.invalid), false);
|
expect(CodeParser.isLocation(CodeType.invalid), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('CodeParser.parse attachmentCode (FJ-)', () {
|
||||||
|
test('identifies FJ- with zongpai B type', () {
|
||||||
|
final result = CodeParser.parse('FJ-26B1');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26B1');
|
||||||
|
});
|
||||||
|
test('identifies FJ- with zongpai C type', () {
|
||||||
|
final result = CodeParser.parse('FJ-26C12');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26C12');
|
||||||
|
});
|
||||||
|
test('identifies FJ- with zongpai T type', () {
|
||||||
|
final result = CodeParser.parse('FJ-26T3');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26T3');
|
||||||
|
});
|
||||||
|
test('identifies FJ- with zongpai BW 4-digit', () {
|
||||||
|
final result = CodeParser.parse('FJ-26BW0001');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26BW0001');
|
||||||
|
});
|
||||||
|
test('identifies FJ- with zongpai CW 4-digit', () {
|
||||||
|
final result = CodeParser.parse('FJ-26CW0015');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26CW0015');
|
||||||
|
});
|
||||||
|
test('normalizes lowercase fj- to uppercase', () {
|
||||||
|
final result = CodeParser.parse('fj-26b1');
|
||||||
|
expect(result.type, CodeType.attachmentCode);
|
||||||
|
expect(result.value, '26B1');
|
||||||
|
});
|
||||||
|
test('rejects FJ without hyphen', () {
|
||||||
|
expect(CodeParser.parse('FJ26B1').type, CodeType.invalid);
|
||||||
|
});
|
||||||
|
test('rejects FJ- with invalid zongpai', () {
|
||||||
|
expect(CodeParser.parse('FJ-HELLO').type, CodeType.invalid);
|
||||||
|
});
|
||||||
|
test('rejects FJ- with BW short serial', () {
|
||||||
|
expect(CodeParser.parse('FJ-26BW01').type, CodeType.invalid);
|
||||||
|
});
|
||||||
|
test('attaches original raw code with FJ- prefix for display', () {
|
||||||
|
final result = CodeParser.parse('FJ-26B42360');
|
||||||
|
expect(result.value, '26B42360');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user