From 6b05d6c0e3958d621b1d99c6e373cbb904638a66 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Wed, 12 Aug 2026 14:56:28 +0800 Subject: [PATCH] feat(android): add attachment config dialog and FJ- scan in registration Co-Authored-By: Claude --- .../dialogs/attachment_category_picker.dart | 83 ++++++++ .../dialogs/attachment_config_dialog.dart | 191 ++++++++++++++++++ .../parts/registration_attachment_part.dart | 185 +++++++++++++++++ .../parts/registration_scan_part.dart | 5 +- .../parts/registration_submit_part.dart | 32 +++ lib/pages/registration_page.dart | 8 + 6 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 lib/pages/registration/dialogs/attachment_category_picker.dart create mode 100644 lib/pages/registration/dialogs/attachment_config_dialog.dart create mode 100644 lib/pages/registration/parts/registration_attachment_part.dart diff --git a/lib/pages/registration/dialogs/attachment_category_picker.dart b/lib/pages/registration/dialogs/attachment_category_picker.dart new file mode 100644 index 0000000..afb9654 --- /dev/null +++ b/lib/pages/registration/dialogs/attachment_category_picker.dart @@ -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 showAttachmentCategoryPicker({ + required BuildContext context, + required List categories, +}) { + return showDialog( + context: context, + builder: (ctx) => _CategoryPickerDialog(categories: categories), + ); +} + +class _CategoryPickerDialog extends StatefulWidget { + final List 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( + 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/registration/dialogs/attachment_config_dialog.dart b/lib/pages/registration/dialogs/attachment_config_dialog.dart new file mode 100644 index 0000000..8890bd1 --- /dev/null +++ b/lib/pages/registration/dialogs/attachment_config_dialog.dart @@ -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 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 showAttachmentConfigDialog({ + required BuildContext context, + required String zongpaiNo, + required List categories, + required List prefillItems, + required int erpQuantity, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _AttachmentConfigDialog( + zongpaiNo: zongpaiNo, + categories: categories, + prefillItems: prefillItems, + erpQuantity: erpQuantity, + ), + ); +} + +class _AttachmentConfigDialog extends StatefulWidget { + final String zongpaiNo; + final List categories; + final List 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 _selected; // category_id → checked + late Map _controllers; // category_id → qty + + @override + void initState() { + super.initState(); + _selected = {}; + _controllers = {}; + final prefillSet = {}; + 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 = []; + 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('确认'), + ), + ], + ); + } +} diff --git a/lib/pages/registration/parts/registration_attachment_part.dart b/lib/pages/registration/parts/registration_attachment_part.dart new file mode 100644 index 0000000..724a145 --- /dev/null +++ b/lib/pages/registration/parts/registration_attachment_part.dart @@ -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 _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 _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(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 _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; + } + } +} diff --git a/lib/pages/registration/parts/registration_scan_part.dart b/lib/pages/registration/parts/registration_scan_part.dart index d6491a7..e4fe0d6 100644 --- a/lib/pages/registration/parts/registration_scan_part.dart +++ b/lib/pages/registration/parts/registration_scan_part.dart @@ -45,8 +45,9 @@ extension _RegistrationScanPart on _RegistrationPageState { final parsed = CodeParser.parse(result.barcode); switch (parsed.type) { case CodeType.zongpaiNo: - case CodeType.attachmentCode: _handleZongpaiScan(parsed.value); + case CodeType.attachmentCode: + _handleAttachmentScan(parsed.value); case CodeType.locationNormal: case CodeType.locationTransit: case CodeType.locationTempStorage: @@ -116,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() { diff --git a/lib/pages/registration/parts/registration_submit_part.dart b/lib/pages/registration/parts/registration_submit_part.dart index bf59476..597ee86 100644 --- a/lib/pages/registration/parts/registration_submit_part.dart +++ b/lib/pages/registration/parts/registration_submit_part.dart @@ -110,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState { } Future _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, diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index 05b97df..77a9a1b 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -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 { /// 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();