Compare commits
5 Commits
a702f80116
...
dev
| 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 {
|
||||
final baseUrl = await _loadApiUrl() ?? '';
|
||||
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);
|
||||
|
||||
if (parsed.type == CodeType.attachmentCode) {
|
||||
this._handleAttachmentBoxScan(parsed.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type != CodeType.zongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
this._showStatusOverride(
|
||||
@@ -66,6 +71,13 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
: 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(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
@@ -82,6 +94,8 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
_deletingAssignedItemId = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
_attachmentPending = pending;
|
||||
_attachmentPendingCount = pendingCount;
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
@@ -92,6 +106,119 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
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 {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
||||
@@ -6,6 +6,53 @@ extension _BoxingSubmitPart on _BoxingPageState {
|
||||
Future<bool> _submit() async {
|
||||
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 quantity = int.parse(_quantityController.text);
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
||||
final int remainingQuantity;
|
||||
final String? zongpaiNo;
|
||||
final int? completedJumpBoxNo;
|
||||
final bool attachmentPending;
|
||||
final int attachmentPendingCount;
|
||||
final VoidCallback onJumpToCompletedBox;
|
||||
|
||||
const BoxingNoticeBanners({
|
||||
@@ -26,6 +28,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
||||
required this.remainingQuantity,
|
||||
required this.zongpaiNo,
|
||||
required this.completedJumpBoxNo,
|
||||
this.attachmentPending = false,
|
||||
this.attachmentPendingCount = 0,
|
||||
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) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/services/scanner_service.dart';
|
||||
import 'package:pad_scanner/services/code_parser.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/feedback_service.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;
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
||||
import 'package:pad_scanner/pages/boxing/dialogs/attachment_category_picker.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_notice_banners.dart';
|
||||
@@ -107,6 +109,19 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
// 多码凑箱:已完成装箱时可跳转的箱号
|
||||
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
|
||||
void 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
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
@@ -244,6 +265,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
_isDuplicateBoxNo = false;
|
||||
_statusOverrideText = 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,
|
||||
zongpaiNo: _zongpaiNo,
|
||||
completedJumpBoxNo: _completedJumpBoxNo,
|
||||
attachmentPending: _attachmentPending,
|
||||
attachmentPendingCount: _attachmentPendingCount,
|
||||
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) {
|
||||
case CodeType.zongpaiNo:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.attachmentCode:
|
||||
_handleAttachmentScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
case CodeType.locationTempStorage:
|
||||
@@ -115,6 +117,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
// Trigger attachment config check in background (modal dialog if needed)
|
||||
unawaited(_checkAndConfigureAttachment(zongpaiNo));
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
|
||||
@@ -110,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
}
|
||||
|
||||
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(
|
||||
baseUrl: baseUrl,
|
||||
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/registration/registration_calculations.dart'
|
||||
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_status_part.dart';
|
||||
part 'registration/parts/registration_overview_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/dialogs/registration_dialogs_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.
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
@@ -195,6 +195,7 @@ class BoxInfoResult {
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final int suggestedBoxNo;
|
||||
final Map<String, dynamic>? attachmentSummary;
|
||||
|
||||
BoxInfoResult({
|
||||
required this.success,
|
||||
@@ -207,6 +208,7 @@ class BoxInfoResult {
|
||||
this.existingBoxes = const [],
|
||||
this.maxBoxNo = 0,
|
||||
this.suggestedBoxNo = 1,
|
||||
this.attachmentSummary,
|
||||
});
|
||||
|
||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||
@@ -232,6 +234,7 @@ class BoxInfoResult {
|
||||
existingBoxes: boxes,
|
||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||
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});
|
||||
}
|
||||
|
||||
// === 附件模块数据类 ===
|
||||
|
||||
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 {
|
||||
final http.Client _client;
|
||||
final Duration timeout;
|
||||
@@ -534,4 +700,341 @@ class ApiService {
|
||||
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 _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
|
||||
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) {
|
||||
final trimmed = code.trim();
|
||||
@@ -10,6 +13,10 @@ class CodeParser {
|
||||
return ParseResult(type: CodeType.invalid, value: code);
|
||||
}
|
||||
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)) {
|
||||
return ParseResult(type: CodeType.locationTransit, value: normalized);
|
||||
}
|
||||
@@ -36,6 +43,7 @@ class CodeParser {
|
||||
|
||||
enum CodeType {
|
||||
zongpaiNo,
|
||||
attachmentCode,
|
||||
locationNormal,
|
||||
locationTransit,
|
||||
locationTempStorage,
|
||||
|
||||
@@ -372,6 +372,248 @@ void main() {
|
||||
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 {
|
||||
|
||||
@@ -62,4 +62,50 @@ void main() {
|
||||
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