diff --git a/lib/pages/boxing/boxing_api_actions.dart b/lib/pages/boxing/boxing_api_actions.dart new file mode 100644 index 0000000..7e397ed --- /dev/null +++ b/lib/pages/boxing/boxing_api_actions.dart @@ -0,0 +1,150 @@ +import 'package:pad_scanner/services/api_service.dart'; +import 'package:pad_scanner/services/app_config_service.dart'; + +enum BoxingActionErrorKind { missingApiUrl, network, duplicate, backend } + +class BoxingActionResult { + final bool success; + final T? data; + final BoxingActionErrorKind? errorKind; + final String? errorMessage; + final int? boxNo; + + const BoxingActionResult._({ + required this.success, + this.data, + this.errorKind, + this.errorMessage, + this.boxNo, + }); + + const BoxingActionResult.ok(T data) : this._(success: true, data: data); + + const BoxingActionResult.error({ + required BoxingActionErrorKind kind, + required String message, + int? boxNo, + }) : this._( + success: false, + errorKind: kind, + errorMessage: message, + boxNo: boxNo, + ); +} + +typedef ApiUrlLoader = Future Function(); + +class BoxingApiActions { + static const networkErrorMessage = '网络异常,请检查网络连接'; + static const missingApiUrlMessage = '未配置 API 地址,请前往设置'; + + final ApiService _apiService; + final ApiUrlLoader _loadApiUrl; + + BoxingApiActions({required ApiService apiService, ApiUrlLoader? loadApiUrl}) + : _apiService = apiService, + _loadApiUrl = + loadApiUrl ?? (() => AppConfigService().getString('api_url')); + + Future> fetchBoxInfo(String zongpai) async { + final baseUrl = await _requireBaseUrl(); + if (baseUrl == null) return _missingApiUrl(); + + final result = await _apiService.fetchBoxInfo( + baseUrl: baseUrl, + zongpaiNo: zongpai, + ); + if (result.success) return BoxingActionResult.ok(result); + return BoxingActionResult.error( + kind: _kindForMessage(result.errorMessage), + message: result.errorMessage ?? '查询失败', + ); + } + + Future> saveBoxRecord({ + required String zongpaiNo, + required int boxNo, + required int quantity, + }) async { + final baseUrl = await _requireBaseUrl(); + if (baseUrl == null) return _missingApiUrl(); + + final result = await _apiService.saveBoxRecord( + baseUrl: baseUrl, + zongpaiNo: zongpaiNo, + boxNo: boxNo, + quantity: quantity, + ); + return _saveResult(result, fallbackMessage: '提交失败'); + } + + Future> updateBoxRecord({ + required int boxItemId, + required int boxNo, + required int quantity, + }) async { + final baseUrl = await _requireBaseUrl(); + if (baseUrl == null) return _missingApiUrl(); + + final result = await _apiService.updateBoxRecord( + baseUrl: baseUrl, + boxItemId: boxItemId, + boxNo: boxNo, + quantity: quantity, + ); + return _saveResult(result, fallbackMessage: '修改失败'); + } + + Future> deleteBoxRecord({ + required int boxItemId, + }) async { + final baseUrl = await _requireBaseUrl(); + if (baseUrl == null) return _missingApiUrl(); + + final result = await _apiService.deleteBoxRecord( + baseUrl: baseUrl, + boxItemId: boxItemId, + ); + 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; + return baseUrl; + } + + BoxingActionResult _missingApiUrl() { + return const BoxingActionResult.error( + kind: BoxingActionErrorKind.missingApiUrl, + message: missingApiUrlMessage, + ); + } + + BoxingActionResult _saveResult( + BoxSaveResult result, { + required String fallbackMessage, + }) { + if (result.success) return BoxingActionResult.ok(result); + if (result.isDuplicate) { + return BoxingActionResult.error( + kind: BoxingActionErrorKind.duplicate, + message: result.errorMessage ?? fallbackMessage, + boxNo: result.boxNo, + ); + } + return BoxingActionResult.error( + kind: _kindForMessage(result.errorMessage), + message: result.errorMessage ?? fallbackMessage, + ); + } + + BoxingActionErrorKind _kindForMessage(String? message) { + if (message == networkErrorMessage) return BoxingActionErrorKind.network; + return BoxingActionErrorKind.backend; + } +} diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index 702833a..73f1f8c 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:pad_scanner/services/app_config_service.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/boxing_context.dart'; import 'package:pad_scanner/services/feedback_service.dart'; +import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart'; import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart' as box_mutations; import 'package:pad_scanner/pages/boxing/boxing_calculations.dart' @@ -39,6 +39,7 @@ class BoxingPage extends StatefulWidget { class _BoxingPageState extends State with WidgetsBindingObserver { final _scannerService = ScannerService(); final _apiService = ApiService(); + late final BoxingApiActions _apiActions; final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); StreamSubscription? _scanSubscription; @@ -100,6 +101,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); + _apiActions = BoxingApiActions(apiService: _apiService); WidgetsBinding.instance.addObserver(this); final arguments = widget.arguments; if (arguments != null) { @@ -264,38 +266,25 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } Future _queryBoxInfo(String zongpai) async { - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _feedbackService.trigger(FeedbackEvent.submitFailure); - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return false; - } - - final result = await _apiService.fetchBoxInfo( - baseUrl: baseUrl, - zongpaiNo: zongpai, - ); + final action = await _apiActions.fetchBoxInfo(zongpai); if (!mounted) return false; - if (!result.success) { - final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; - _feedbackService.trigger( - isNetwork ? FeedbackEvent.networkError : FeedbackEvent.scanInvalid, - ); + if (!action.success) { + _feedbackService.trigger(switch (action.errorKind) { + BoxingActionErrorKind.network => FeedbackEvent.networkError, + BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure, + _ => FeedbackEvent.scanInvalid, + }); _showStatusOverride( - result.errorMessage ?? '查询失败', - isNetwork ? StatusDotColor.yellow : StatusDotColor.red, + action.errorMessage ?? '查询失败', + _dotForActionError(action.errorKind), const Duration(seconds: 2), ); return false; } + final result = action.data!; // 判断该总牌号是否已全部装箱完毕 final packedQuantity = result.currentZongpaiBoxes.fold( 0, @@ -599,33 +588,19 @@ class _BoxingPageState extends State with WidgetsBindingObserver { Future _submit() async { if (!_canSubmit) return false; - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _feedbackService.trigger(FeedbackEvent.submitFailure); - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return false; - } - final boxNo = int.parse(_boxNoController.text); final quantity = int.parse(_quantityController.text); setState(() => _isSubmitting = true); final editing = _editingBox; - final result = editing == null - ? await _apiService.saveBoxRecord( - baseUrl: baseUrl, + final action = editing == null + ? await _apiActions.saveBoxRecord( zongpaiNo: _zongpaiNo!, boxNo: boxNo, quantity: quantity, ) - : await _apiService.updateBoxRecord( - baseUrl: baseUrl, + : await _apiActions.updateBoxRecord( boxItemId: editing.boxItemId, boxNo: boxNo, quantity: quantity, @@ -635,31 +610,23 @@ class _BoxingPageState extends State with WidgetsBindingObserver { setState(() => _isSubmitting = false); - if (result.success) { + if (action.success) { if (editing == null) { - _onSubmitSuccess(boxNo, quantity, result.boxItemId); + _onSubmitSuccess(boxNo, quantity, action.data!.boxItemId); } else { _onUpdateSuccess(editing, boxNo, quantity); } return true; - } else if (result.isDuplicate) { + } else if (action.errorKind == BoxingActionErrorKind.duplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); _showStatusOverride( - '该总排号在箱号 ${result.boxNo ?? ""} 已存在,请重新输入', + '该总排号在箱号 ${action.boxNo ?? ""} 已存在,请重新输入', StatusDotColor.amber, 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), - ); + _showActionError(action, fallback: '提交失败'); return false; } } @@ -883,20 +850,8 @@ class _BoxingPageState extends State with WidgetsBindingObserver { return; } - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return; - } - setState(() => _isSubmitting = true); - final result = await _apiService.updateBoxRecord( - baseUrl: baseUrl, + final action = await _apiActions.updateBoxRecord( boxItemId: item.boxItemId, boxNo: item.boxNo, quantity: quantity, @@ -904,7 +859,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { if (!mounted) return; setState(() => _isSubmitting = false); - if (result.success) { + if (action.success) { setState(() { _currentZongpaiBoxes = _currentZongpaiBoxes.map((box) { if (box.boxItemId != item.boxItemId) return box; @@ -925,39 +880,18 @@ class _BoxingPageState extends State with WidgetsBindingObserver { const Duration(milliseconds: 1500), ); } else { - final dot = result.isDuplicate - ? StatusDotColor.amber - : StatusDotColor.red; - _showStatusOverride( - result.errorMessage ?? '修改失败', - dot, - const Duration(seconds: 2), - ); + _showActionError(action, fallback: '修改失败'); } } Future _deleteAssignedItem(CurrentZongpaiBoxData item) async { - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return; - } - setState(() => _isSubmitting = true); - final result = await _apiService.deleteBoxRecord( - baseUrl: baseUrl, - boxItemId: item.boxItemId, - ); + final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId); if (!mounted) return; setState(() { _isSubmitting = false; - if (result.success) { + if (action.success) { _currentZongpaiBoxes = _currentZongpaiBoxes .where((box) => box.boxItemId != item.boxItemId) .toList(); @@ -971,18 +905,14 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } }); - if (result.success) { + if (action.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { - _showStatusOverride( - result.errorMessage ?? '删除失败', - StatusDotColor.red, - const Duration(seconds: 2), - ); + _showActionError(action, fallback: '删除失败'); } } @@ -1013,20 +943,8 @@ class _BoxingPageState extends State with WidgetsBindingObserver { return; } - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return; - } - setState(() => _isSubmitting = true); - final result = await _apiService.updateBoxRecord( - baseUrl: baseUrl, + final action = await _apiActions.updateBoxRecord( boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, @@ -1034,7 +952,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { if (!mounted) return; setState(() => _isSubmitting = false); - if (result.success) { + if (action.success) { setState(() { final index = _manyToOnePackedItems.indexWhere( (packed) => packed.boxItemId == item.boxItemId, @@ -1055,14 +973,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { const Duration(milliseconds: 1500), ); } else { - final dot = result.isDuplicate - ? StatusDotColor.amber - : StatusDotColor.red; - _showStatusOverride( - result.errorMessage ?? '修改失败', - dot, - const Duration(seconds: 2), - ); + _showActionError(action, fallback: '修改失败'); } } @@ -1082,27 +993,13 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } Future _deletePackedItem(ManyToOnePackedItem item) async { - final configService = AppConfigService(); - final baseUrl = await configService.getString('api_url') ?? ''; - if (baseUrl.isEmpty) { - _showStatusOverride( - '未配置 API 地址,请前往设置', - StatusDotColor.red, - const Duration(seconds: 2), - ); - return; - } - setState(() => _isSubmitting = true); - final result = await _apiService.deleteBoxRecord( - baseUrl: baseUrl, - boxItemId: item.boxItemId, - ); + final action = await _apiActions.deleteBoxRecord(boxItemId: item.boxItemId); if (!mounted) return; setState(() { _isSubmitting = false; - if (result.success) { + if (action.success) { _manyToOnePackedItems.removeWhere( (packed) => packed.boxItemId == item.boxItemId, ); @@ -1115,18 +1012,14 @@ class _BoxingPageState extends State with WidgetsBindingObserver { } }); - if (result.success) { + if (action.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { - _showStatusOverride( - result.errorMessage ?? '删除失败', - StatusDotColor.red, - const Duration(seconds: 2), - ); + _showActionError(action, fallback: '删除失败'); } } @@ -1141,6 +1034,30 @@ class _BoxingPageState extends State with WidgetsBindingObserver { // === Status bar management === + StatusDotColor _dotForActionError(BoxingActionErrorKind? kind) { + return switch (kind) { + BoxingActionErrorKind.network => StatusDotColor.yellow, + BoxingActionErrorKind.duplicate => StatusDotColor.amber, + _ => StatusDotColor.red, + }; + } + + void _showActionError( + BoxingActionResult action, { + required String fallback, + }) { + _feedbackService.trigger(switch (action.errorKind) { + BoxingActionErrorKind.network => FeedbackEvent.networkError, + BoxingActionErrorKind.duplicate => FeedbackEvent.duplicateBoxNo, + _ => FeedbackEvent.submitFailure, + }); + _showStatusOverride( + action.errorMessage ?? fallback, + _dotForActionError(action.errorKind), + const Duration(seconds: 2), + ); + } + void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { _statusOverrideText = text; diff --git a/test/pages/boxing/boxing_api_actions_test.dart b/test/pages/boxing/boxing_api_actions_test.dart new file mode 100644 index 0000000..16aac48 --- /dev/null +++ b/test/pages/boxing/boxing_api_actions_test.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +void main() { + group('BoxingApiActions', () { + test('returns missingApiUrl when API URL is empty', () async { + final actions = BoxingApiActions( + apiService: ApiService( + client: _MockClient((_) async { + fail('ApiService should not be called without a base URL'); + }), + ), + loadApiUrl: () async => '', + ); + + final result = await actions.fetchBoxInfo('26B1'); + + expect(result.success, isFalse); + expect(result.errorKind, BoxingActionErrorKind.missingApiUrl); + expect(result.errorMessage, BoxingApiActions.missingApiUrlMessage); + }); + + test('fetchBoxInfo passes through successful data', () async { + final actions = BoxingApiActions( + apiService: ApiService( + client: _MockClient((request) async { + expect(request.url.path, '/CargoTrace/box/info'); + expect(request.url.queryParameters['zongpai_no'], '26B1'); + return http.Response( + jsonEncode({ + 'zongpai_no': '26B1', + 'paichan_no': 'W00009', + 'work_order_no': 'WO001', + 'quantity': 8, + 'current_zongpai_boxes': [ + {'box_item_id': 1, 'box_no': 2, 'quantity': 3}, + ], + 'existing_boxes': [], + 'max_box_no': 2, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + loadApiUrl: () async => 'http://localhost', + ); + + final result = await actions.fetchBoxInfo('26B1'); + + expect(result.success, isTrue); + expect(result.data?.paichanNo, 'W00009'); + expect(result.data?.currentZongpaiBoxes.single.boxNo, 2); + }); + + test('saveBoxRecord maps duplicate and preserves box number', () async { + final actions = BoxingApiActions( + apiService: ApiService( + client: _MockClient((_) async { + return http.Response( + jsonEncode({ + 'error_code': 'DUPLICATE_BOX_ITEM', + 'message': '重复装箱', + 'box_no': 7, + }), + 409, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + loadApiUrl: () async => 'http://localhost', + ); + + final result = await actions.saveBoxRecord( + zongpaiNo: '26B1', + boxNo: 7, + quantity: 2, + ); + + expect(result.success, isFalse); + expect(result.errorKind, BoxingActionErrorKind.duplicate); + expect(result.boxNo, 7); + }); + + test('updateBoxRecord maps network errors', () async { + final actions = BoxingApiActions( + apiService: ApiService( + client: _MockClient((_) async { + throw Exception('Connection refused'); + }), + ), + loadApiUrl: () async => 'http://localhost', + ); + + final result = await actions.updateBoxRecord( + boxItemId: 1, + boxNo: 2, + quantity: 3, + ); + + expect(result.success, isFalse); + expect(result.errorKind, BoxingActionErrorKind.network); + expect(result.errorMessage, BoxingApiActions.networkErrorMessage); + }); + + test('deleteBoxRecord passes through success and backend errors', () async { + var calls = 0; + final actions = BoxingApiActions( + apiService: ApiService( + client: _MockClient((_) 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({'message': '指定装箱明细不存在'}), + 404, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ), + loadApiUrl: () async => 'http://localhost', + ); + + final ok = await actions.deleteBoxRecord(boxItemId: 1); + final missing = await actions.deleteBoxRecord(boxItemId: 999); + + expect(ok.success, isTrue); + expect(missing.success, isFalse); + expect(missing.errorKind, BoxingActionErrorKind.backend); + expect(missing.errorMessage, '指定装箱明细不存在'); + }); + }); +} + +class _MockClient extends http.BaseClient { + final Future Function(http.BaseRequest) _handler; + + _MockClient(this._handler); + + @override + Future send(http.BaseRequest request) async { + final response = await _handler(request); + return http.StreamedResponse( + http.ByteStream.fromBytes(response.bodyBytes), + response.statusCode, + headers: response.headers, + reasonPhrase: response.reasonPhrase, + ); + } +}