From deb038404a989f869812874d17816fa28ad4dbe7 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Fri, 15 May 2026 12:40:10 +0800 Subject: [PATCH] feat: link transit registration to boxing --- lib/pages/boxing_page.dart | 145 +++++++++++++-- lib/pages/registration_page.dart | 180 ++++++++++++++++++- lib/services/registration_linking.dart | 39 ++++ lib/services/scanner_service.dart | 25 +-- test/services/registration_linking_test.dart | 85 +++++++++ 5 files changed, 445 insertions(+), 29 deletions(-) create mode 100644 lib/services/registration_linking.dart create mode 100644 test/services/registration_linking_test.dart diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index d8c0bde..7931437 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; @@ -16,6 +18,16 @@ enum BoxingMode { multiCode, // 多码凑箱 } +class BoxingPageArguments { + final BoxingMode initialMode; + final List autoScanCodes; + + const BoxingPageArguments({ + required this.initialMode, + required this.autoScanCodes, + }); +} + // === 页面阶段 === enum _Phase { @@ -63,7 +75,9 @@ class _ManyToOnePackedItem { // === 主页面 === class BoxingPage extends StatefulWidget { - const BoxingPage({super.key}); + final BoxingPageArguments? arguments; + + const BoxingPage({super.key, this.arguments}); @override State createState() => _BoxingPageState(); @@ -73,6 +87,7 @@ class _BoxingPageState extends State { final _scannerService = ScannerService(); final _apiService = ApiService(); final _feedbackService = FeedbackService(); + StreamSubscription? _scanSubscription; // 模式 BoxingMode _mode = BoxingMode.singleCode; @@ -123,17 +138,29 @@ class _BoxingPageState extends State { String _statusText = '等待扫码'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; + bool _isAutoProcessing = false; + final _autoWarnings = []; @override void initState() { super.initState(); + final arguments = widget.arguments; + if (arguments != null) { + _mode = arguments.initialMode; + } _boxNoController.addListener(_onBoxNoTextChanged); - _scannerService.scanResults.listen(_onScan); + _scanSubscription = _scannerService.scanResults.listen(_onScan); + if (arguments != null && arguments.autoScanCodes.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _processAutoScanCodes(arguments.autoScanCodes); + }); + } } @override void dispose() { _boxNoController.removeListener(_onBoxNoTextChanged); + _scanSubscription?.cancel(); _boxNoController.dispose(); _quantityController.dispose(); _boxNoFocusNode.dispose(); @@ -206,6 +233,10 @@ class _BoxingPageState extends State { // === 扫码处理 === void _onScan(ScanResult result) { + if (_isAutoProcessing) { + return; + } + final parsed = CodeParser.parse(result.barcode); if (parsed.type != CodeType.zongpaiNo) { @@ -224,7 +255,7 @@ class _BoxingPageState extends State { _queryBoxInfo(zongpai); } - Future _queryBoxInfo(String zongpai) async { + Future _queryBoxInfo(String zongpai) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { @@ -234,7 +265,7 @@ class _BoxingPageState extends State { StatusDotColor.red, const Duration(seconds: 2), ); - return; + return false; } final result = await _apiService.fetchBoxInfo( @@ -242,7 +273,7 @@ class _BoxingPageState extends State { zongpaiNo: zongpai, ); - if (!mounted) return; + if (!mounted) return false; if (!result.success) { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; @@ -254,7 +285,7 @@ class _BoxingPageState extends State { isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); - return; + return false; } _feedbackService.trigger(FeedbackEvent.scanValid); @@ -281,6 +312,54 @@ class _BoxingPageState extends State { // 根据模式自动填充 _applyAutoFill(); }); + return true; + } + + Future _processAutoScanCodes(List codes) async { + if (!mounted) return; + setState(() { + _isAutoProcessing = true; + _autoWarnings.clear(); + _statusOverrideText = '正在同步转运数据...'; + _statusOverrideDot = StatusDotColor.orange; + }); + + if (_mode == BoxingMode.singleCode) { + final code = codes.first; + final ok = await _queryBoxInfo(code); + if (!ok && mounted) { + setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略')); + } + } else { + for (final code in codes) { + if (!mounted) return; + final queried = await _queryBoxInfo(code); + if (!queried) { + if (mounted) { + setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略')); + } + continue; + } + if (!_canSubmit) { + if (mounted) { + setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略')); + } + continue; + } + final saved = await _submit(); + if (!saved && mounted) { + setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理')); + } + } + } + + if (!mounted) return; + setState(() { + _isAutoProcessing = false; + _statusOverrideText = null; + _statusOverrideDot = null; + }); + _focusQuantityInput(); } void _applyAutoFill() { @@ -452,8 +531,8 @@ class _BoxingPageState extends State { return byItemId.values.toList(growable: false); } - Future _submit() async { - if (!_canSubmit) return; + Future _submit() async { + if (!_canSubmit) return false; final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; @@ -464,7 +543,7 @@ class _BoxingPageState extends State { StatusDotColor.red, const Duration(seconds: 2), ); - return; + return false; } final boxNo = int.parse(_boxNoController.text); @@ -487,7 +566,7 @@ class _BoxingPageState extends State { quantity: quantity, ); - if (!mounted) return; + if (!mounted) return false; setState(() => _isSubmitting = false); @@ -497,6 +576,7 @@ class _BoxingPageState extends State { } else { _onUpdateSuccess(editing, boxNo, quantity); } + return true; } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); _showStatusOverride( @@ -504,6 +584,7 @@ class _BoxingPageState extends State { StatusDotColor.amber, const Duration(seconds: 2), ); + return false; } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( @@ -514,6 +595,7 @@ class _BoxingPageState extends State { isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); + return false; } } @@ -1103,6 +1185,11 @@ class _BoxingPageState extends State { // === 状态文字 === void _updateBaseStatus() { + if (_isAutoProcessing) { + _statusDot = StatusDotColor.orange; + _statusText = '正在同步转运数据...'; + return; + } if (_isSubmitting) { _statusDot = StatusDotColor.orange; _statusText = '正在提交…'; @@ -1173,7 +1260,7 @@ class _BoxingPageState extends State { Padding( padding: const EdgeInsets.only(right: 4), child: TextButton.icon( - onPressed: _cycleMode, + onPressed: _isAutoProcessing ? null : _cycleMode, icon: const Icon(Icons.swap_horiz, size: 18), label: Text(_modeLabel, style: const TextStyle(fontSize: 13)), style: TextButton.styleFrom( @@ -1187,6 +1274,42 @@ class _BoxingPageState extends State { ), body: Column( children: [ + if (_isAutoProcessing) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: Colors.orange.shade50, + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.orange.shade700, + ), + ), + const SizedBox(width: 8), + Text( + '正在同步转运数据...', + style: TextStyle( + color: Colors.orange.shade900, + fontSize: 13, + ), + ), + ], + ), + ), + if (_autoWarnings.isNotEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: Colors.amber.shade100, + child: Text( + _autoWarnings.join(';'), + style: TextStyle(color: Colors.amber.shade900, fontSize: 13), + ), + ), // 重复箱号警告条(保留,因为这是输入区关联的即时反馈) if (_paichanSwitchNotice != null) Container( diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index 554f09d..722ffdb 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -7,6 +7,8 @@ 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/feedback_service.dart'; +import 'package:pad_scanner/services/registration_linking.dart'; +import 'package:pad_scanner/pages/boxing_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; class RegistrationPage extends StatefulWidget { @@ -44,7 +46,7 @@ class _RegistrationPageState extends State { @override void initState() { super.initState(); - _scanSubscription = _scannerService.scanResults.listen(_onScan); + _startScanListening(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -58,6 +60,27 @@ class _RegistrationPageState extends State { super.dispose(); } + void _startScanListening() { + _scanSubscription ??= _scannerService.scanResults.listen(_onScan); + } + + Future _stopScanListening() async { + final subscription = _scanSubscription; + _scanSubscription = null; + await subscription?.cancel(); + } + + void _restartScanListening() { + _scanSubscription?.cancel(); + _scanSubscription = null; + _startScanListening(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); + } + void _onKeyEvent(KeyEvent event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { _submit(); @@ -78,7 +101,7 @@ class _RegistrationPageState extends State { _locationType = parsed.type; _clearStatusOverride(); }); - } + } else {} case CodeType.invalid: _feedbackService.trigger(FeedbackEvent.scanInvalid); _showStatusOverride( @@ -163,6 +186,8 @@ class _RegistrationPageState extends State { bool get _canSubmit => _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; + bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode); + Future _baseUrl() async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; @@ -239,12 +264,49 @@ class _RegistrationPageState extends State { setState(() => _isSubmitting = true); if (_isLocked && _zongpaiNos.length > 1) { + if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) { + if (!mounted) return; + setState(() => _isSubmitting = false); + _feedbackService.trigger(FeedbackEvent.submitFailure); + _showStatusOverride( + batchPaichaMismatchMessage, + StatusDotColor.red, + const Duration(seconds: 2), + ); + return; + } await _submitBatch(baseUrl); } else { await _submitOne(baseUrl, _zongpaiNos.first); } } + Future _ensureBatchSamePaicha(String baseUrl) async { + final paichaByCode = {}; + final cachedOverview = _overview; + if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) { + for (final item in cachedOverview.items) { + paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!; + } + } + + for (final zongpaiNo in _zongpaiNos) { + if (paichaByCode.containsKey(zongpaiNo)) continue; + final result = await _apiService.fetchPaichaOverview( + baseUrl: baseUrl, + zongpaiNo: zongpaiNo, + ); + if (!result.success || result.paichaNo == null) { + return false; + } + for (final item in result.items) { + paichaByCode[item.zongpaiNo] = result.paichaNo!; + } + } + + return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code])); + } + Future _submitOne(String baseUrl, String zongpaiNo) async { final result = await _apiService.registerLocation( baseUrl: baseUrl, @@ -258,6 +320,16 @@ class _RegistrationPageState extends State { if (result.success) { _feedbackService.trigger(FeedbackEvent.submitSuccess); + if (_isTransitTarget) { + setState(() => _isSubmitting = false); + await _navigateToBoxing( + mode: BoxingMode.singleCode, + codes: [zongpaiNo], + codesToClear: [zongpaiNo], + clearLocation: !_isLocked, + ); + return; + } setState(() { _zongpaiNos.remove(zongpaiNo); if (!_isLocked) { @@ -302,6 +374,7 @@ class _RegistrationPageState extends State { int offShelfCount = 0; final failed = []; final toRemove = []; + final transitCodes = []; for (final zongpaiNo in List.of(_zongpaiNos)) { final result = await _apiService.registerLocation( @@ -315,9 +388,26 @@ class _RegistrationPageState extends State { offShelfCount++; } toRemove.add(zongpaiNo); + if (_isTransitTarget) { + transitCodes.add(zongpaiNo); + } } else { failed.add(zongpaiNo); if (result.isDuplicate) { + if (_isTransitTarget && transitCodes.isNotEmpty) { + setState(() { + _zongpaiNos.removeWhere((item) => toRemove.contains(item)); + _isSubmitting = false; + }); + await _navigateToBoxing( + mode: BoxingMode.multiCode, + codes: transitCodes, + codesToClear: toRemove, + clearLocation: false, + returnMessage: '成功 $successCount 条,失败 ${failed.length} 条', + ); + return; + } setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; @@ -327,6 +417,20 @@ class _RegistrationPageState extends State { return; } if (result.isAlreadyOffShelf) { + if (_isTransitTarget && transitCodes.isNotEmpty) { + setState(() { + _zongpaiNos.removeWhere((item) => toRemove.contains(item)); + _isSubmitting = false; + }); + await _navigateToBoxing( + mode: BoxingMode.multiCode, + codes: transitCodes, + codesToClear: toRemove, + clearLocation: false, + returnMessage: '成功 $successCount 条,失败 ${failed.length} 条', + ); + return; + } setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; @@ -349,6 +453,19 @@ class _RegistrationPageState extends State { _isSubmitting = false; }); + if (_isTransitTarget && transitCodes.isNotEmpty) { + await _navigateToBoxing( + mode: BoxingMode.multiCode, + codes: transitCodes, + codesToClear: toRemove, + clearLocation: failed.isEmpty, + returnMessage: failed.isEmpty + ? null + : '成功 $successCount 条,失败 ${failed.length} 条', + ); + return; + } + if (failed.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitSuccess); final msg = offShelfCount == successCount @@ -370,6 +487,49 @@ class _RegistrationPageState extends State { } } + Future _navigateToBoxing({ + required BoxingMode mode, + required List codes, + required List codesToClear, + required bool clearLocation, + String? returnMessage, + }) async { + await _refreshCurrentOverview(); + if (!mounted) return; + final navigator = Navigator.of(context); + await _stopScanListening(); + await navigator.push( + MaterialPageRoute( + builder: (_) => BoxingPage( + arguments: BoxingPageArguments( + initialMode: mode, + autoScanCodes: codes, + ), + ), + ), + ); + if (!mounted) return; + await Future.delayed(const Duration(milliseconds: 120)); + if (!mounted) return; + _restartScanListening(); + setState(() { + _zongpaiNos.removeWhere((item) => codesToClear.contains(item)); + if (clearLocation) { + _locationCode = null; + _locationType = null; + } + }); + await _refreshCurrentOverview(); + if (!mounted) return; + if (returnMessage != null) { + _showStatusOverride( + returnMessage, + StatusDotColor.red, + const Duration(seconds: 2), + ); + } + } + void _showDuplicateDialog(String zongpaiNo, Map? info) { showDialog( context: context, @@ -498,9 +658,11 @@ class _RegistrationPageState extends State { child: ElevatedButton( onPressed: _canSubmit ? _submit : null, style: ElevatedButton.styleFrom( - backgroundColor: _canSubmit - ? colorScheme.primary - : Colors.grey.shade300, + backgroundColor: registrationSubmitColor( + canSubmit: _canSubmit, + isTransitTarget: _isTransitTarget, + primaryColor: colorScheme.primary, + ), foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, @@ -518,9 +680,11 @@ class _RegistrationPageState extends State { ), ) : Text( - _isLocked && _zongpaiNos.length > 1 - ? '批量上架(${_zongpaiNos.length} 条)' - : '确 认 上 架', + registrationSubmitLabel( + isTransitTarget: _isTransitTarget, + isLocked: _isLocked, + zongpaiCount: _zongpaiNos.length, + ), style: const TextStyle(fontSize: 17), ), ), diff --git a/lib/services/registration_linking.dart b/lib/services/registration_linking.dart new file mode 100644 index 0000000..45d688b --- /dev/null +++ b/lib/services/registration_linking.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:pad_scanner/services/code_parser.dart'; + +const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号'; + +bool isTransitTarget(CodeType? locationType, String? locationCode) { + return locationType == CodeType.locationTransit || + (locationCode?.startsWith('TRANS-') ?? false); +} + +String registrationSubmitLabel({ + required bool isTransitTarget, + required bool isLocked, + required int zongpaiCount, +}) { + if (!isTransitTarget) { + return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架'; + } + return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱'; +} + +Color registrationSubmitColor({ + required bool canSubmit, + required bool isTransitTarget, + required Color primaryColor, +}) { + if (!canSubmit) return Colors.grey.shade300; + return isTransitTarget ? Colors.orange : primaryColor; +} + +bool allSamePaicha(Iterable paichaNos) { + String? first; + for (final paichaNo in paichaNos) { + if (paichaNo == null || paichaNo.isEmpty) return false; + first ??= paichaNo; + if (paichaNo != first) return false; + } + return first != null; +} diff --git a/lib/services/scanner_service.dart b/lib/services/scanner_service.dart index cb7a155..4214aad 100644 --- a/lib/services/scanner_service.dart +++ b/lib/services/scanner_service.dart @@ -9,19 +9,24 @@ class ScanResult { } class ScannerService { - static const _eventChannel = - EventChannel('com.example.pad_scanner/scan'); - - Stream? _scanStream; + static const _eventChannel = EventChannel('com.example.pad_scanner/scan'); + static Stream? _sharedScanStream; Stream get scanResults { - _scanStream ??= _eventChannel + _sharedScanStream ??= _createSharedScanStream(); + return _sharedScanStream!; + } + + static Stream _createSharedScanStream() { + return _eventChannel .receiveBroadcastStream() .map((event) => event as Map) - .map((event) => ScanResult( - barcode: event['barcode'] as String? ?? '', - codeType: event['codeType'] as String? ?? 'UNKNOWN', - )); - return _scanStream!; + .map( + (event) => ScanResult( + barcode: event['barcode'] as String? ?? '', + codeType: event['codeType'] as String? ?? 'UNKNOWN', + ), + ) + .asBroadcastStream(); } } diff --git a/test/services/registration_linking_test.dart b/test/services/registration_linking_test.dart new file mode 100644 index 0000000..51ef84c --- /dev/null +++ b/test/services/registration_linking_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/services/code_parser.dart'; +import 'package:pad_scanner/services/registration_linking.dart'; + +void main() { + group('registration linking helpers', () { + test('detects transit target by type or code prefix', () { + expect(isTransitTarget(CodeType.locationTransit, 'TRANS-01'), isTrue); + expect(isTransitTarget(CodeType.locationNormal, 'TRANS-02'), isTrue); + expect(isTransitTarget(CodeType.locationNormal, 'A01-02-03'), isFalse); + expect(isTransitTarget(null, null), isFalse); + }); + + test('builds submit labels for normal and transit modes', () { + expect( + registrationSubmitLabel( + isTransitTarget: false, + isLocked: false, + zongpaiCount: 1, + ), + '确 认 上 架', + ); + expect( + registrationSubmitLabel( + isTransitTarget: false, + isLocked: true, + zongpaiCount: 3, + ), + '批量上架(3 条)', + ); + expect( + registrationSubmitLabel( + isTransitTarget: true, + isLocked: false, + zongpaiCount: 1, + ), + '转运并装箱', + ); + expect( + registrationSubmitLabel( + isTransitTarget: true, + isLocked: true, + zongpaiCount: 3, + ), + '批量转运并凑箱(3 条)', + ); + }); + + test('uses orange submit color only for enabled transit target', () { + const primary = Colors.blue; + expect( + registrationSubmitColor( + canSubmit: true, + isTransitTarget: true, + primaryColor: primary, + ), + Colors.orange, + ); + expect( + registrationSubmitColor( + canSubmit: true, + isTransitTarget: false, + primaryColor: primary, + ), + primary, + ); + expect( + registrationSubmitColor( + canSubmit: false, + isTransitTarget: true, + primaryColor: primary, + ), + Colors.grey.shade300, + ); + }); + + test('validates all codes belong to one paicha', () { + expect(allSamePaicha(['R00001', 'R00001']), isTrue); + expect(allSamePaicha(['R00001', 'R00002']), isFalse); + expect(allSamePaicha(['R00001', null]), isFalse); + expect(allSamePaicha([]), isFalse); + }); + }); +}