diff --git a/lib/pages/boxing/boxing_api_actions.dart b/lib/pages/boxing/boxing_api_actions.dart index 7e397ed..0fd9c41 100644 --- a/lib/pages/boxing/boxing_api_actions.dart +++ b/lib/pages/boxing/boxing_api_actions.dart @@ -112,6 +112,85 @@ class BoxingApiActions { ); } + Future> 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> 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> 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> + 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 _requireBaseUrl() async { final baseUrl = await _loadApiUrl() ?? ''; if (baseUrl.isEmpty) return null; diff --git a/lib/pages/boxing/dialogs/attachment_category_picker.dart b/lib/pages/boxing/dialogs/attachment_category_picker.dart new file mode 100644 index 0000000..ad40115 --- /dev/null +++ b/lib/pages/boxing/dialogs/attachment_category_picker.dart @@ -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 showBoxingAttachmentCategoryPicker({ + required BuildContext context, + required List categories, +}) { + return showDialog( + context: context, + builder: (ctx) => _BoxingCategoryPickerDialog(categories: categories), + ); +} + +class _BoxingCategoryPickerDialog extends StatefulWidget { + final List 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( + groupValue: _selectedId, + onChanged: (v) => setState(() => _selectedId = v), + child: widget.categories.length <= 6 + ? Column( + mainAxisSize: MainAxisSize.min, + children: widget.categories + .map( + (cat) => RadioListTile( + dense: true, + title: Text(cat.name), + value: cat.id, + ), + ) + .toList(), + ) + : SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.categories + .map( + (cat) => RadioListTile( + 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('确认'), + ), + ], + ); + } +} diff --git a/lib/pages/boxing/parts/boxing_scan_part.dart b/lib/pages/boxing/parts/boxing_scan_part.dart index 602e22a..f4221e7 100644 --- a/lib/pages/boxing/parts/boxing_scan_part.dart +++ b/lib/pages/boxing/parts/boxing_scan_part.dart @@ -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( @@ -92,6 +97,119 @@ extension _BoxingScanPart on _BoxingPageState { return true; } + Future _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 _processAutoScanCodes(List codes) async { if (!mounted) return; setState(() { diff --git a/lib/pages/boxing/parts/boxing_submit_part.dart b/lib/pages/boxing/parts/boxing_submit_part.dart index 0e541af..986dae1 100644 --- a/lib/pages/boxing/parts/boxing_submit_part.dart +++ b/lib/pages/boxing/parts/boxing_submit_part.dart @@ -6,6 +6,53 @@ extension _BoxingSubmitPart on _BoxingPageState { Future _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); diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index 173260b..d521bc5 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -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,15 @@ class _BoxingPageState extends State 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; + @override void initState() { super.initState(); @@ -148,6 +159,12 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } } + Future _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 +261,10 @@ class _BoxingPageState extends State with WidgetsBindingObserver { _isDuplicateBoxNo = false; _statusOverrideText = null; _statusOverrideDot = null; + _attachmentCategoryId = null; + _attachmentCategoryName = null; + _attachmentExpectedQty = null; + _attachmentBoxedQty = null; } // === 重复箱号检测 ===