diff --git a/docs/plans/2026-05-09-registration-design.md b/docs/plans/2026-05-09-registration-design.md new file mode 100644 index 0000000..1010f4d --- /dev/null +++ b/docs/plans/2026-05-09-registration-design.md @@ -0,0 +1,93 @@ +# 上架登记模块设计文档 + +日期:2026-05-09 +状态:已批准 + +--- + +## 1. 概述 + +基于 PRD v1.0 实现上架登记模块,完全替换现有 ScanPage。支持单次上架和连续上架(锁定货位)两种模式。新增配置页面管理 API 地址。 + +## 2. 架构 + +延续现有轻量架构,不引入额外状态管理框架。使用 setState + 简单状态类。 + +### 文件结构 + +``` +lib/ +├── main.dart # MaterialApp + 路由 +├── models/ +│ ├── registration_state.dart # 上架登记页面状态 +│ └── api_config.dart # API 配置模型 +├── pages/ +│ ├── registration_page.dart # 上架登记主页(替换 ScanPage) +│ └── settings_page.dart # 重构:API地址 + 保存 + 测试连接 +├── services/ +│ ├── api_service.dart # 重写:适配 /CargoTrace/location +│ ├── code_parser.dart # 新增:码值识别 +│ └── scanner_service.dart # 保持不变 +``` + +## 3. 核心组件 + +### 3.1 CodeParser(码值识别) + +静态工具类,实现 PRD §4 规则: + +- 总排号:`^\d{2}(B|C|T)\d+$` 或 `^\d{2}(BW|CW)\d{4}$` +- 普通货位:`[区域货架]-[层]-[格]` 全大写横杠分隔 +- 转运货位:`TRANS-` 开头 +- 无效码:不匹配以上规则 + +返回枚举类型:`zongpaiNo` / `locationNormal` / `locationTransit` / `invalid` + +### 3.2 RegistrationPage(上架登记主页) + +状态字段: +- zongpaiNo / locationCode / isLocked / isSubmitting / statusText / errorType + +扫码处理流程: +1. ScannerService 收到扫码事件 +2. CodeParser 识别码值类型 +3. 根据类型填入对应字段(重复同类型覆盖) +4. 锁定模式下货位号不接受新扫码值 +5. 两字段均非空时确认按钮高亮 +6. 提交 → 调用 ApiService → 处理结果 + +### 3.3 SettingsPage(配置页) + +- API 地址输入框 +- 保存设置按钮 → SharedPreferences 持久化 +- 测试连接按钮 → HEAD/GET 请求验证可达性 +- 返回按钮 + +### 3.4 ApiService(API 服务) + +- POST `{baseUrl}/CargoTrace/location` +- 请求体:`{ "zongpai_no": "...", "location_code": "..." }` +- 处理 200/409/400/500 响应 +- 超时 5 秒 + +## 4. 错误处理 + +| 错误类型 | UI 表现 | +|---------|---------| +| 无效码 | 红色 SnackBar,已有字段不受影响 | +| 重复上架 (409) | Dialog 弹窗,显示已有货位和登记时间,需手动关闭 | +| 网络异常 | 黄色提示条,数据保留,可重试 | +| 其他业务错误 | 显示后端错误描述,数据保留 | + +## 5. 反馈机制(当前版本) + +仅实现屏幕反馈:颜色变化、状态提示文案、弹窗。声音/振动/LED 留后续版本。 + +## 6. 页面布局 + +参照 PRD §8.1 的 ASCII 布局: +- 顶部标题栏 + 锁定货位 Toggle +- 总排号输入显示区(扫入时绿色边框) +- 货位号显示区 + 货位类型标签(蓝/橙) +- 确认上架按钮(两字段均填入时高亮) +- 底部状态提示栏 diff --git a/docs/plans/2026-05-09-registration-plan.md b/docs/plans/2026-05-09-registration-plan.md new file mode 100644 index 0000000..311bb41 --- /dev/null +++ b/docs/plans/2026-05-09-registration-plan.md @@ -0,0 +1,941 @@ +# 上架登记模块 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the generic ScanPage with a shelf-registration (上架登记) module that validates scanned barcodes, binds execution card numbers to warehouse locations, and provides API configuration. + +**Architecture:** Lightweight Flutter app using setState for state management, EventChannel for native barcode scanner, SharedPreferences for config persistence, and http package for API calls. CodeParser provides pure regex-based barcode classification. + +**Tech Stack:** Flutter 3.x, Dart, http, shared_preferences, Android native EventChannel (SEUIC scanner) + +--- + +### Task 1: CodeParser — barcode classification service + +**Files:** +- Create: `lib/services/code_parser.dart` +- Test: `test/services/code_parser_test.dart` + +**Step 1: Write the tests** + +```dart +// test/services/code_parser_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_scanner/services/code_parser.dart'; + +void main() { + group('CodeParser.parse', () { + test('identifies zongpaiNo type B', () { + final result = CodeParser.parse('26B1'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26B1'); + }); + + test('identifies zongpaiNo type C', () { + final result = CodeParser.parse('26C12'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26C12'); + }); + + test('identifies zongpaiNo type T', () { + final result = CodeParser.parse('26T3'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26T3'); + }); + + test('identifies zongpaiNo type BW (thermometer, 4 digits)', () { + final result = CodeParser.parse('26BW0001'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26BW0001'); + }); + + test('identifies zongpaiNo type CW (thermometer, 4 digits)', () { + final result = CodeParser.parse('26CW0015'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26CW0015'); + }); + + test('rejects BW with non-4-digit serial', () { + final result = CodeParser.parse('26BW01'); + expect(result.type, CodeType.invalid); + }); + + test('rejects CW with non-4-digit serial', () { + final result = CodeParser.parse('26CW15'); + expect(result.type, CodeType.invalid); + }); + + test('identifies normal location code', () { + final result = CodeParser.parse('A01-02-03'); + expect(result.type, CodeType.locationNormal); + expect(result.value, 'A01-02-03'); + }); + + test('identifies transit location code', () { + final result = CodeParser.parse('TRANS-01'); + expect(result.type, CodeType.locationTransit); + expect(result.value, 'TRANS-01'); + }); + + test('rejects lowercase location code', () { + final result = CodeParser.parse('a01-02-03'); + expect(result.type, CodeType.invalid); + }); + + test('rejects completely invalid code', () { + final result = CodeParser.parse('HELLO123'); + expect(result.type, CodeType.invalid); + }); + + test('rejects empty string', () { + final result = CodeParser.parse(''); + expect(result.type, CodeType.invalid); + }); + + test('isLocation helper returns true for normal location', () { + expect(CodeParser.isLocation(CodeType.locationNormal), true); + expect(CodeParser.isLocation(CodeType.locationTransit), true); + expect(CodeParser.isLocation(CodeType.zongpaiNo), false); + expect(CodeParser.isLocation(CodeType.invalid), false); + }); + + test('identifies production number format (reserved, still invalid)', () { + final result = CodeParser.parse('R00001'); + expect(result.type, CodeType.invalid); + }); + }); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `flutter test test/services/code_parser_test.dart` +Expected: FAIL — `code_parser.dart` does not exist + +**Step 3: Write the implementation** + +```dart +// lib/services/code_parser.dart +/// Regex-based barcode classifier per PRD §4. +class CodeParser { + // 总排号: YY + B/C/T + digits, or YY + BW/CW + exactly 4 digits + static final _zongpaiRegex = RegExp( + r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$', + ); + + // 普通货位: AREA-LEVEL-SLOT, all uppercase letters/digits separated by dashes + static final _locationRegex = RegExp( + r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$', + ); + + // 转运货位: TRANS-xxx + static final _transitRegex = RegExp( + r'^TRANS-', + ); + + /// Parse a scanned code string and return its classification. + static ParseResult parse(String code) { + if (code.isEmpty) { + return ParseResult(type: CodeType.invalid, value: code); + } + + // Check transit first (TRANS- prefix) + if (_transitRegex.hasMatch(code)) { + return ParseResult(type: CodeType.locationTransit, value: code); + } + + // Check zongpai number + if (_zongpaiRegex.hasMatch(code)) { + return ParseResult(type: CodeType.zongpaiNo, value: code); + } + + // Check normal location + if (_locationRegex.hasMatch(code)) { + return ParseResult(type: CodeType.locationNormal, value: code); + } + + return ParseResult(type: CodeType.invalid, value: code); + } + + /// Returns true if the code type represents any location. + static bool isLocation(CodeType type) => + type == CodeType.locationNormal || type == CodeType.locationTransit; +} + +enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid } + +class ParseResult { + final CodeType type; + final String value; + ParseResult({required this.type, required this.value}); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `flutter test test/services/code_parser_test.dart` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add lib/services/code_parser.dart test/services/code_parser_test.dart +git commit -m "feat: add CodeParser for barcode classification per PRD §4" +``` + +--- + +### Task 2: ApiService — rewrite for registration API + +**Files:** +- Modify: `lib/services/api_service.dart` (full rewrite) +- Delete: `lib/models/scan_record.dart` (no longer needed) + +**Step 1: Write the new ApiService** + +```dart +// lib/services/api_service.dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Result of a registration API call. +class RegistrationResult { + final bool success; + final bool isDuplicate; + final String? errorMessage; + final Map? duplicateInfo; + + RegistrationResult({ + required this.success, + this.isDuplicate = false, + this.errorMessage, + this.duplicateInfo, + }); + + factory RegistrationResult.ok() => + RegistrationResult(success: true); + + factory RegistrationResult.duplicate(Map info) => + RegistrationResult(success: false, isDuplicate: true, duplicateInfo: info); + + factory RegistrationResult.error(String message) => + RegistrationResult(success: false, errorMessage: message); +} + +class ApiService { + final http.Client _client; + final Duration timeout; + + ApiService({http.Client? client, this.timeout = const Duration(seconds: 5)}) + : _client = client ?? http.Client(); + + /// Submit a shelf registration (上架登记). + Future registerLocation({ + required String baseUrl, + required String zongpaiNo, + required String locationCode, + }) async { + final uri = Uri.parse('$baseUrl/CargoTrace/location'); + try { + final response = await _client + .post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'zongpai_no': zongpaiNo, + 'location_code': locationCode, + }), + ) + .timeout(timeout); + + switch (response.statusCode) { + case 200: + return RegistrationResult.ok(); + 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})'; + return RegistrationResult.error(msg.toString()); + } + } catch (e) { + return RegistrationResult.error('网络异常,请检查网络连接'); + } + } + + /// Test connectivity by making a HEAD request to the base URL. + Future testConnection(String baseUrl) async { + try { + final uri = Uri.parse(baseUrl); + final response = await _client.head(uri).timeout(timeout); + return response.statusCode < 500; + } catch (_) { + return false; + } + } +} +``` + +**Step 2: Verify compilation** + +Run: `flutter analyze lib/services/api_service.dart` +Expected: No issues + +**Step 3: Commit** + +```bash +git add lib/services/api_service.dart +git rm lib/models/scan_record.dart +git commit -m "feat: rewrite ApiService for registration API, remove ScanRecord model" +``` + +--- + +### Task 3: SettingsPage — rewrite with save + test connection + +**Files:** +- Modify: `lib/pages/settings_page.dart` (full rewrite) + +**Step 1: Write the new SettingsPage** + +```dart +// lib/pages/settings_page.dart +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pad_scanner/services/api_service.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final _controller = TextEditingController(); + final _apiService = ApiService(); + bool _saving = false; + bool _testing = false; + + @override + void initState() { + super.initState(); + _loadUrl(); + } + + Future _loadUrl() async { + final prefs = await SharedPreferences.getInstance(); + final url = prefs.getString('api_url') ?? ''; + _controller.text = url; + } + + Future _saveUrl() async { + final url = _controller.text.trim(); + if (url.isEmpty) { + _showSnackBar('请输入 API 地址', isError: true); + return; + } + setState(() => _saving = true); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('api_url', url); + setState(() => _saving = false); + if (mounted) { + _showSnackBar('设置已保存'); + } + } + + Future _testConnection() async { + final url = _controller.text.trim(); + if (url.isEmpty) { + _showSnackBar('请先输入 API 地址', isError: true); + return; + } + setState(() => _testing = true); + final ok = await _apiService.testConnection(url); + setState(() => _testing = false); + if (mounted) { + _showSnackBar( + ok ? '连接成功' : '连接失败,请检查地址和网络', + isError: !ok, + ); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red.shade700 : Colors.green.shade700, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('设置')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('API 服务器地址', style: TextStyle(fontSize: 14)), + const SizedBox(height: 8), + TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'http://192.168.1.100:8000', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _saving ? null : _saveUrl, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('保存设置'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _testing ? null : _testConnection, + icon: _testing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi), + label: const Text('测试连接'), + ), + ], + ), + ), + ); + } +} +``` + +**Step 2: Verify compilation** + +Run: `flutter analyze lib/pages/settings_page.dart` +Expected: No issues + +**Step 3: Commit** + +```bash +git add lib/pages/settings_page.dart +git commit -m "feat: rewrite SettingsPage with save and test-connection" +``` + +--- + +### Task 4: RegistrationPage — main shelf registration UI + +**Files:** +- Create: `lib/pages/registration_page.dart` + +**Step 1: Write the RegistrationPage** + +This is the main page. It handles: +- Scanner event stream subscription +- Barcode classification via CodeParser +- State management for zongpaiNo, locationCode, lock mode +- Submit flow with error handling +- PRD §8 layout + +```dart +// lib/pages/registration_page.dart +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.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/settings_page.dart'; + +class RegistrationPage extends StatefulWidget { + const RegistrationPage({super.key}); + + @override + State createState() => _RegistrationPageState(); +} + +class _RegistrationPageState extends State { + final _scannerService = ScannerService(); + final _apiService = ApiService(); + + String? _zongpaiNo; + String? _locationCode; + CodeType? _locationType; + bool _isLocked = false; + bool _isSubmitting = false; + + // Feedback state + String? _successMessage; + String? _snackbarMessage; + Color? _snackbarColor; + + @override + void initState() { + super.initState(); + _scannerService.scanResults.listen(_onScan); + } + + void _onScan(ScanResult result) { + final parsed = CodeParser.parse(result.barcode); + switch (parsed.type) { + case CodeType.zongpaiNo: + setState(() { + _zongpaiNo = parsed.value; + _snackbarMessage = null; + _successMessage = null; + }); + case CodeType.locationNormal: + case CodeType.locationTransit: + if (!_isLocked) { + setState(() { + _locationCode = parsed.value; + _locationType = parsed.type; + _snackbarMessage = null; + _successMessage = null; + }); + } + case CodeType.invalid: + _showFeedback('无效码,请重新扫描', isError: true); + } + } + + void _showFeedback(String message, {bool isError = false}) { + setState(() { + _snackbarMessage = message; + _snackbarColor = isError ? Colors.red.shade700 : Colors.green.shade700; + }); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _snackbarMessage = null); + }); + } + + String get _statusText { + if (_isSubmitting) return '正在提交…'; + if (_successMessage != null) return _successMessage!; + if (_isLocked && _locationCode != null && _zongpaiNo == null) { + return '货位已锁定,请扫描下一张执行卡'; + } + final hasZ = _zongpaiNo != null; + final hasL = _locationCode != null; + if (hasZ && hasL) return '请确认信息并提交'; + if (hasZ && !hasL) return '请扫描目标货位号'; + if (!hasZ && hasL) return '请扫描执行卡'; + return '等待扫描总排号或货位号…'; + } + + bool get _canSubmit => + _zongpaiNo != null && + _locationCode != null && + !_isSubmitting; + + Future _submit() async { + if (!_canSubmit) return; + + final prefs = await SharedPreferences.getInstance(); + final baseUrl = prefs.getString('api_url') ?? ''; + if (baseUrl.isEmpty) { + _showFeedback('未配置 API 地址,请前往设置', isError: true); + return; + } + + setState(() => _isSubmitting = true); + + final result = await _apiService.registerLocation( + baseUrl: baseUrl, + zongpaiNo: _zongpaiNo!, + locationCode: _locationCode!, + ); + + if (!mounted) return; + + setState(() => _isSubmitting = false); + + if (result.success) { + setState(() { + _zongpaiNo = null; + if (!_isLocked) { + _locationCode = null; + _locationType = null; + } + _successMessage = _isLocked + ? '货位已锁定,请扫描下一张执行卡' + : '上架成功'; + }); + _showFeedback( + _isLocked ? '上架成功' : '上架成功', + isError: false, + ); + // Auto-clear success message + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _successMessage = null); + }); + } else if (result.isDuplicate) { + _showDuplicateDialog(result.duplicateInfo); + } else { + _showFeedback(result.errorMessage ?? '提交失败', isError: true); + } + } + + void _showDuplicateDialog(Map? info) { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.red.shade50, + title: const Row( + children: [ + Icon(Icons.warning, color: Colors.red), + SizedBox(width: 8), + Text('重复上架', style: TextStyle(color: Colors.red)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('总排号:${_zongpaiNo ?? ""}'), + const SizedBox(height: 4), + Text('已登记货位:${info?["location_code"] ?? "未知"}'), + const SizedBox(height: 4), + Text('登记时间:${info?["registered_at"] ?? "未知"}'), + const SizedBox(height: 12), + const Text('请核查实物,确认是否操作错误。', style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('关闭'), + ), + ], + ), + ); + } + + void _toggleLock(bool value) { + if (value && _locationCode == null) { + _showFeedback('请先扫描货位号', isError: true); + return; + } + setState(() => _isLocked = value); + } + + String _locationLabel(CodeType? type) { + if (type == CodeType.locationTransit) return '转运区域'; + return '普通货架'; + } + + Color _locationLabelColor(CodeType? type) { + if (type == CodeType.locationTransit) return Colors.orange; + return Colors.blue; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('上架登记'), + actions: [ + // Lock toggle in app bar + Padding( + padding: const EdgeInsets.only(right: 4), + 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()), + ), + ), + ], + ), + ), + ], + ), + body: Column( + children: [ + // Feedback snackbar area + if (_snackbarMessage != null) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + color: _snackbarColor, + child: Text( + _snackbarMessage!, + style: const TextStyle(color: Colors.white, fontSize: 14), + textAlign: TextAlign.center, + ), + ), + + // Main form area + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Zongpai number field + const Align( + alignment: Alignment.centerLeft, + child: Text('总排号', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all( + color: _zongpaiNo != null ? Colors.green : Colors.grey.shade400, + width: _zongpaiNo != null ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _zongpaiNo ?? '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _zongpaiNo != null ? Colors.black87 : Colors.grey, + ), + ), + ), + + const SizedBox(height: 20), + + // Location field + Row( + children: [ + 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), + decoration: BoxDecoration( + color: _locationLabelColor(_locationType).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _locationLabel(_locationType), + style: TextStyle( + fontSize: 11, + color: _locationLabelColor(_locationType), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all( + color: _locationCode != null ? Colors.green : Colors.grey.shade400, + width: _locationCode != null ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + _locationCode ?? '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _locationCode != null ? Colors.black87 : Colors.grey, + ), + ), + ), + if (_isLocked) + const Icon(Icons.lock, color: Colors.orange, size: 20), + ], + ), + ), + + const SizedBox(height: 28), + + // Submit button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _canSubmit ? _submit : null, + style: ElevatedButton.styleFrom( + backgroundColor: _canSubmit + ? colorScheme.primary + : Colors.grey.shade300, + foregroundColor: _canSubmit + ? Colors.white + : Colors.grey.shade600, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isSubmitting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('确 认 上 架', style: TextStyle(fontSize: 18)), + ), + ), + ], + ), + ), + ), + + // Status bar at bottom + 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: _isSubmitting + ? Colors.orange + : _successMessage != null + ? Colors.green + : Colors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusText, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } +} +``` + +**Step 2: Verify compilation** + +Run: `flutter analyze lib/pages/registration_page.dart` +Expected: No issues + +**Step 3: Commit** + +```bash +git add lib/pages/registration_page.dart +git commit -m "feat: add RegistrationPage with single and lock-mode shelf registration" +``` + +--- + +### Task 5: main.dart — wire up RegistrationPage as home + +**Files:** +- Modify: `lib/main.dart` +- Delete: `lib/pages/scan_page.dart` + +**Step 1: Update main.dart** + +```dart +// lib/main.dart +import 'package:flutter/material.dart'; +import 'package:pad_scanner/pages/registration_page.dart'; + +void main() { + runApp(const PadScannerApp()); +} + +class PadScannerApp extends StatelessWidget { + const PadScannerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '上架登记', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + home: const RegistrationPage(), + ); + } +} +``` + +**Step 2: Delete old ScanPage** + +```bash +git rm lib/pages/scan_page.dart +``` + +**Step 3: Verify full project compiles** + +Run: `flutter analyze` +Expected: No issues + +**Step 4: Commit** + +```bash +git add lib/main.dart +git commit -m "feat: wire RegistrationPage as app home, remove ScanPage" +``` + +--- + +### Task 6: Final verification + +**Step 1: Run all tests** + +Run: `flutter test` +Expected: All tests pass + +**Step 2: Run full analysis** + +Run: `flutter analyze` +Expected: No issues found + +**Step 3: Verify no dangling imports** + +Search for any remaining references to `scan_page.dart` or `scan_record.dart`: +Run: `grep -r "scan_page\|scan_record" lib/` +Expected: No matches diff --git a/docs/上架登记模块PRD_v1.0.md b/docs/上架登记模块PRD_v1.0.md new file mode 100644 index 0000000..76cf0b5 --- /dev/null +++ b/docs/上架登记模块PRD_v1.0.md @@ -0,0 +1,310 @@ +# PRD · 模块一:上架登记 + +版本:v1.0 +状态:待评审 +适用端:安卓扫码枪 Flutter 应用 + +--- + +## 1. 功能概述 + +上架登记模块是成品库房作业流程的第二步。工人完成分堆后,需要将每张执行卡对应的产品登记到具体的货位上。系统记录总排号与货位号的绑定关系,为后续的货架查询、清点和装箱编号提供数据基础。 + +本模块支持两种操作场景: + +- **单次上架**:每次绑定一个总排号到一个货位; +- **连续上架(锁定模式)**:锁定一个货位后,连续扫描多个总排号,批量完成绑定。 + +--- + +## 2. 用户与使用场景 + +**使用人员**:成品库房工人 + +**典型场景**: + +|场景|描述| +|---|---| +|普通上架|工人将某个执行卡对应的产品放到货架上,扫码登记货架货位。| +|转运上架|工人判断某个产品可以直接进入转运流程,扫码登记转运特殊货位。| +|批量上架|同一批次多个执行卡产品放到同一货架位,锁定货位后连续扫执行卡。| + +--- + +## 3. 设备能力约定 + +|项目|说明| +|---|---| +|扫码输出方式|广播输出(Android Intent)| +|屏幕尺寸|3.5 英寸| +|键盘|31 键实体键盘| +|网络|WiFi / 蓝牙 / 蜂窝,三种模式| +|反馈方式|声音、振动、LED 灯、屏幕显示| + +Flutter 层通过 `EventChannel` 接收原生广播,不依赖输入框焦点捕获。 + +--- + +## 4. 码值识别规则 + +系统根据扫入码值的格式自动判断类型,不强制要求扫码顺序。 + +|码值类型|识别规则|示例| +|---|---|---| +|总排号|正则 `^\d{2}(B\|C\|T)\d+$\|^\d{2}(BW\|CW)\d{4}$`,温度计(BW/CW)流水号必须固定4位数字,其余类型不补零|`26B1`、`26C12`、`26BW0001`、`26CW0015`、`26T3`| +|排产号|正则 `^[A-Z]{1,2}\d{5}(-J)?$`(当前模块暂不使用,预留)|`R00001`、`CY00012`、`R06936-J`| +|普通货位号|格式为 `[区域货架]-[层]-[格]`,全大写,横杠分隔|`A01-02-03`| +|转运特殊货位号|以 `TRANS-` 开头|`TRANS-01`| +|无效码|不符合以上任何规则|—| + +**重复扫码规则**:在同一次提交动作完成前,如果同一类型的码被多次扫入,取最后一次扫入的值作为有效值,前一次自动覆盖。 + +--- + +## 5. 功能详细说明 + +### 5.1 单次上架模式 + +#### 5.1.1 进入方式 + +应用启动后默认进入单次上架模式。 + +#### 5.1.2 操作流程 + +```mermaid +flowchart TD + A([进入上架登记页]) --> B[等待扫码\n总排号或货位号,顺序不限] + B --> C{识别码值类型} + C -->|总排号格式| D[填入总排号字段] + C -->|货位号格式| E[填入货位号字段] + C -->|无效码| F[提示无效码\n不影响已有字段] + F --> B + D -->|重复扫入同类型码| D + E -->|重复扫入同类型码| E + D --> G{两个字段\n是否均已填入} + E --> G + G -->|否| B + G -->|是| H[确认按钮高亮\n工人核查信息] + H --> I[按确认键提交] + I --> J[调用后端接口] + J --> K{结果} + K -->|成功| L[清空页面\n成功反馈 1.5s\n等待下一次扫码] + K -->|重复上架| M[错误弹窗\n显示已有货位与时间\n需手动关闭] + K -->|网络异常| N[保留数据\n提示检查网络\n可直接重试] + K -->|其他错误| O[保留数据\n显示错误描述] + L --> B + M --> B + N --> I +``` + +#### 5.1.3 提交前校验 + +|校验项|规则|处理方式| +|---|---|---| +|总排号格式|必须符合 `两位年份+类型字母(B/C/BW/CW/T)+流水号` 格式;温度计类型流水号必须为4位|扫入时即提示"无效码"| +|货位号格式|必须符合普通货位或转运特殊货位规则|扫入时即提示"无效码"| +|两个字段均已填入|确认按钮才可点击|按钮置灰,无法提交| +|总排号已存在货位记录|后端返回冲突错误|显示错误提示,见 §6.2| + +#### 5.1.4 提交成功后的状态 + +- 清空总排号字段和货位号字段; +- 页面回到初始等待扫码状态; +- 成功反馈持续约 1.5 秒后自动消失; +- 货位锁定模式未开启时,货位号字段也一并清空。 + +--- + +### 5.2 连续上架模式(锁定货位) + +#### 5.2.1 进入方式 + +页面上提供"锁定货位"切换控件(Toggle)。工人在货位号字段已填入有效值时,可以开启锁定模式。 + +#### 5.2.2 锁定模式行为 + +|行为|说明| +|---|---| +|货位号字段锁定|货位号字段显示为锁定状态,新扫入的货位号码值不会覆盖当前货位号| +|总排号字段正常工作|每次扫入总排号后,填入总排号字段,等待工人确认| +|确认提交|提交成功后,仅清空总排号字段,货位号保持不变,页面立即进入等待下一个总排号状态| +|解除锁定|工人再次点击控件解除锁定,货位号字段恢复可覆盖状态| + +#### 5.2.3 锁定模式操作流程 + +```mermaid +flowchart TD + A([进入上架登记页]) --> B[扫入有效货位号] + B --> C[开启锁定货位 Toggle] + C --> D[货位号字段锁定\n新扫入货位码不覆盖] + D --> E[扫入总排号] + E --> F{总排号\n是否有效} + F -->|无效| G[提示无效码] --> E + F -->|有效| H[填入总排号字段] + H --> I[工人核查信息\n按确认键提交] + I --> J[调用后端接口] + J --> K{结果} + K -->|成功| L[仅清空总排号字段\n货位号保持锁定] + K -->|重复上架| M[错误弹窗\n需手动关闭] + K -->|失败| N[保留数据\n显示错误] + L --> O{继续\n锁定模式?} + M --> O + N --> O + O -->|是| E + O -->|解除锁定| P[关闭 Toggle\n货位号字段恢复可覆盖] + P --> Q([返回单次上架模式]) +``` + +--- + +## 6. 异常与错误处理 + +### 6.1 无效码 + +扫入码值无法识别为总排号或货位号时: + +- 屏幕顶部短暂显示红色提示条:"无效码,请重新扫描"; +- 当前已填入的有效字段不受影响; +- 触发错误音效。 + +### 6.2 总排号重复上架(核心错误) + +后端返回该总排号已存在货位记录: + +- 页面显示明显的错误提示弹窗,内容包含: + - 当前总排号; + - 该总排号已登记的货位号; + - 登记时间; +- 提示工人核查实物,确认是否发生了错误操作; +- 弹窗需要工人主动关闭,不自动消失; +- 当前页面数据保留,等待工人决定下一步操作; +- 触发与普通失败不同的错误音效(更明显)。 + +> 设计说明:重复上架在正常业务中不应发生。一旦出现,说明现场可能有问题(如同一产品被两人同时操作、扫错执行卡等),需要工人停下来核查,不应允许静默通过。 + +### 6.3 网络异常 + +接口请求超时或网络不可达: + +- 屏幕显示"网络异常,请检查网络连接"; +- 当前已填入的数据保留; +- 工人可在网络恢复后直接重试,无需重新扫码; +- 触发失败音效。 + +### 6.4 后端其他错误 + +后端返回其他业务错误: + +- 显示后端返回的错误描述(需后端统一错误格式); +- 当前数据保留,等待工人处理。 + +--- + +## 7. 反馈机制 + +|事件|屏幕|声音|振动|LED| +|---|---|---|---|---| +|扫入有效总排号|总排号字段填入,绿色高亮|短促提示音|短震|—| +|扫入有效货位号|货位号字段填入,绿色高亮|短促提示音|短震|—| +|扫入无效码|红色提示条|错误音|短震|红色闪烁| +|提交成功|全屏绿色提示 1.5 秒|成功音|长震|绿色常亮 1.5 秒| +|重复上架错误|红色错误弹窗(需手动关闭)|连续错误音|连续震|红色闪烁| +|网络异常|黄色提示条|失败音|短震|—| + +--- + +## 8. 页面结构 + +### 8.1 主页面布局 + +``` +┌──────────────────────────────┐ +│ 上架登记 [锁定货位 ○] │ ← 顶部标题栏 + 锁定控件 +├──────────────────────────────┤ +│ │ +│ 总排号 │ +│ ┌────────────────────────┐ │ +│ │ 26B1 │ │ ← 已扫入时绿色边框 +│ └────────────────────────┘ │ +│ │ +│ 目标货位 │ +│ ┌────────────────────────┐ │ +│ │ A01-02-03 [普通货架] │ │ ← 货位类型标签 +│ └────────────────────────┘ │ ← 锁定时显示锁图标 +│ │ +│ ┌────────────────────────┐ │ +│ │ 确 认 上 架 │ │ ← 两个字段均已填入时高亮 +│ └────────────────────────┘ │ +│ │ +├──────────────────────────────┤ +│ ● 等待扫描总排号... │ ← 当前状态提示 +└──────────────────────────────┘ +``` + +### 8.2 状态提示栏文案 + +|当前状态|提示文案| +|---|---| +|两个字段均为空|等待扫描总排号或货位号…| +|仅总排号已填入|请扫描目标货位号| +|仅货位号已填入|请扫描执行卡| +|两个字段均已填入|请确认信息并提交| +|提交中|正在提交…| +|锁定模式下提交成功|货位已锁定,请扫描下一张执行卡| + +### 8.3 货位类型标签 + +|货位类型|标签文字|标签颜色| +|---|---|---| +|普通货架货位|普通货架|蓝色| +|转运特殊货位|转运区域|橙色| + +--- + +## 9. 接口依赖 + +### 9.1 上架登记接口 + +**POST** `/CargoTrace/location` + +请求体: + +```json +{ + "zongpai_no": "26B1", + "location_code": "A01-02-03" +} +``` + +成功响应:`200 OK` + +失败响应: + +|HTTP 状态码|错误码|含义| +|---|---|---| +|409 Conflict|`DUPLICATE_LOCATION`|该总排号已存在货位记录| +|400 Bad Request|`INVALID_ZONGPAI`|总排号不存在| +|400 Bad Request|`INVALID_LOCATION`|货位号不合法| +|500|`SERVER_ERROR`|服务器内部错误| + +--- + +## 10. 非功能要求 + +|项目|要求| +|---|---| +|接口响应时间|正常网络下 ≤ 500ms| +|扫码到字段填入延迟|≤ 100ms(本地处理,无网络请求)| +|离线处理策略|断网时不允许提交,提示工人检查网络,数据保留在页面上| +|货位类型本地判断|货位类型(普通/转运)由客户端根据编码规则本地判断,不依赖接口| + +--- + +## 11. 超出当前版本范围 + +以下内容在当前版本中不实现: + +- 操作日志与操作人员记录(登录功能待定); +- 重复上架的强制覆盖功能; +- 离线缓存与联网同步; +- 总排号货位变更历史查询。 \ No newline at end of file diff --git a/test/models/scan_record_test.dart b/test/models/scan_record_test.dart deleted file mode 100644 index 270017d..0000000 --- a/test/models/scan_record_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:pad_scanner/models/scan_record.dart'; - -void main() { - group('ScanRecord', () { - test('creates with correct defaults', () { - final record = ScanRecord( - barcode: '1234567890', - codeType: 'CODE128', - timestamp: DateTime.parse('2026-05-07T10:30:00Z'), - ); - expect(record.barcode, '1234567890'); - expect(record.codeType, 'CODE128'); - expect(record.status, SendStatus.pending); - }); - - test('toJson produces correct map', () { - final record = ScanRecord( - barcode: 'ABC123', - codeType: 'QR', - timestamp: DateTime.parse('2026-05-07T10:30:00Z'), - ); - final json = record.toJson(); - expect(json['barcode'], 'ABC123'); - expect(json['code_type'], 'QR'); - expect(json['timestamp'], '2026-05-07T10:30:00.000Z'); - }); - }); -} diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart deleted file mode 100644 index 088a1ca..0000000 --- a/test/services/api_service_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:pad_scanner/services/api_service.dart'; - -void main() { - group('ApiService', () { - test('sendScanData returns true on 200', () async { - final client = MockClient((request) async { - return http.Response('{"status":"ok"}', 200); - }); - final service = ApiService(client: client); - final result = await service.sendScanData( - 'http://localhost:8000/scan', - barcode: '123456', - codeType: 'CODE128', - ); - expect(result, isTrue); - }); - - test('sendScanData returns false on error', () async { - final client = MockClient((request) async { - return http.Response('error', 500); - }); - final service = ApiService(client: client); - final result = await service.sendScanData( - 'http://localhost:8000/scan', - barcode: '123456', - codeType: 'CODE128', - ); - expect(result, isFalse); - }); - }); -}