diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 27dbde0..5b3a1ef 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -299,6 +299,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 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 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 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 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 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 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? 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 +697,341 @@ class ApiService { return false; } } + + /// 查询附件类型列表 — GET /CargoTrace/attachment/categories + Future 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), + ) + .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 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; + final items = + (body['items'] as List?) + ?.map( + (e) => AttachmentStatusItemData.fromJson( + e as Map, + ), + ) + .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 putAttachmentConfig({ + required String baseUrl, + required String zongpaiNo, + required String determination, + required List 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; + final respItems = + (body['items'] as List?) + ?.map( + (e) => AttachmentStatusItemData.fromJson( + e as Map, + ), + ) + .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; + 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 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; + return AttachmentLocationResult( + success: true, + locationCode: body['location_code']?.toString(), + previousLocation: body['previous_location']?.toString(), + ); + case 400: + final body = jsonDecode(response.body) as Map; + return AttachmentLocationResult( + success: false, + errorMessage: body['message']?.toString() ?? '请求参数错误', + ); + case 409: + final body = jsonDecode(response.body) as Map; + 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 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; + 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; + return AttachmentBoxResult( + success: false, + errorMessage: body['message']?.toString() ?? '请求参数错误', + ); + case 404: + return AttachmentBoxResult( + success: false, + errorMessage: '未找到该总排号或附件类型', + ); + case 409: + final body = jsonDecode(response.body) as Map; + 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 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; + 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; + 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 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: '网络异常,请检查网络连接', + ); + } + } } diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart index 854f3b6..9d5d4ca 100644 --- a/test/services/api_service_test.dart +++ b/test/services/api_service_test.dart @@ -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 body; + final mockClient = _MockClient((request) async { + final req = request as http.Request; + body = jsonDecode(req.body) as Map; + 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 body; + final mockClient = _MockClient((request) async { + final req = request as http.Request; + body = jsonDecode(req.body) as Map; + 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 body; + final mockClient = _MockClient((request) async { + final req = request as http.Request; + body = jsonDecode(req.body) as Map; + 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 {