Compare commits
5 Commits
a702f80116
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf974cee92 | ||
|
|
15c29bceb8 | ||
|
|
6b05d6c0e3 | ||
|
|
b7484915c6 | ||
|
|
b383328aed |
@@ -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('确认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ 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:
|
case CodeType.locationTempStorage:
|
||||||
@@ -115,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() {
|
||||||
|
|||||||
@@ -110,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,
|
||||||
|
|||||||
@@ -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: '网络异常,请检查网络连接',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ class CodeParser {
|
|||||||
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
|
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
|
||||||
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
||||||
static final _transitRegex = RegExp(r'^TRANS-');
|
static final _transitRegex = RegExp(r'^TRANS-');
|
||||||
|
static final _attachmentRegex = RegExp(
|
||||||
|
r'^FJ-(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$',
|
||||||
|
);
|
||||||
|
|
||||||
static ParseResult parse(String code) {
|
static ParseResult parse(String code) {
|
||||||
final trimmed = code.trim();
|
final trimmed = code.trim();
|
||||||
@@ -10,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);
|
||||||
}
|
}
|
||||||
@@ -36,6 +43,7 @@ class CodeParser {
|
|||||||
|
|
||||||
enum CodeType {
|
enum CodeType {
|
||||||
zongpaiNo,
|
zongpaiNo,
|
||||||
|
attachmentCode,
|
||||||
locationNormal,
|
locationNormal,
|
||||||
locationTransit,
|
locationTransit,
|
||||||
locationTempStorage,
|
locationTempStorage,
|
||||||
|
|||||||
@@ -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