# 上架登记模块 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