diff --git a/docs/plans/2026-05-11-boxing-module.md b/docs/plans/2026-05-11-boxing-module.md new file mode 100644 index 0000000..e20fe27 --- /dev/null +++ b/docs/plans/2026-05-11-boxing-module.md @@ -0,0 +1,519 @@ +# 装箱编号模块 Implementation Plan — Flutter + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现 PRD v1.3 中定义的装箱编号模块 Flutter 页面,支持三种装箱模式(一码一箱、一码多箱、多码一箱),包含扫码识别、信息查询、自动填充、重复校验和提交功能。 + +**Architecture:** 在现有 Flutter 应用基础上扩展,使用 setState 管理 UI 状态,EventChannel 接收扫码数据,http 包调用后端 API。新增 BoxingPage 和 BoxingDetailPage 两个页面。 + +**Tech Stack:** Flutter 3.x, Dart, http, shared_preferences, Android native EventChannel (SEUIC scanner) + +--- + +## 页面结构 + +``` +HomePage + ├── 上架登记 (RegistrationPage) ← 已完成 + ├── 装箱编号 (BoxingPage) ← 本次实现 + │ └── 装箱详情 (BoxingDetailPage) ← 子页面 + └── 货架查询 ← 规划中 +``` + +## 状态机设计 + +三种装箱模式对应不同的状态流转: + +``` +一码一箱: 等待扫码 → 扫码后自动填充箱号+数量 → 确认 → 成功(1.5s) → 重置等待扫码 +一码多箱: 等待扫码 → 扫码后自动填充箱号 → 手动填数量 → 确认 → 成功 + → "继续添加": 预填箱号+1、预填上次数量 → 确认 → ... + → "返回": 重置等待扫码 +多码一箱: 等待扫码 → 扫码后填充+锁定箱号+数量 → 确认 → 成功 + → 自动等待下一个扫码 → 箱号锁定 → 自动填充新数量 → 确认 → ... + → "返回": 重置等待扫码 +``` + +--- + +### Task 1: ApiService 扩展 — 装箱 API 方法 + +**Files:** +- Modify: `lib/services/api_service.dart` + +**Step 1: 添加装箱相关的数据类和 API 方法** + +在文件末尾(`ApiService` 类内部)新增以下方法,在文件顶部新增数据类: + +```dart +// === 装箱模块数据类 === + +/// 装箱信息查询结果 +class BoxInfoResult { + final bool success; + final String? errorMessage; + final String? errorCode; + final String? zongpaiNo; + final String? paichanNo; + final int? quantity; + final List existingBoxes; + final int maxBoxNo; + final int suggestedBoxNo; + + BoxInfoResult({ + required this.success, + this.errorMessage, + this.errorCode, + this.zongpaiNo, + this.paichanNo, + this.quantity, + this.existingBoxes = const [], + this.maxBoxNo = 0, + this.suggestedBoxNo = 1, + }); + + factory BoxInfoResult.ok(Map json) { + final boxes = (json['existing_boxes'] as List?) + ?.map((b) => BoxDetailData.fromJson(b as Map)) + .toList() ?? + []; + return BoxInfoResult( + success: true, + zongpaiNo: json['zongpai_no'] as String?, + paichanNo: json['paichan_no'] as String?, + quantity: json['quantity'] as int?, + existingBoxes: boxes, + maxBoxNo: json['max_box_no'] as int? ?? 0, + suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, + ); + } + + factory BoxInfoResult.error(String message, {String? errorCode}) { + return BoxInfoResult(success: false, errorMessage: message, errorCode: errorCode); + } +} + +/// 箱号明细 +class BoxDetailData { + final int boxNo; + final List items; + + BoxDetailData({required this.boxNo, required this.items}); + + factory BoxDetailData.fromJson(Map json) { + final items = (json['items'] as List?) + ?.map((i) => BoxItemData.fromJson(i as Map)) + .toList() ?? + []; + return BoxDetailData(boxNo: json['box_no'] as int, items: items); + } +} + +/// 箱号内单个总排号明细 +class BoxItemData { + final String zongpaiNo; + final int quantity; + + BoxItemData({required this.zongpaiNo, required this.quantity}); + + factory BoxItemData.fromJson(Map json) { + return BoxItemData( + zongpaiNo: json['zongpai_no'] as String, + quantity: json['quantity'] as int, + ); + } +} + +/// 装箱保存结果 +class BoxSaveResult { + final bool success; + final bool isDuplicate; + final String? errorMessage; + final String? paichanNo; + final int? boxNo; + + BoxSaveResult({ + required this.success, + this.isDuplicate = false, + this.errorMessage, + this.paichanNo, + this.boxNo, + }); + + factory BoxSaveResult.ok(Map json) { + return BoxSaveResult( + success: true, + paichanNo: json['paichan_no'] as String?, + boxNo: json['box_no'] as int?, + ); + } + + factory BoxSaveResult.duplicate(Map json) { + return BoxSaveResult( + success: false, + isDuplicate: true, + paichanNo: json['paichan_no'] as String?, + boxNo: json['box_no'] as int?, + ); + } + + factory BoxSaveResult.error(String message) { + return BoxSaveResult(success: false, errorMessage: message); + } +} +``` + +在 `ApiService` 类内新增两个方法: + +```dart + /// 查询装箱信息 — GET /CargoTrace/box/info + Future fetchBoxInfo({ + required String baseUrl, + required String zongpaiNo, + }) async { + final uri = Uri.parse('$baseUrl/CargoTrace/box/info').replace( + queryParameters: {'zongpai_no': zongpaiNo}, + ); + try { + final response = await _client.get(uri, headers: { + 'Content-Type': 'application/json', + }).timeout(timeout); + + switch (response.statusCode) { + case 200: + final body = jsonDecode(response.body) as Map; + return BoxInfoResult.ok(body); + case 400: + final body = jsonDecode(response.body) as Map; + final errorCode = body['error_code']?.toString() ?? ''; + return BoxInfoResult.error( + _errorMessage(errorCode), + errorCode: errorCode, + ); + case 404: + return BoxInfoResult.error('未找到该总排号对应的排产号信息'); + default: + return BoxInfoResult.error('查询失败 (${response.statusCode})'); + } + } catch (e) { + return BoxInfoResult.error('网络异常,请检查网络连接'); + } + } + + /// 保存装箱记录 — POST /CargoTrace/box + Future saveBoxRecord({ + required String baseUrl, + required String zongpaiNo, + required int boxNo, + required int quantity, + }) async { + final uri = Uri.parse('$baseUrl/CargoTrace/box'); + try { + final response = await _client + .post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'zongpai_no': zongpaiNo, + 'box_no': boxNo, + 'quantity': quantity, + }), + ) + .timeout(timeout); + + switch (response.statusCode) { + case 200: + final body = jsonDecode(response.body) as Map; + return BoxSaveResult.ok(body); + case 400: + final body = jsonDecode(response.body) as Map; + final msg = body['message']?.toString() ?? '请求参数错误'; + return BoxSaveResult.error(msg); + case 409: + final body = jsonDecode(response.body) as Map; + return BoxSaveResult.duplicate(body); + case 404: + return BoxSaveResult.error('未找到该总排号对应的排产号信息'); + default: + return BoxSaveResult.error('提交失败 (${response.statusCode})'); + } + } catch (e) { + return BoxSaveResult.error('网络异常,请检查网络连接'); + } + } + + String _errorMessage(String errorCode) { + switch (errorCode) { + case 'INVALID_ZONGPAI': + return '无效的总排号格式'; + default: + return '请求参数错误'; + } + } +``` + +**Step 2: 验证编译** + +Run: `flutter analyze lib/services/api_service.dart` + +**Step 3: Commit** + +```bash +git add lib/services/api_service.dart +git commit -m "feat: add box info query and save methods to ApiService" +``` + +--- + +### Task 2: BoxingDetailPage — 装箱详情页 + +**Files:** +- Create: `lib/pages/boxing_detail_page.dart` + +**Step 1: 实现详情页** + +只读页面,展示排产号下的完整装箱明细(PRD §7.3)。 + +```dart +import 'package:flutter/material.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +class BoxingDetailPage extends StatelessWidget { + final String paichanNo; + final List existingBoxes; + + const BoxingDetailPage({ + super.key, + required this.paichanNo, + required this.existingBoxes, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Text('装箱详情'), + const SizedBox(width: 12), + Text( + paichanNo, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), + ), + ], + ), + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: existingBoxes.length, + itemBuilder: (context, index) { + final box = existingBoxes[index]; + return _buildBoxGroup(context, box); + }, + ), + ), + // 底部汇总 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Text( + '共 ${existingBoxes.length} 箱', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _buildBoxGroup(BuildContext context, BoxDetailData box) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '箱号 ${box.boxNo}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + children: box.items.map((item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)), + Text('数量:${item.quantity}', style: const TextStyle(fontSize: 14)), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } +} +``` + +**Step 2: 验证编译** + +Run: `flutter analyze lib/pages/boxing_detail_page.dart` + +**Step 3: Commit** + +```bash +git add lib/pages/boxing_detail_page.dart +git commit -m "feat: add BoxingDetailPage for boxing record details" +``` + +--- + +### Task 3: BoxingPage — 装箱编号主页面 + +**Files:** +- Create: `lib/pages/boxing_page.dart` + +这是核心页面,实现 PRD §7 中的所有交互逻辑。 + +**Step 1: 实现装箱模式枚举和页面** + +核心状态变量: +- `BoxingMode _mode` — 当前装箱模式 +- `_BoxingPhase _phase` — 当前阶段(waiting / scanned / submitted) +- `String? _zongpaiNo` — 当前总排号 +- `String? _paichanNo` — 排产号 +- `int? _erpQuantity` — ERP 中的数量 +- `List _existingBoxes` — 已有箱号明细 +- `int _maxBoxNo` — 最大箱号 +- `TextEditingController _boxNoController` — 箱号输入 +- `TextEditingController _quantityController` — 数量输入 +- `bool _boxNoLocked` — 箱号是否锁定(多码一箱) +- `int? _lastBoxNo` / `int? _lastQuantity` — 上次提交的值(一码多箱用) + +页面结构: + +``` +┌──────────────────────────────────┐ +│ 装箱编号 [一码一箱 ○] │ ← AppBar + 模式切换 +├──────────────────────────────────┤ +│ 提示条区域 (错误/警告) │ ← 红色/amber/yellow 提示条 +├──────────────────────────────────┤ +│ ✓ 26BW0011 已识别 │ ← 扫码区 +│ │ +│ 排产号: W00009 │ ← 信息区 +│ 已有箱数:3箱 最大箱号:3 │ +│ [详情 →] │ +│ ─────────────────────────────── │ +│ 箱号[ 4 ] 数量[ 80 ] [确认] │ ← 输入区 +│ │ +├──────────────────────────────────┤ +│ [返回] [继续添加] │ ← 操作栏(一码多箱/多码一箱) +├──────────────────────────────────┤ +│ ● 等待扫码 │ ← 状态栏 +└──────────────────────────────────┘ +``` + +**扫码处理逻辑(_onScan):** +1. 解析码值 → 只接受 `CodeType.zongpaiNo` +2. 调用 `fetchBoxInfo` 查询后端 +3. 成功后根据模式填充: + - 一码一箱:箱号 = suggested_box_no,数量 = erp_quantity + - 一码多箱:箱号 = suggested_box_no,数量为空 + - 多码一箱(首次):箱号 = suggested_box_no,数量 = erp_quantity,箱号锁定 + - 多码一箱(后续):箱号保持锁定,数量 = 新总排号的 erp_quantity + +**重复箱号检测(本地):** +- 当箱号值变化时,比对 `_existingBoxes` 中是否已存在该箱号 +- 重复时:amber 边框 + 警告条 + 禁用确认按钮 + +**提交逻辑(_submit):** +1. 调用 `saveBoxRecord` 保存 +2. 成功后根据模式处理后续: + - 一码一箱:显示成功 1.5s → 重置 + - 一码多箱:记录 lastBoxNo/lastQuantity → 等待"继续添加" + - 多码一箱:保留箱号锁定 → 等待下一个扫码 + +**Step 2: 验证编译** + +Run: `flutter analyze lib/pages/boxing_page.dart` + +**Step 3: Commit** + +```bash +git add lib/pages/boxing_page.dart +git commit -m "feat: add BoxingPage with three boxing modes" +``` + +--- + +### Task 4: HomePage 更新 — 接入装箱模块 + +**Files:** +- Modify: `lib/pages/home_page.dart` + +**Step 1: 更新装箱模块状态** + +将 `boxing` FeatureCard 的 `status` 从 `ModuleStatus.developing` 改为 `ModuleStatus.online`,设置 `version: 'v1.0'`。 + +**Step 2: 更新导航逻辑** + +在 `_navigateToModule` 的 `ModuleType.boxing` case 中: +- 添加 `import 'package:pad_scanner/pages/boxing_page.dart';` +- 替换 placeholder 为 `targetPage = const BoxingPage();` + +**Step 3: 验证编译** + +Run: `flutter analyze` + +**Step 4: Commit** + +```bash +git add lib/pages/home_page.dart +git commit -m "feat: activate boxing module in HomePage" +``` + +--- + +### Task 5: 最终验证 + +**Step 1: 全量分析** + +Run: `flutter analyze` + +Expected: No issues found + +**Step 2: 运行测试** + +Run: `flutter test` + +Expected: All tests pass + +**Step 3: 检查无遗漏引用** + +确认 `BoxingPage` 已正确从 `HomePage` 导航访问。 + +--- + +## 文件变更汇总 + +| 操作 | 文件 | 说明 | +|------|------|------| +| Modify | `lib/services/api_service.dart` | 新增装箱 API 方法和数据类 | +| Create | `lib/pages/boxing_detail_page.dart` | 装箱详情只读页 | +| Create | `lib/pages/boxing_page.dart` | 装箱编号主页面(三种模式) | +| Modify | `lib/pages/home_page.dart` | 激活装箱模块导航 | diff --git a/lib/main.dart b/lib/main.dart index aceec8b..bfe71a6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pad_scanner/pages/registration_page.dart'; +import 'package:pad_scanner/pages/home_page.dart'; void main() { runApp(const PadScannerApp()); @@ -11,12 +11,12 @@ class PadScannerApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: '上架登记', + title: 'CargoTrace', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true, ), - home: const RegistrationPage(), + home: const HomePage(), ); } } diff --git a/lib/pages/boxing_detail_page.dart b/lib/pages/boxing_detail_page.dart new file mode 100644 index 0000000..a316867 --- /dev/null +++ b/lib/pages/boxing_detail_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +class BoxingDetailPage extends StatelessWidget { + final String paichanNo; + final List existingBoxes; + + const BoxingDetailPage({ + super.key, + required this.paichanNo, + required this.existingBoxes, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Text('装箱详情'), + const SizedBox(width: 12), + Text( + paichanNo, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), + ), + ], + ), + ), + body: Column( + children: [ + Expanded( + child: existingBoxes.isEmpty + ? const Center(child: Text('暂无装箱记录')) + : ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: existingBoxes.length, + itemBuilder: (context, index) { + return _buildBoxGroup(context, existingBoxes[index]); + }, + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Text( + '共 ${existingBoxes.length} 箱', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + } + + Widget _buildBoxGroup(BuildContext context, BoxDetailData box) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '箱号 ${box.boxNo}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + children: box.items.map((item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)), + Text('数量:${item.quantity}', + style: const TextStyle(fontSize: 14)), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart new file mode 100644 index 0000000..7d3ad07 --- /dev/null +++ b/lib/pages/boxing_page.dart @@ -0,0 +1,846 @@ +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/pages/boxing_detail_page.dart'; + +// === 装箱模式 === + +enum BoxingMode { + one2one, // 一码一箱 + one2many, // 一码多箱 + many2one, // 多码一箱 +} + +// === 页面阶段 === + +enum _Phase { + waiting, // 等待扫码 + scanned, // 已扫码,显示信息 + submitted, // 已提交成功 +} + +// === 主页面 === + +class BoxingPage extends StatefulWidget { + const BoxingPage({super.key}); + + @override + State createState() => _BoxingPageState(); +} + +class _BoxingPageState extends State { + final _scannerService = ScannerService(); + final _apiService = ApiService(); + + // 模式 + BoxingMode _mode = BoxingMode.one2one; + + // 阶段 + _Phase _phase = _Phase.waiting; + + // 当前总排号 + String? _zongpaiNo; + + // 排产号信息(来自后端查询) + String? _paichanNo; + int? _erpQuantity; + List _existingBoxes = []; + int _maxBoxNo = 0; + + // 输入 + final _boxNoController = TextEditingController(); + final _quantityController = TextEditingController(); + final _boxNoFocusNode = FocusNode(); + final _quantityFocusNode = FocusNode(); + + // 多码一箱:已扫描过的总排号列表(用于显示) + final _scannedZongpais = []; + + // 箱号锁定(多码一箱模式) + bool _boxNoLocked = false; + + // 提交中 + bool _isSubmitting = false; + + // 一码多箱:上次提交的值(用于继续添加预填) + int? _lastBoxNo; + int? _lastQuantity; + + // 重复箱号 + bool _isDuplicateBoxNo = false; + + // 反馈 + String? _feedbackMessage; + Color? _feedbackColor; + + @override + void initState() { + super.initState(); + _scannerService.scanResults.listen(_onScan); + } + + @override + void dispose() { + _boxNoController.dispose(); + _quantityController.dispose(); + _boxNoFocusNode.dispose(); + _quantityFocusNode.dispose(); + super.dispose(); + } + + // === 模式切换 === + + String get _modeLabel { + switch (_mode) { + case BoxingMode.one2one: + return '一码一箱'; + case BoxingMode.one2many: + return '一码多箱'; + case BoxingMode.many2one: + return '多码一箱'; + } + } + + void _cycleMode() { + setState(() { + switch (_mode) { + case BoxingMode.one2one: + _mode = BoxingMode.one2many; + case BoxingMode.one2many: + _mode = BoxingMode.many2one; + case BoxingMode.many2one: + _mode = BoxingMode.one2one; + } + _resetState(); + }); + } + + void _resetState() { + _phase = _Phase.waiting; + _zongpaiNo = null; + _paichanNo = null; + _erpQuantity = null; + _existingBoxes = []; + _maxBoxNo = 0; + _boxNoController.clear(); + _quantityController.clear(); + _scannedZongpais.clear(); + _boxNoLocked = false; + _isSubmitting = false; + _lastBoxNo = null; + _lastQuantity = null; + _isDuplicateBoxNo = false; + _feedbackMessage = null; + _feedbackColor = null; + } + + // === 扫码处理 === + + void _onScan(ScanResult result) { + final parsed = CodeParser.parse(result.barcode); + + if (parsed.type != CodeType.zongpaiNo) { + _showFeedback('无效码,请重新扫描', isError: true); + return; + } + + final zongpai = parsed.value; + + // 多码一箱已提交后:等待下一个扫码 + if (_mode == BoxingMode.many2one && _phase == _Phase.submitted) { + _handleNextScanMany2One(zongpai); + return; + } + + // 其他模式:等待扫码阶段才处理 + if (_phase != _Phase.waiting) return; + + _queryBoxInfo(zongpai); + } + + void _handleNextScanMany2One(String zongpai) { + setState(() { + _phase = _Phase.waiting; + _zongpaiNo = null; + _feedbackMessage = null; + }); + _queryBoxInfo(zongpai); + } + + Future _queryBoxInfo(String zongpai) async { + final configService = AppConfigService(); + final baseUrl = await configService.getString('api_url') ?? ''; + if (baseUrl.isEmpty) { + _showFeedback('未配置 API 地址,请前往设置', isError: true); + return; + } + + final result = await _apiService.fetchBoxInfo( + baseUrl: baseUrl, + zongpaiNo: zongpai, + ); + + if (!mounted) return; + + if (!result.success) { + _showFeedback(result.errorMessage ?? '查询失败', isError: true); + return; + } + + setState(() { + _zongpaiNo = zongpai; + _paichanNo = result.paichanNo; + _erpQuantity = result.quantity; + _existingBoxes = result.existingBoxes; + _maxBoxNo = result.maxBoxNo; + _phase = _Phase.scanned; + _isDuplicateBoxNo = false; + _feedbackMessage = null; + + // 多码一箱:记录已扫描列表 + if (_mode == BoxingMode.many2one && !_scannedZongpais.contains(zongpai)) { + _scannedZongpais.add(zongpai); + } + + // 根据模式自动填充 + _applyAutoFill(); + }); + } + + void _applyAutoFill() { + switch (_mode) { + case BoxingMode.one2one: + // 箱号 = max+1, 数量 = ERP 数量 + _boxNoController.text = (_maxBoxNo + 1).toString(); + _quantityController.text = (_erpQuantity ?? 0).toString(); + case BoxingMode.one2many: + if (_lastBoxNo != null) { + // 继续添加:箱号 = 上次+1, 数量 = 上次值 + _boxNoController.text = (_lastBoxNo! + 1).toString(); + _quantityController.text = _lastQuantity?.toString() ?? ''; + } else { + // 首次:箱号 = max+1, 数量不填 + _boxNoController.text = (_maxBoxNo + 1).toString(); + _quantityController.clear(); + } + case BoxingMode.many2one: + // 箱号 = max+1 (首次) 或锁定, 数量 = ERP 数量 + if (!_boxNoLocked) { + _boxNoController.text = (_maxBoxNo + 1).toString(); + _boxNoLocked = true; + } + _quantityController.text = (_erpQuantity ?? 0).toString(); + } + _checkDuplicateBoxNo(); + } + + // === 重复箱号检测 === + + void _checkDuplicateBoxNo() { + final boxNo = int.tryParse(_boxNoController.text); + if (boxNo == null) { + setState(() => _isDuplicateBoxNo = false); + return; + } + final exists = _existingBoxes.any((b) => b.boxNo == boxNo); + setState(() => _isDuplicateBoxNo = exists); + } + + // === 提交 === + + bool get _canSubmit { + if (_isSubmitting || _phase != _Phase.scanned) return false; + if (_zongpaiNo == null) return false; + final boxNo = int.tryParse(_boxNoController.text); + final qty = int.tryParse(_quantityController.text); + if (boxNo == null || qty == null || qty <= 0) return false; + if (_isDuplicateBoxNo) return false; + return true; + } + + Future _submit() async { + if (!_canSubmit) return; + + final configService = AppConfigService(); + final baseUrl = await configService.getString('api_url') ?? ''; + if (baseUrl.isEmpty) { + _showFeedback('未配置 API 地址,请前往设置', isError: true); + return; + } + + final boxNo = int.parse(_boxNoController.text); + final quantity = int.parse(_quantityController.text); + + setState(() => _isSubmitting = true); + + final result = await _apiService.saveBoxRecord( + baseUrl: baseUrl, + zongpaiNo: _zongpaiNo!, + boxNo: boxNo, + quantity: quantity, + ); + + if (!mounted) return; + + setState(() => _isSubmitting = false); + + if (result.success) { + _onSubmitSuccess(boxNo, quantity); + } else if (result.isDuplicate) { + _showFeedback( + '排产号 ${result.paichanNo ?? ""} 下箱号 ${result.boxNo ?? ""} 已存在', + isError: true, + ); + } else { + _showFeedback(result.errorMessage ?? '提交失败', isError: true); + } + } + + void _onSubmitSuccess(int boxNo, int quantity) { + setState(() { + _lastBoxNo = boxNo; + _lastQuantity = quantity; + // 刷新已有箱号列表(将新记录加入本地列表) + _existingBoxes = List.from(_existingBoxes) + ..add(BoxDetailData( + boxNo: boxNo, + items: [BoxItemData(zongpaiNo: _zongpaiNo!, quantity: quantity)], + )); + _maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo; + }); + + switch (_mode) { + case BoxingMode.one2one: + // 显示成功 1.5s → 重置 + _showFeedback('装箱成功', isError: false); + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _resetState()); + }); + + case BoxingMode.one2many: + // 进入已提交状态,等待"继续添加"或"返回" + setState(() { + _phase = _Phase.submitted; + _zongpaiNo = null; + }); + _showFeedback('装箱成功', isError: false); + + case BoxingMode.many2one: + // 进入已提交状态,自动等待下一个扫码 + setState(() { + _phase = _Phase.submitted; + _zongpaiNo = null; + }); + _showFeedback('装箱成功,请扫描下一个总排号', isError: false); + } + } + + // === 操作按钮 === + + void _onContinueAdding() { + // 一码多箱的继续添加 + setState(() { + _phase = _Phase.waiting; + _feedbackMessage = null; + }); + // 预填值会在下次扫码后的 _applyAutoFill 中处理 + // 但这里需要手动触发,因为不重新扫码 + // 用户需要扫描同一个总排号(或其他总排号) + } + + void _onGoBack() { + setState(() => _resetState()); + } + + // === 反馈 === + + void _showFeedback(String message, {bool isError = false}) { + setState(() { + _feedbackMessage = message; + _feedbackColor = isError ? Colors.red.shade700 : Colors.green.shade700; + }); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _feedbackMessage = null); + }); + } + + // === 导航到详情页 === + + void _openDetail() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BoxingDetailPage( + paichanNo: _paichanNo ?? '', + existingBoxes: _existingBoxes, + ), + ), + ); + } + + // === 状态文字 === + + String get _statusText { + if (_isSubmitting) return '正在提交…'; + if (_feedbackMessage != null) return _feedbackMessage!; + switch (_phase) { + case _Phase.waiting: + if (_mode == BoxingMode.many2one && _boxNoLocked) { + return '请扫描下一个总排号'; + } + return '等待扫码'; + case _Phase.scanned: + return '请确认信息并提交'; + case _Phase.submitted: + switch (_mode) { + case BoxingMode.one2many: + return '装箱成功,可继续添加或返回'; + case BoxingMode.many2one: + return '请扫描下一个总排号'; + case BoxingMode.one2one: + return ''; + } + } + } + + Color get _statusDotColor { + if (_isSubmitting) return Colors.orange; + if (_feedbackMessage != null) { + return _feedbackColor == Colors.red.shade700 ? Colors.red : Colors.green; + } + switch (_phase) { + case _Phase.waiting: + return Colors.blue; + case _Phase.scanned: + return Colors.green; + case _Phase.submitted: + return Colors.green; + } + } + + // === Build === + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isWaiting = _phase == _Phase.waiting && _zongpaiNo == null; + final showActionButtons = + _phase == _Phase.submitted && _mode != BoxingMode.one2one; + + return Scaffold( + appBar: AppBar( + title: const Text('装箱编号'), + actions: [ + // 模式切换按钮 + Padding( + padding: const EdgeInsets.only(right: 4), + child: TextButton.icon( + onPressed: _cycleMode, + icon: const Icon(Icons.swap_horiz, size: 18), + label: Text( + _modeLabel, + style: const TextStyle(fontSize: 13), + ), + style: TextButton.styleFrom( + foregroundColor: _mode == BoxingMode.many2one + ? Colors.orange + : colorScheme.primary, + ), + ), + ), + ], + ), + body: Column( + children: [ + // 反馈提示条 + if (_feedbackMessage != null) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + color: _feedbackColor, + child: Text( + _feedbackMessage!, + style: const TextStyle(color: Colors.white, fontSize: 14), + textAlign: TextAlign.center, + ), + ), + + // 重复箱号警告条 + if (_isDuplicateBoxNo && _phase == _Phase.scanned) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: Colors.amber.shade100, + child: Text( + '箱号 ${_boxNoController.text} 已存在,请重新输入', + style: TextStyle(color: Colors.amber.shade900, fontSize: 13), + ), + ), + + // 主内容区 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // === 扫码区 === + _buildScanArea(isWaiting), + + const SizedBox(height: 12), + + // === 信息区 === + _buildInfoArea(isWaiting, colorScheme), + + const Divider(height: 24), + + // === 输入区 === + _buildInputArea(isWaiting), + ], + ), + ), + ), + + // 操作按钮(一码多箱 / 多码一箱) + if (showActionButtons) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _onGoBack, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(40), + ), + child: const Text('返回'), + ), + ), + if (_mode == BoxingMode.one2many) ...[ + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: _onContinueAdding, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(40), + ), + child: const Text('继续添加'), + ), + ), + ], + ], + ), + ), + + // 底部状态栏 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + color: colorScheme.surfaceContainerHighest, + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _statusDotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusText, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } + + // === 扫码区 Widget === + + Widget _buildScanArea(bool isWaiting) { + if (isWaiting) { + // 等待扫码:置灰提示 + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.qr_code_scanner, color: Colors.grey, size: 20), + SizedBox(width: 8), + Text( + '请扫描执行卡二维码', + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ); + } + + // 已扫码:显示总排号 + if (_mode == BoxingMode.many2one && _scannedZongpais.isNotEmpty) { + // 多码一箱:显示已扫描列表 + return Wrap( + spacing: 6, + runSpacing: 4, + children: _scannedZongpais.map((zp) { + final isCurrent = zp == _zongpaiNo; + return Chip( + avatar: Icon( + Icons.check_circle, + size: 16, + color: isCurrent ? Colors.green : Colors.grey, + ), + label: Text(zp, style: const TextStyle(fontSize: 13)), + visualDensity: VisualDensity.compact, + ); + }).toList(), + ); + } + + // 单个总排号 + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.green, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green, size: 18), + const SizedBox(width: 8), + Text( + _zongpaiNo ?? '', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const Spacer(), + const Text( + '已识别', + style: TextStyle(fontSize: 12, color: Colors.green), + ), + ], + ), + ); + } + + // === 信息区 Widget === + + Widget _buildInfoArea(bool isWaiting, ColorScheme colorScheme) { + final grey = isWaiting; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 排产号 + Text( + '排产号:', + style: TextStyle( + fontSize: 13, + color: grey ? Colors.grey : Colors.black54, + ), + ), + const SizedBox(height: 2), + Text( + _paichanNo ?? '--', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: grey ? Colors.grey : Colors.black87, + ), + ), + const SizedBox(height: 8), + + // 已有箱数 + 最大箱号 + 详情按钮 + Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + '已有箱数:', + style: TextStyle( + fontSize: 13, + color: grey ? Colors.grey : Colors.black54, + ), + ), + Text( + grey ? '--' : '${_existingBoxes.length}箱', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: grey ? Colors.grey : Colors.black87, + ), + ), + const SizedBox(width: 16), + Text( + '最大箱号:', + style: TextStyle( + fontSize: 13, + color: grey ? Colors.grey : Colors.black54, + ), + ), + Text( + grey ? '--' : '$_maxBoxNo', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _maxBoxNo > 0 ? Colors.amber.shade800 : Colors.grey, + ), + ), + ], + ), + ), + TextButton( + onPressed: grey || _existingBoxes.isEmpty ? null : _openDetail, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '详情 →', + style: TextStyle( + fontSize: 13, + color: grey || _existingBoxes.isEmpty + ? Colors.grey.shade400 + : colorScheme.primary, + ), + ), + ), + ], + ), + ], + ); + } + + // === 输入区 Widget === + + Widget _buildInputArea(bool isWaiting) { + final enabled = !isWaiting && _phase == _Phase.scanned; + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 箱号输入 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '箱号', + style: TextStyle( + fontSize: 13, + color: enabled ? Colors.black54 : Colors.grey, + ), + ), + const SizedBox(height: 4), + SizedBox( + height: 40, + child: TextField( + controller: _boxNoController, + focusNode: _boxNoFocusNode, + enabled: enabled && !_boxNoLocked, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => _checkDuplicateBoxNo(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 10), + border: const OutlineInputBorder(), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: _isDuplicateBoxNo + ? Colors.amber + : Colors.grey.shade400, + ), + ), + suffixIcon: _boxNoLocked + ? const Icon(Icons.lock, size: 18, color: Colors.orange) + : null, + ), + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + + // 数量输入 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '数量', + style: TextStyle( + fontSize: 13, + color: enabled ? Colors.black54 : Colors.grey, + ), + ), + const SizedBox(height: 4), + SizedBox( + height: 40, + child: TextField( + controller: _quantityController, + focusNode: _quantityFocusNode, + enabled: enabled, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 10), + border: OutlineInputBorder(), + ), + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + + // 确认按钮 + SizedBox( + height: 40, + child: ElevatedButton( + onPressed: _canSubmit ? _submit : null, + style: ElevatedButton.styleFrom( + backgroundColor: + _canSubmit ? Theme.of(context).colorScheme.primary : Colors.grey.shade300, + foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, + ), + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('确认', style: TextStyle(fontSize: 15)), + ), + ), + ], + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart new file mode 100644 index 0000000..b823f29 --- /dev/null +++ b/lib/pages/home_page.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:pad_scanner/services/app_config_service.dart'; +import 'package:pad_scanner/pages/settings_page.dart'; +import 'package:pad_scanner/pages/registration_page.dart'; +import 'package:pad_scanner/pages/boxing_page.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +/// Module type enum for each available feature card +enum ModuleType { registration, boxing, shelfQuery } + +/// Module status for PRD state tracking +enum ModuleStatus { online, developing, planning } + +/// Function card data model per PRD §3.3 +class FeatureCard { + final ModuleType type; + final IconData icon; + final String title; + final String description; + final ModuleStatus status; + final String? version; + final String route; + + const FeatureCard({ + required this.type, + required this.icon, + required this.title, + required this.description, + required this.status, + this.version, + required this.route, + }); +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _apiService = ApiService(); + bool _isCheckingConnection = false; + bool _isConnected = false; + String _apiAddress = ''; + + /// Module registry from PRD §3.4 + static const _modules = [ + FeatureCard( + type: ModuleType.registration, + icon: Icons.local_shipping_outlined, + title: '上架登记', + description: '扫码绑定总排号与货位', + status: ModuleStatus.online, + version: 'v1.0', + route: '/registration', + ), + FeatureCard( + type: ModuleType.boxing, + icon: Icons.view_list_outlined, + title: '装箱编号', + description: '查询箱号、录入装箱明细', + status: ModuleStatus.online, + version: 'v1.0', + route: '/boxing', + ), + FeatureCard( + type: ModuleType.shelfQuery, + icon: Icons.search_outlined, + title: '货架查询', + description: '按合同查询货架库存', + status: ModuleStatus.planning, + version: null, + route: '', + ), + ]; + + @override + void initState() { + super.initState(); + _checkConnection(); + } + + Future _checkConnection() async { + final configService = AppConfigService(); + final url = await configService.getString('api_url') ?? ''; + if (url.isEmpty) { + setState(() { + _isConnected = false; + _apiAddress = ''; + _isCheckingConnection = false; + }); + return; + } + + setState(() => _isCheckingConnection = true); + final ok = await _apiService.testConnection(url); + if (!mounted) return; + setState(() { + _isConnected = ok; + _apiAddress = url; + _isCheckingConnection = false; + }); + } + + void _onCardTap(FeatureCard card) { + switch (card.status) { + case ModuleStatus.online: + _navigateToModule(card.type); + case ModuleStatus.developing: + _showToast('该功能正在开发中'); + case ModuleStatus.planning: + _showToast('该功能规划中'); + } + } + + void _showToast(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } + + void _navigateToModule(ModuleType type) { + late Widget targetPage; + switch (type) { + case ModuleType.registration: + targetPage = const RegistrationPage(); + break; + case ModuleType.boxing: + targetPage = const BoxingPage(); + break; + case ModuleType.shelfQuery: + return; // 规划中,不导航 + } + Navigator.push( + context, + MaterialPageRoute(builder: (_) => targetPage), + ).then((_) => _checkConnection()); + } + + void _navigateToSettings() { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ).then((_) => _checkConnection()); + } + + Color _getConnectionStatusColor() { + if (_isCheckingConnection) return Colors.orange; + return _isConnected ? Colors.green : Colors.red; + } + + String _getConnectionStatusText() { + if (_isCheckingConnection) return '● 正在检测连接...'; + if (!_isConnected || _apiAddress.isEmpty) return '● 未连接,请检查设置'; + return '● 已连接 $_apiAddress'; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('CargoTrace'), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: const Icon(Icons.settings), + onPressed: _navigateToSettings, + ), + ), + ], + ), + body: Column( + children: [ + // Function cards area + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: _modules.length, + itemBuilder: (context, index) { + final card = _modules[index]; + return _buildFeatureCard(context, card); + }, + ), + ), + + // Bottom connection status bar + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + color: colorScheme.surfaceContainerHighest, + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _getConnectionStatusColor(), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _getConnectionStatusText(), + style: const TextStyle(fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFeatureCard(BuildContext context, FeatureCard card) { + final Color statusBgColor; + final TextStyle statusTextStyle; + final String statusText; + + switch (card.status) { + case ModuleStatus.online: + statusBgColor = Colors.green.shade700; + statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12); + statusText = card.version ?? '已上线'; + break; + case ModuleStatus.developing: + statusBgColor = Colors.blue.shade700; + statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12); + statusText = '开发中'; + break; + case ModuleStatus.planning: + statusBgColor = Colors.grey.shade500; + statusTextStyle = const TextStyle(color: Colors.white, fontSize: 12); + statusText = '规划中'; + break; + } + + final canNavigate = card.status == ModuleStatus.online; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: () => _onCardTap(card), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: canNavigate ? Colors.white : Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // First row: icon + module name + Row( + children: [ + Icon( + card.icon, + size: 22, + color: canNavigate ? Colors.blue : Colors.grey, + ), + const SizedBox(width: 8), + Text( + card.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: canNavigate ? Colors.black87 : Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 6), + // Second row: description + Text( + card.description, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + const SizedBox(height: 10), + // Third row: status badge (right aligned) + Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: statusBgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(statusText, style: statusTextStyle), + if (card.status == ModuleStatus.online) ...[ + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ] else ...[ + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index 9ff642f..afa79e6 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:vibration/vibration.dart'; -import 'package:shared_preferences/shared_preferences.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/sound_service.dart'; -import 'package:pad_scanner/pages/settings_page.dart'; class RegistrationPage extends StatefulWidget { const RegistrationPage({super.key}); @@ -129,8 +128,8 @@ class _RegistrationPageState extends State { Future _submit() async { if (!_canSubmit) return; - final prefs = await SharedPreferences.getInstance(); - final baseUrl = prefs.getString('api_url') ?? ''; + final configService = AppConfigService(); + final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showFeedback('未配置 API 地址,请前往设置', isError: true); return; @@ -166,8 +165,7 @@ class _RegistrationPageState extends State { _locationCode = null; _locationType = null; } - _successMessage = - _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功'; + _successMessage = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功'; }); _showFeedback('上架成功', isError: false); Future.delayed(const Duration(milliseconds: 1500), () { @@ -259,8 +257,10 @@ class _RegistrationPageState extends State { const SizedBox(height: 4), Text('登记时间:${info?["registered_at"] ?? "未知"}'), const SizedBox(height: 12), - const Text('请核查实物,确认是否操作错误。', - style: TextStyle(fontWeight: FontWeight.bold)), + const Text( + '请核查实物,确认是否操作错误。', + style: TextStyle(fontWeight: FontWeight.bold), + ), ], ), actions: [ @@ -315,17 +315,7 @@ class _RegistrationPageState extends State { child: Row( children: [ const Text('锁定货位', style: TextStyle(fontSize: 13)), - Switch( - value: _isLocked, - onChanged: _toggleLock, - ), - IconButton( - icon: const Icon(Icons.settings), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), - ), + Switch(value: _isLocked, onChanged: _toggleLock), ], ), ), @@ -337,8 +327,7 @@ class _RegistrationPageState extends State { if (_snackbarMessage != null) Container( width: double.infinity, - padding: - const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), color: _snackbarColor, child: Text( _snackbarMessage!, @@ -356,17 +345,24 @@ class _RegistrationPageState extends State { // ---- 目标货位 (上方) ---- Row( children: [ - const Text('目标货位', - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), + const Text( + '目标货位', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), if (_locationCode != null) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( - color: _locationLabelColor(_locationType) - .withValues(alpha: 0.15), + color: _locationLabelColor( + _locationType, + ).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -385,7 +381,9 @@ class _RegistrationPageState extends State { Container( width: double.infinity, padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), + horizontal: 12, + vertical: 14, + ), decoration: BoxDecoration( border: Border.all( color: _locationCode != null @@ -410,8 +408,11 @@ class _RegistrationPageState extends State { ), ), if (_isLocked) - const Icon(Icons.lock, - color: Colors.orange, size: 20), + const Icon( + Icons.lock, + color: Colors.orange, + size: 20, + ), ], ), ), @@ -421,14 +422,20 @@ class _RegistrationPageState extends State { // ---- 总排号 (下方) ---- Row( children: [ - const Text('总排号', - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), + const Text( + '总排号', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), if (_isLocked && _zongpaiNos.isNotEmpty) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), @@ -455,14 +462,15 @@ class _RegistrationPageState extends State { width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade400), + border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), ), child: const Text( '', style: TextStyle( - fontSize: 20, color: Colors.grey), + fontSize: 20, + color: Colors.grey, + ), ), ) : ListView.separated( @@ -471,36 +479,32 @@ class _RegistrationPageState extends State { const SizedBox(height: 6), itemBuilder: (context, index) { return Dismissible( - key: ValueKey( - '${_zongpaiNos[index]}-$index'), - direction: - DismissDirection.endToStart, - onDismissed: (_) => - _removeZongpai(index), + key: ValueKey('${_zongpaiNos[index]}-$index'), + direction: DismissDirection.endToStart, + onDismissed: (_) => _removeZongpai(index), background: Container( - alignment: - Alignment.centerRight, - padding: const EdgeInsets.only( - right: 16), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), decoration: BoxDecoration( color: Colors.red.shade100, - borderRadius: - BorderRadius.circular(8), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.delete, + color: Colors.red, ), - child: const Icon(Icons.delete, - color: Colors.red), ), child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), decoration: BoxDecoration( border: Border.all( - color: Colors.green, - width: 2), - borderRadius: - BorderRadius.circular(8), + color: Colors.green, + width: 2, + ), + borderRadius: BorderRadius.circular(8), ), child: Row( children: [ @@ -509,21 +513,20 @@ class _RegistrationPageState extends State { _zongpaiNos[index], style: const TextStyle( fontSize: 20, - fontWeight: - FontWeight.bold, + fontWeight: FontWeight.bold, color: Colors.black87, ), ), ), IconButton( icon: const Icon( - Icons.close, - size: 20), + Icons.close, + size: 20, + ), onPressed: () => _removeZongpai(index), padding: EdgeInsets.zero, - constraints: - const BoxConstraints(), + constraints: const BoxConstraints(), ), ], ), @@ -537,7 +540,9 @@ class _RegistrationPageState extends State { Container( width: double.infinity, padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), + horizontal: 12, + vertical: 14, + ), decoration: BoxDecoration( border: Border.all( color: _zongpaiNos.isNotEmpty @@ -615,8 +620,8 @@ class _RegistrationPageState extends State { color: _isSubmitting ? Colors.orange : _successMessage != null - ? Colors.green - : Colors.blue, + ? Colors.green + : Colors.blue, shape: BoxShape.circle, ), ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index d7858eb..2aa05f9 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pad_scanner/services/app_config_service.dart'; import 'package:file_picker/file_picker.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/sound_service.dart'; @@ -29,8 +29,8 @@ class _SettingsPageState extends State { } Future _loadUrl() async { - final prefs = await SharedPreferences.getInstance(); - final url = prefs.getString('api_url') ?? ''; + final configService = AppConfigService(); + final url = await configService.getString('api_url') ?? ''; _controller.text = url; } @@ -52,8 +52,8 @@ class _SettingsPageState extends State { return; } setState(() => _saving = true); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('api_url', url); + final configService = AppConfigService(); + await configService.setString('api_url', url); setState(() => _saving = false); if (mounted) { _showSnackBar('设置已保存'); @@ -70,10 +70,7 @@ class _SettingsPageState extends State { final ok = await _apiService.testConnection(url); setState(() => _testing = false); if (mounted) { - _showSnackBar( - ok ? '连接成功' : '连接失败,请检查地址和网络', - isError: !ok, - ); + _showSnackBar(ok ? '连接成功' : '连接失败,请检查地址和网络', isError: !ok); } } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index ebf8bf6..7e988ba 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -25,6 +25,120 @@ class RegistrationResult { RegistrationResult(success: false, errorMessage: message); } +// === 装箱模块数据类 === + +/// 箱号内单个总排号明细 +class BoxItemData { + final String zongpaiNo; + final int quantity; + + BoxItemData({required this.zongpaiNo, required this.quantity}); + + factory BoxItemData.fromJson(Map json) { + return BoxItemData( + zongpaiNo: json['zongpai_no'] as String, + quantity: json['quantity'] as int, + ); + } +} + +/// 箱号明细 +class BoxDetailData { + final int boxNo; + final List items; + + BoxDetailData({required this.boxNo, required this.items}); + + factory BoxDetailData.fromJson(Map json) { + final items = (json['items'] as List?) + ?.map((i) => BoxItemData.fromJson(i as Map)) + .toList() ?? + []; + return BoxDetailData(boxNo: json['box_no'] as int, items: items); + } +} + +/// 装箱信息查询结果 +class BoxInfoResult { + final bool success; + final String? errorMessage; + final String? zongpaiNo; + final String? paichanNo; + final int? quantity; + final List existingBoxes; + final int maxBoxNo; + final int suggestedBoxNo; + + BoxInfoResult({ + required this.success, + this.errorMessage, + this.zongpaiNo, + this.paichanNo, + this.quantity, + this.existingBoxes = const [], + this.maxBoxNo = 0, + this.suggestedBoxNo = 1, + }); + + factory BoxInfoResult.ok(Map json) { + final boxes = (json['existing_boxes'] as List?) + ?.map((b) => BoxDetailData.fromJson(b as Map)) + .toList() ?? + []; + return BoxInfoResult( + success: true, + zongpaiNo: json['zongpai_no'] as String?, + paichanNo: json['paichan_no'] as String?, + quantity: json['quantity'] as int?, + existingBoxes: boxes, + maxBoxNo: json['max_box_no'] as int? ?? 0, + suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, + ); + } + + factory BoxInfoResult.error(String message) { + return BoxInfoResult(success: false, errorMessage: message); + } +} + +/// 装箱保存结果 +class BoxSaveResult { + final bool success; + final bool isDuplicate; + final String? errorMessage; + final String? paichanNo; + final int? boxNo; + + BoxSaveResult({ + required this.success, + this.isDuplicate = false, + this.errorMessage, + this.paichanNo, + this.boxNo, + }); + + factory BoxSaveResult.ok(Map json) { + return BoxSaveResult( + success: true, + paichanNo: json['paichan_no'] as String?, + boxNo: json['box_no'] as int?, + ); + } + + factory BoxSaveResult.duplicate(Map json) { + return BoxSaveResult( + success: false, + isDuplicate: true, + paichanNo: json['paichan_no'] as String?, + boxNo: json['box_no'] as int?, + ); + } + + factory BoxSaveResult.error(String message) { + return BoxSaveResult(success: false, errorMessage: message); + } +} + class ApiService { final http.Client _client; final Duration timeout; @@ -54,12 +168,16 @@ class ApiService { switch (response.statusCode) { case 200: return RegistrationResult.ok(); + case 400: + final body = jsonDecode(response.body) as Map; + final msg = body['message']?.toString() ?? '请求参数错误'; + return RegistrationResult.error(msg); case 409: final body = jsonDecode(response.body) as Map; return RegistrationResult.duplicate(body); default: final body = jsonDecode(response.body) as Map; - final msg = body['error'] ?? body['message'] ?? 'Unknown error (${response.statusCode})'; + final msg = body['message'] ?? body['error'] ?? 'Unknown error (${response.statusCode})'; return RegistrationResult.error(msg.toString()); } } catch (e) { @@ -67,6 +185,77 @@ class ApiService { } } + /// 查询装箱信息 — GET /CargoTrace/box/info + Future fetchBoxInfo({ + required String baseUrl, + required String zongpaiNo, + }) async { + final uri = Uri.parse('$baseUrl/CargoTrace/box/info').replace( + queryParameters: {'zongpai_no': zongpaiNo}, + ); + try { + final response = await _client + .get(uri, headers: {'Content-Type': 'application/json'}) + .timeout(timeout); + + switch (response.statusCode) { + case 200: + final body = jsonDecode(response.body) as Map; + return BoxInfoResult.ok(body); + case 400: + return BoxInfoResult.error('无效的总排号格式'); + case 404: + return BoxInfoResult.error('未找到该总排号对应的排产号信息'); + default: + return BoxInfoResult.error('查询失败 (${response.statusCode})'); + } + } catch (e) { + return BoxInfoResult.error('网络异常,请检查网络连接'); + } + } + + /// 保存装箱记录 — POST /CargoTrace/box + Future saveBoxRecord({ + required String baseUrl, + required String zongpaiNo, + required int boxNo, + required int quantity, + }) async { + final uri = Uri.parse('$baseUrl/CargoTrace/box'); + try { + final response = await _client + .post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'zongpai_no': zongpaiNo, + 'box_no': boxNo, + 'quantity': quantity, + }), + ) + .timeout(timeout); + + switch (response.statusCode) { + case 200: + final body = jsonDecode(response.body) as Map; + return BoxSaveResult.ok(body); + case 400: + final body = jsonDecode(response.body) as Map; + final msg = body['message']?.toString() ?? '请求参数错误'; + return BoxSaveResult.error(msg); + case 409: + final body = jsonDecode(response.body) as Map; + return BoxSaveResult.duplicate(body); + case 404: + return BoxSaveResult.error('未找到该总排号对应的排产号信息'); + default: + return BoxSaveResult.error('提交失败 (${response.statusCode})'); + } + } catch (e) { + return BoxSaveResult.error('网络异常,请检查网络连接'); + } + } + /// Test connectivity by making a HEAD request to the base URL. Future testConnection(String baseUrl) async { try { diff --git a/lib/services/app_config_service.dart b/lib/services/app_config_service.dart new file mode 100644 index 0000000..9f49ead --- /dev/null +++ b/lib/services/app_config_service.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:convert'; + +/// 应用全局配置服务 (替代 SharedPreferences) +/// 配置数据持久化为本地 JSON 文件,覆盖安装应用时数据不丢失。 +class AppConfigService { + static const String _kConfigFileName = 'app_config.json'; + static final Map _defaults = {'api_url': ''}; + + Future get _filePath async { + final directory = await getApplicationDocumentsDirectory(); + return '${directory.path}/$_kConfigFileName'; + } + + /// 读取完整配置字典 + Future> loadConfig() async { + try { + final file = File(await _filePath); + if (await file.exists()) { + final jsonString = await file.readAsString(); + return jsonDecode(jsonString) as Map; + } + } catch (e) { + debugPrint('[AppConfig] 读取配置失败: $e'); + } + return Map.from(_defaults); + } + + /// 读取字符串配置项 + Future getString(String key) async { + final config = await loadConfig(); + return config[key] as String?; + } + + /// 保存字符串配置项 + Future setString(String key, String value) async { + final config = await loadConfig(); + config[key.toString()] = value.toString(); + await _saveConfig(config); + } + + /// 完整写入 JSON 文件 + Future _saveConfig(Map config) async { + try { + final file = File(await _filePath); + await file.writeAsString(jsonEncode(config)); + } catch (e) { + debugPrint('[AppConfig] 保存配置失败: $e'); + } + } +} diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart index 440622e..62f8d7f 100644 --- a/lib/services/sound_service.dart +++ b/lib/services/sound_service.dart @@ -1,37 +1,36 @@ import 'package:audioplayers/audioplayers.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pad_scanner/services/app_config_service.dart'; class SoundService { static const _keySuccess = 'sound_success'; static const _keyFailure = 'sound_failure'; final _player = AudioPlayer(); + final _configService = AppConfigService(); Future getSuccessPath() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keySuccess); + return _configService.getString(_keySuccess); } Future getFailurePath() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_keyFailure); + return _configService.getString(_keyFailure); } Future setSuccessPath(String? path) async { - final prefs = await SharedPreferences.getInstance(); if (path != null) { - await prefs.setString(_keySuccess, path); + await _configService.setString(_keySuccess, path); } else { - await prefs.remove(_keySuccess); + final config = await _configService.loadConfig(); + config.remove(_keySuccess); } } Future setFailurePath(String? path) async { - final prefs = await SharedPreferences.getInstance(); if (path != null) { - await prefs.setString(_keyFailure, path); + await _configService.setString(_keyFailure, path); } else { - await prefs.remove(_keyFailure); + final config = await _configService.loadConfig(); + config.remove(_keyFailure); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8b5e7e2..eea5416 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,11 +8,9 @@ import Foundation import audioplayers_darwin import device_info_plus import file_picker -import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 7f89492..c8a7f7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audioplayers: dependency: "direct main" description: @@ -133,10 +133,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dbus: dependency: transitive description: @@ -377,7 +377,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -464,62 +464,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.dev" - source: hosted - version: "2.5.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e - url: "https://pub.dev" - source: hosted - version: "2.4.13" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -529,10 +473,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -625,10 +569,10 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.2.0" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c9b80f5..d059e70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 http: ^1.2.0 - shared_preferences: ^2.2.0 + path_provider: ^2.1.1 vibration: ^3.1.8 file_picker: 11.0.0 audioplayers: ^6.6.0 diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart new file mode 100644 index 0000000..1f7a745 --- /dev/null +++ b/test/services/api_service_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:pad_scanner/services/api_service.dart'; + +void main() { + group('ApiService.registerLocation', () { + test('returns ok on 200', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({'zongpai_no': '26B1', 'location_code': 'A01-02-03', 'created_at': '2026-05-11T10:00:00'}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final svc = ApiService(client: mockClient); + final result = await svc.registerLocation( + baseUrl: 'http://localhost', + zongpaiNo: '26B1', + locationCode: 'A01-02-03', + ); + expect(result.success, isTrue); + expect(result.isDuplicate, isFalse); + }); + + test('returns duplicate on 409 with location_code and registered_at', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({ + 'error_code': 'DUPLICATE_LOCATION', + 'message': '该总排号已存在货位记录', + 'location_code': 'A01-02-03', + 'registered_at': '2026-05-11T10:00:00', + }), + 409, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final svc = ApiService(client: mockClient); + final result = await svc.registerLocation( + baseUrl: 'http://localhost', + zongpaiNo: '26B1', + locationCode: 'A02-01-01', + ); + expect(result.success, isFalse); + expect(result.isDuplicate, isTrue); + expect(result.duplicateInfo?['location_code'], 'A01-02-03'); + expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00'); + }); + + test('returns error on 400 with message', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({'error_code': 'INVALID_ZONGPAI', 'message': 'INVALID_ZONGPAI'}), + 400, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final svc = ApiService(client: mockClient); + final result = await svc.registerLocation( + baseUrl: 'http://localhost', + zongpaiNo: 'INVALID', + locationCode: 'A01-02-03', + ); + expect(result.success, isFalse); + expect(result.isDuplicate, isFalse); + expect(result.errorMessage, 'INVALID_ZONGPAI'); + }); + + test('returns error on network failure', () async { + final mockClient = _MockClient((request) async { + throw Exception('Connection refused'); + }); + final svc = ApiService(client: mockClient, timeout: Duration(seconds: 1)); + final result = await svc.registerLocation( + baseUrl: 'http://localhost', + zongpaiNo: '26B1', + locationCode: 'A01-02-03', + ); + expect(result.success, isFalse); + expect(result.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, + ); + } +}