From 5bae60cc82d8e42d4607211dfb09ecca1ebf1c32 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Mon, 11 May 2026 17:38:16 +0800 Subject: [PATCH] feat: implement boxing module with three modes (one2one, one2many, many2one) - Add BoxInfoResult/BoxSaveResult data classes and API methods to ApiService - Create BoxingPage with mode switching, auto-fill rules, duplicate detection - Create BoxingDetailPage for read-only box detail view - Activate boxing module in HomePage navigation Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-05-11-boxing-module.md | 519 +++++++++++++++ lib/pages/boxing_detail_page.dart | 95 +++ lib/pages/boxing_page.dart | 846 +++++++++++++++++++++++++ lib/pages/home_page.dart | 11 +- lib/services/api_service.dart | 185 ++++++ 5 files changed, 1649 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-05-11-boxing-module.md create mode 100644 lib/pages/boxing_detail_page.dart create mode 100644 lib/pages/boxing_page.dart 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/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 index c3eaf13..b823f29 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -2,6 +2,7 @@ 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 @@ -60,8 +61,8 @@ class _HomePageState extends State { icon: Icons.view_list_outlined, title: '装箱编号', description: '查询箱号、录入装箱明细', - status: ModuleStatus.developing, - version: null, + status: ModuleStatus.online, + version: 'v1.0', route: '/boxing', ), FeatureCard( @@ -127,11 +128,7 @@ class _HomePageState extends State { targetPage = const RegistrationPage(); break; case ModuleType.boxing: - //TODO: Implement boxing page - targetPage = Scaffold( - appBar: AppBar(title: const Text('装箱编号')), - body: const Center(child: Text('开发中')), - ); + targetPage = const BoxingPage(); break; case ModuleType.shelfQuery: return; // 规划中,不导航 diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index a13ca0a..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; @@ -71,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 {