feat(android): add attachment API methods and result classes

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-08-12 14:51:41 +08:00
parent b383328aed
commit b7484915c6
2 changed files with 742 additions and 0 deletions

View File

@@ -299,6 +299,169 @@ class BoxDeleteResult {
BoxDeleteResult({required this.success, this.errorMessage}); 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<String, dynamic> 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<AttachmentCategoryData> 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<String, dynamic> 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<AttachmentStatusItemData> 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<String, dynamic> 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<AttachmentStatusItemData> 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<String, dynamic>? 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 { class ApiService {
final http.Client _client; final http.Client _client;
final Duration timeout; final Duration timeout;
@@ -534,4 +697,341 @@ class ApiService {
return false; return false;
} }
} }
/// 查询附件类型列表 — GET /CargoTrace/attachment/categories
Future<AttachmentCategoryResult> 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<String, dynamic>),
)
.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<AttachmentStatusResult> 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<String, dynamic>;
final items =
(body['items'] as List?)
?.map(
(e) => AttachmentStatusItemData.fromJson(
e as Map<String, dynamic>,
),
)
.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<AttachmentConfigResult> putAttachmentConfig({
required String baseUrl,
required String zongpaiNo,
required String determination,
required List<AttachmentConfigItemData> 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<String, dynamic>;
final respItems =
(body['items'] as List?)
?.map(
(e) => AttachmentStatusItemData.fromJson(
e as Map<String, dynamic>,
),
)
.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<String, dynamic>;
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<AttachmentLocationResult> 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<String, dynamic>;
return AttachmentLocationResult(
success: true,
locationCode: body['location_code']?.toString(),
previousLocation: body['previous_location']?.toString(),
);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AttachmentLocationResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
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<AttachmentBoxResult> 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<String, dynamic>;
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<String, dynamic>;
return AttachmentBoxResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
);
case 404:
return AttachmentBoxResult(
success: false,
errorMessage: '未找到该总排号或附件类型',
);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
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<AttachmentBoxResult> 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<String, dynamic>;
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<String, dynamic>;
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<AttachmentBoxDeleteResult> 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: '网络异常,请检查网络连接',
);
}
}
} }

View File

@@ -372,6 +372,248 @@ void main() {
expect(missing.errorMessage, '指定装箱明细不存在'); 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<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
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<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
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<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
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 { class _MockClient extends http.BaseClient {