Files
pad_scanner/docs/plans/2026-05-11-boxing-module.md
Misaka_Company 5bae60cc82 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 <noreply@anthropic.com>
2026-05-11 17:38:16 +08:00

520 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 装箱编号模块 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<BoxDetailData> 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<String, dynamic> json) {
final boxes = (json['existing_boxes'] as List<dynamic>?)
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
.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<BoxItemData> items;
BoxDetailData({required this.boxNo, required this.items});
factory BoxDetailData.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>?)
?.map((i) => BoxItemData.fromJson(i as Map<String, dynamic>))
.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<String, dynamic> 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<String, dynamic> json) {
return BoxSaveResult(
success: true,
paichanNo: json['paichan_no'] as String?,
boxNo: json['box_no'] as int?,
);
}
factory BoxSaveResult.duplicate(Map<String, dynamic> 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<BoxInfoResult> 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<String, dynamic>;
return BoxInfoResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
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<BoxSaveResult> 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<String, dynamic>;
return BoxSaveResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return BoxSaveResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
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<BoxDetailData> 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<BoxDetailData> _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` | 激活装箱模块导航 |