From b2f2c536cbfddb90f25ae4088b023b22596009ec Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Fri, 15 May 2026 08:45:22 +0800 Subject: [PATCH] feat(registration): merge shelf overview into registration --- lib/pages/registration_page.dart | 841 +++++++++++++++++----------- lib/services/api_service.dart | 98 ++++ test/services/api_service_test.dart | 80 +++ 3 files changed, 706 insertions(+), 313 deletions(-) diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index 20e351f..554f09d 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; @@ -20,22 +22,29 @@ class _RegistrationPageState extends State { final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); + StreamSubscription? _scanSubscription; final _zongpaiNos = []; String? _locationCode; CodeType? _locationType; bool _isLocked = false; bool _isSubmitting = false; - // Status bar state StatusDotColor _statusDot = StatusDotColor.blue; String _statusText = '等待扫描总排号或货位号…'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; + bool _overviewLoading = false; + bool _overviewNotFound = false; + String? _overviewError; + String? _overviewZongpaiNo; + PaichaOverviewResult? _overview; + int _overviewRequestId = 0; + @override void initState() { super.initState(); - _scannerService.scanResults.listen(_onScan); + _scanSubscription = _scannerService.scanResults.listen(_onScan); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -43,6 +52,7 @@ class _RegistrationPageState extends State { @override void dispose() { + _scanSubscription?.cancel(); _focusNode.dispose(); _feedbackService.dispose(); super.dispose(); @@ -58,24 +68,7 @@ class _RegistrationPageState extends State { final parsed = CodeParser.parse(result.barcode); switch (parsed.type) { case CodeType.zongpaiNo: - _feedbackService.trigger(FeedbackEvent.scanValid); - setState(() { - if (_isLocked) { - final idx = _zongpaiNos.indexOf(parsed.value); - if (idx >= 0) { - _zongpaiNos[idx] = parsed.value; - } else { - _zongpaiNos.add(parsed.value); - } - } else { - if (_zongpaiNos.isNotEmpty) { - _zongpaiNos[0] = parsed.value; - } else { - _zongpaiNos.add(parsed.value); - } - } - _clearStatusOverride(); - }); + _handleZongpaiScan(parsed.value); case CodeType.locationNormal: case CodeType.locationTransit: if (!_isLocked) { @@ -96,13 +89,33 @@ class _RegistrationPageState extends State { } } - void _removeZongpai(int index) { + void _handleZongpaiScan(String zongpaiNo) { + final shouldRefresh = _overviewZongpaiNo != zongpaiNo; + _feedbackService.trigger(FeedbackEvent.scanValid); setState(() { - _zongpaiNos.removeAt(index); + if (_isLocked) { + if (!_zongpaiNos.contains(zongpaiNo)) { + _zongpaiNos.add(zongpaiNo); + } + } else { + if (_zongpaiNos.isEmpty) { + _zongpaiNos.add(zongpaiNo); + } else { + _zongpaiNos[0] = zongpaiNo; + } + } + _clearStatusOverride(); }); + if (shouldRefresh) { + _loadOverview(zongpaiNo); + } } - // --- Status bar management --- + void _removeZongpai(String zongpaiNo) { + setState(() { + _zongpaiNos.remove(zongpaiNo); + }); + } void _clearStatusOverride() { _statusOverrideText = null; @@ -130,15 +143,15 @@ class _RegistrationPageState extends State { _statusText = '货位已锁定,请扫描下一张执行卡'; return; } - final hasZ = _zongpaiNos.isNotEmpty; - final hasL = _locationCode != null; - if (hasZ && hasL) { + final hasZongpai = _zongpaiNos.isNotEmpty; + final hasLocation = _locationCode != null; + if (hasZongpai && hasLocation) { _statusDot = StatusDotColor.blue; _statusText = '请确认信息并提交'; - } else if (hasZ && !hasL) { + } else if (hasZongpai) { _statusDot = StatusDotColor.blue; _statusText = '请扫描目标货位号'; - } else if (!hasZ && hasL) { + } else if (hasLocation) { _statusDot = StatusDotColor.blue; _statusText = '请扫描执行卡'; } else { @@ -150,12 +163,70 @@ class _RegistrationPageState extends State { bool get _canSubmit => _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; - Future _submit() async { - if (!_canSubmit) return; - + Future _baseUrl() async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { + return null; + } + return baseUrl; + } + + Future _loadOverview(String zongpaiNo, {bool force = false}) async { + if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) { + return; + } + final requestId = ++_overviewRequestId; + setState(() { + _overviewZongpaiNo = zongpaiNo; + _overviewLoading = true; + _overviewNotFound = false; + _overviewError = null; + }); + + final baseUrl = await _baseUrl(); + if (!mounted || requestId != _overviewRequestId) return; + if (baseUrl == null) { + setState(() { + _overviewLoading = false; + _overviewError = '未配置 API 地址,请前往设置'; + }); + return; + } + + final result = await _apiService.fetchPaichaOverview( + baseUrl: baseUrl, + zongpaiNo: zongpaiNo, + ); + if (!mounted || requestId != _overviewRequestId) return; + + setState(() { + _overviewLoading = false; + if (result.success) { + _overview = result; + _overviewNotFound = false; + _overviewError = null; + } else if (result.notFound) { + _overview = null; + _overviewNotFound = true; + _overviewError = null; + } else { + _overviewError = result.errorMessage ?? '加载失败,点击重试'; + } + }); + } + + Future _refreshCurrentOverview() async { + final zongpaiNo = _overviewZongpaiNo; + if (zongpaiNo == null) return; + await _loadOverview(zongpaiNo, force: true); + } + + Future _submit() async { + if (!_canSubmit) return; + + final baseUrl = await _baseUrl(); + if (baseUrl == null) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', @@ -202,6 +273,7 @@ class _RegistrationPageState extends State { StatusDotColor.green, const Duration(milliseconds: 1500), ); + await _refreshCurrentOverview(); } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateError); _showDuplicateDialog(zongpaiNo, result.duplicateInfo); @@ -231,10 +303,10 @@ class _RegistrationPageState extends State { final failed = []; final toRemove = []; - for (final zp in List.of(_zongpaiNos)) { + for (final zongpaiNo in List.of(_zongpaiNos)) { final result = await _apiService.registerLocation( baseUrl: baseUrl, - zongpaiNo: zp, + zongpaiNo: zongpaiNo, locationCode: _locationCode!, ); if (result.success) { @@ -242,21 +314,21 @@ class _RegistrationPageState extends State { if (result.isOffShelfSuccess) { offShelfCount++; } - toRemove.add(zp); + toRemove.add(zongpaiNo); } else { - failed.add(zp); + failed.add(zongpaiNo); if (result.isDuplicate) { setState(() { - _zongpaiNos.removeWhere((e) => toRemove.contains(e)); + _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); _feedbackService.trigger(FeedbackEvent.duplicateError); - _showDuplicateDialog(zp, result.duplicateInfo); + _showDuplicateDialog(zongpaiNo, result.duplicateInfo); return; } if (result.isAlreadyOffShelf) { setState(() { - _zongpaiNos.removeWhere((e) => toRemove.contains(e)); + _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); _feedbackService.trigger(FeedbackEvent.submitFailure); @@ -273,7 +345,7 @@ class _RegistrationPageState extends State { if (!mounted) return; setState(() { - _zongpaiNos.removeWhere((e) => toRemove.contains(e)); + _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); @@ -287,6 +359,7 @@ class _RegistrationPageState extends State { StatusDotColor.green, const Duration(milliseconds: 1500), ); + await _refreshCurrentOverview(); } else { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( @@ -351,10 +424,8 @@ class _RegistrationPageState extends State { } setState(() { _isLocked = value; - if (!value) { - if (_zongpaiNos.length > 1) { - _zongpaiNos.removeRange(0, _zongpaiNos.length - 1); - } + if (!value && _zongpaiNos.length > 1) { + _zongpaiNos.removeRange(0, _zongpaiNos.length - 1); } }); } @@ -369,11 +440,20 @@ class _RegistrationPageState extends State { return Colors.blue; } + Color _overviewRowColor(String status) { + switch (status) { + case 'on_shelf': + return const Color(0xFFE3F2FD); + case 'transferred': + return const Color(0xFFFFF3E0); + default: + return const Color(0xFFF5F5F5); + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - - // Compute effective status _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; @@ -398,285 +478,420 @@ class _RegistrationPageState extends State { ), body: Column( children: [ - // Main form area (no top banner) - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // ---- 目标货位 (上方) ---- - 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: 16), - - // ---- 总排号 (下方) ---- - Row( - children: [ - const Text( - '总排号', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - if (_isLocked && _zongpaiNos.isNotEmpty) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${_zongpaiNos.length} 条', - style: const TextStyle( - fontSize: 11, - color: Colors.blue, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 6), - - if (_isLocked) ...[ - // 锁定模式:列表显示多条总排号 - Expanded( - child: _zongpaiNos.isEmpty - ? Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade400, - ), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '', - style: TextStyle( - fontSize: 20, - color: Colors.grey, - ), - ), - ) - : ListView.separated( - itemCount: _zongpaiNos.length, - separatorBuilder: (_, __) => - const SizedBox(height: 6), - itemBuilder: (context, index) { - return Dismissible( - key: ValueKey( - '${_zongpaiNos[index]}-$index', - ), - direction: DismissDirection.endToStart, - onDismissed: (_) => _removeZongpai(index), - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - decoration: BoxDecoration( - color: Colors.red.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.delete, - color: Colors.red, - ), - ), - child: Container( - 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: [ - Expanded( - child: Text( - _zongpaiNos[index], - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ), - IconButton( - icon: const Icon( - Icons.close, - size: 20, - ), - onPressed: () => - _removeZongpai(index), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ), - ); - }, - ), - ), - ] else ...[ - // 单次模式:单个显示 - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 14, - ), - decoration: BoxDecoration( - border: Border.all( - color: _zongpaiNos.isNotEmpty - ? Colors.green - : Colors.grey.shade400, - width: _zongpaiNos.isNotEmpty ? 2 : 1, - ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '登记操作区', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + _isLocked + ? _buildLockedInputPanel() + : _buildSingleInputPanel(), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + height: 46, + 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: Text( - _zongpaiNos.isNotEmpty ? _zongpaiNos.first : '', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: _zongpaiNos.isNotEmpty - ? Colors.black87 - : Colors.grey, - ), - ), ), - ], - - const SizedBox(height: 16), - - // 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, - ), - ) - : Text( - _isLocked && _zongpaiNos.length > 1 - ? '批量上架(${_zongpaiNos.length} 条)' - : '确认上架(P1)', - style: const TextStyle(fontSize: 18), + child: _isSubmitting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, ), - ), + ) + : Text( + _isLocked && _zongpaiNos.length > 1 + ? '批量上架(${_zongpaiNos.length} 条)' + : '确 认 上 架', + style: const TextStyle(fontSize: 17), + ), ), - ], - ), + ), + ], ), ), - - // Bottom status bar + Divider(height: 1, color: Colors.grey.shade300), + Expanded(child: _buildOverviewSection()), StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ), ); } + + Widget _buildSingleInputPanel() { + final hasLocation = _locationCode != null; + final hasZongpai = _zongpaiNos.isNotEmpty; + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 58), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + border: Border.all( + color: hasLocation || hasZongpai + ? Colors.green + : Colors.grey.shade400, + width: hasLocation || hasZongpai ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Flexible( + flex: 5, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + _locationCode ?? '', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: hasLocation ? Colors.black87 : Colors.grey, + ), + ), + ), + if (hasLocation) ...[ + const SizedBox(width: 6), + _buildLocationChip(), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '|', + style: TextStyle(fontSize: 20, color: Colors.grey.shade500), + ), + ), + Flexible( + flex: 3, + child: Text( + hasZongpai ? _zongpaiNos.first : '', + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: hasZongpai ? Colors.black87 : Colors.grey, + ), + ), + ), + ], + ), + ); + } + + Widget _buildLockedInputPanel() { + final hasLocation = _locationCode != null; + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 82), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: hasLocation || _zongpaiNos.isNotEmpty + ? Colors.green + : Colors.grey.shade400, + width: hasLocation || _zongpaiNos.isNotEmpty ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _locationCode ?? '', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: hasLocation ? Colors.black87 : Colors.grey, + ), + ), + ), + const Icon(Icons.lock, color: Colors.orange, size: 18), + const SizedBox(width: 6), + if (hasLocation) _buildLocationChip(), + ], + ), + const SizedBox(height: 8), + if (_zongpaiNos.isEmpty) + const SizedBox(height: 24) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: _zongpaiNos + .map( + (zongpaiNo) => InputChip( + label: Text(zongpaiNo), + visualDensity: VisualDensity.compact, + onDeleted: () => _removeZongpai(zongpaiNo), + ), + ) + .toList(), + ), + ], + ), + ); + } + + Widget _buildLocationChip() { + final color = _locationLabelColor(_locationType); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _locationLabel(_locationType), + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildOverviewSection() { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + '排产号货架总览', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + if (_overviewLoading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 6), + Expanded(child: _buildOverviewBody()), + ], + ), + ); + } + + Widget _buildOverviewBody() { + if (_overviewZongpaiNo == null) { + return _buildOverviewMessage('扫描总排号后自动显示排产号上架情况'); + } + if (_overviewLoading && _overview == null) { + return _buildOverviewMessage('正在加载排产号上架情况…'); + } + if (_overviewNotFound) { + return _buildOverviewMessage('暂无排产信息'); + } + if (_overviewError != null && _overview == null) { + return _buildRetryMessage(_overviewError!); + } + final overview = _overview; + if (overview == null) { + return _buildOverviewMessage('扫描总排号后自动显示排产号上架情况'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_overviewError != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text( + _overviewError!, + style: const TextStyle(fontSize: 12, color: Colors.red), + ), + ), + TextButton( + onPressed: _overviewZongpaiNo == null + ? null + : () => _loadOverview(_overviewZongpaiNo!, force: true), + child: const Text('重试'), + ), + ], + ), + ), + Text( + '${overview.paichaNo ?? "--"} 共 ${overview.totalCount} 个总排号', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + _buildOverviewHeader(), + Expanded( + child: ListView.builder( + itemCount: overview.items.length, + itemBuilder: (context, index) { + return _buildOverviewRow(overview.items[index]); + }, + ), + ), + ], + ); + } + + Widget _buildOverviewHeader() { + return Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: Colors.grey.shade200, + border: Border.all(color: Colors.grey.shade400), + ), + child: const Row( + children: [ + Expanded(flex: 20, child: Text('总排号', style: _headerStyle)), + Expanded(flex: 20, child: Text('工令号', style: _headerStyle)), + Expanded( + flex: 14, + child: Text('数量', textAlign: TextAlign.center, style: _headerStyle), + ), + Expanded( + flex: 26, + child: Text('货位号', textAlign: TextAlign.right, style: _headerStyle), + ), + ], + ), + ); + } + + Widget _buildOverviewRow(PaichaOverviewItem item) { + final current = item.zongpaiNo == _overviewZongpaiNo; + final rowStyle = TextStyle( + fontSize: 12, + fontWeight: current ? FontWeight.w700 : FontWeight.w500, + color: Colors.black87, + ); + return Container( + constraints: const BoxConstraints(minHeight: 34), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: _overviewRowColor(item.status), + border: Border( + left: BorderSide( + color: current ? Colors.green : Colors.grey.shade300, + width: current ? 2 : 1, + ), + right: BorderSide( + color: current ? Colors.green : Colors.grey.shade300, + width: current ? 2 : 1, + ), + bottom: BorderSide( + color: current ? Colors.green : Colors.grey.shade300, + width: current ? 2 : 1, + ), + ), + ), + child: Row( + children: [ + Expanded( + flex: 20, + child: Text( + item.zongpaiNo, + overflow: TextOverflow.ellipsis, + style: rowStyle, + ), + ), + Expanded( + flex: 20, + child: Text( + item.workOrderNo ?? '--', + overflow: TextOverflow.ellipsis, + style: rowStyle, + ), + ), + Expanded( + flex: 14, + child: Text( + item.quantity.toString(), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: rowStyle, + ), + ), + Expanded( + flex: 26, + child: Text( + item.locationCode ?? '—', + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: rowStyle, + ), + ), + ], + ), + ); + } + + Widget _buildOverviewMessage(String text) { + return Center( + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ); + } + + Widget _buildRetryMessage(String text) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Colors.red), + ), + const SizedBox(height: 6), + TextButton( + onPressed: _overviewZongpaiNo == null + ? null + : () => _loadOverview(_overviewZongpaiNo!, force: true), + child: const Text('重试'), + ), + ], + ), + ); + } } + +const _headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w700); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 319999a..27dbde0 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -46,6 +46,76 @@ class RegistrationResult { RegistrationResult(success: false, errorMessage: message); } +class PaichaOverviewItem { + final String zongpaiNo; + final String? workOrderNo; + final int quantity; + final String? locationCode; + final String status; + + PaichaOverviewItem({ + required this.zongpaiNo, + this.workOrderNo, + required this.quantity, + this.locationCode, + required this.status, + }); + + factory PaichaOverviewItem.fromJson(Map json) { + return PaichaOverviewItem( + zongpaiNo: json['zongpai_no'] as String, + workOrderNo: json['work_order_no']?.toString(), + quantity: json['quantity'] as int? ?? 0, + locationCode: json['location_code']?.toString(), + status: json['status']?.toString() ?? 'not_shelved', + ); + } +} + +class PaichaOverviewResult { + final bool success; + final bool notFound; + final String? errorMessage; + final String? paichaNo; + final int totalCount; + final List items; + + PaichaOverviewResult({ + required this.success, + this.notFound = false, + this.errorMessage, + this.paichaNo, + this.totalCount = 0, + this.items = const [], + }); + + factory PaichaOverviewResult.ok(Map json) { + final items = + (json['items'] as List?) + ?.map( + (item) => + PaichaOverviewItem.fromJson(item as Map), + ) + .toList() ?? + []; + return PaichaOverviewResult( + success: true, + paichaNo: json['paicha_no'] as String?, + totalCount: json['total_count'] as int? ?? items.length, + items: items, + ); + } + + factory PaichaOverviewResult.notFoundResult() => PaichaOverviewResult( + success: false, + notFound: true, + errorMessage: '暂无排产信息', + ); + + factory PaichaOverviewResult.error(String message) => + PaichaOverviewResult(success: false, errorMessage: message); +} + // === 装箱模块数据类 === /// 箱号内单个总排号明细 @@ -290,6 +360,34 @@ class ApiService { } } + Future fetchPaichaOverview({ + required String baseUrl, + required String zongpaiNo, + }) async { + final uri = Uri.parse( + '$baseUrl/CargoTrace/location/paicha-overview', + ).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 PaichaOverviewResult.ok(body); + case 400: + return PaichaOverviewResult.error('无效的总排号格式'); + case 404: + return PaichaOverviewResult.notFoundResult(); + default: + return PaichaOverviewResult.error('加载失败,点击重试'); + } + } catch (e) { + return PaichaOverviewResult.error('加载失败,点击重试'); + } + } + /// 查询装箱信息 — GET /CargoTrace/box/info Future fetchBoxInfo({ required String baseUrl, diff --git a/test/services/api_service_test.dart b/test/services/api_service_test.dart index f1ecc00..854f3b6 100644 --- a/test/services/api_service_test.dart +++ b/test/services/api_service_test.dart @@ -144,6 +144,86 @@ void main() { }); group('ApiService boxing APIs', () { + test('fetchPaichaOverview parses overview items', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({ + 'paicha_no': 'R00001', + 'total_count': 2, + 'items': [ + { + 'zongpai_no': '26B1', + 'work_order_no': '6-1(7)', + 'quantity': 80, + 'location_code': 'A01-02-03', + 'status': 'on_shelf', + }, + { + 'zongpai_no': '26B2', + 'work_order_no': '6-2(3)', + 'quantity': 45, + 'location_code': null, + 'status': 'not_shelved', + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.fetchPaichaOverview( + baseUrl: 'http://localhost', + zongpaiNo: '26B1', + ); + + expect(result.success, isTrue); + expect(result.paichaNo, 'R00001'); + expect(result.totalCount, 2); + expect(result.items.first.status, 'on_shelf'); + expect(result.items.last.locationCode, isNull); + }); + + test('fetchPaichaOverview maps 404 to not found result', () async { + final mockClient = _MockClient((request) async { + return http.Response( + jsonEncode({'error_code': 'PAICHA_NOT_FOUND', 'message': '暂无排产信息'}), + 404, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.fetchPaichaOverview( + baseUrl: 'http://localhost', + zongpaiNo: '26B404', + ); + + expect(result.success, isFalse); + expect(result.notFound, isTrue); + expect(result.errorMessage, '暂无排产信息'); + }); + + test( + 'fetchPaichaOverview maps network failure to retryable error', + () async { + final mockClient = _MockClient((request) async { + throw Exception('Connection refused'); + }); + + final svc = ApiService(client: mockClient); + final result = await svc.fetchPaichaOverview( + baseUrl: 'http://localhost', + zongpaiNo: '26B1', + ); + + expect(result.success, isFalse); + expect(result.notFound, isFalse); + expect(result.errorMessage, '加载失败,点击重试'); + }, + ); + test('fetchBoxInfo parses current boxes and box item ids', () async { final mockClient = _MockClient((request) async { return http.Response(