feat(registration): merge shelf overview into registration

This commit is contained in:
Misaka_Company
2026-05-15 08:45:22 +08:00
parent ce80e1a1f6
commit b2f2c536cb
3 changed files with 706 additions and 313 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/app_config_service.dart';
@@ -20,22 +22,29 @@ class _RegistrationPageState extends State<RegistrationPage> {
final _feedbackService = FeedbackService(); final _feedbackService = FeedbackService();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
StreamSubscription<ScanResult>? _scanSubscription;
final _zongpaiNos = <String>[]; final _zongpaiNos = <String>[];
String? _locationCode; String? _locationCode;
CodeType? _locationType; CodeType? _locationType;
bool _isLocked = false; bool _isLocked = false;
bool _isSubmitting = false; bool _isSubmitting = false;
// Status bar state
StatusDotColor _statusDot = StatusDotColor.blue; StatusDotColor _statusDot = StatusDotColor.blue;
String _statusText = '等待扫描总排号或货位号…'; String _statusText = '等待扫描总排号或货位号…';
String? _statusOverrideText; String? _statusOverrideText;
StatusDotColor? _statusOverrideDot; StatusDotColor? _statusOverrideDot;
bool _overviewLoading = false;
bool _overviewNotFound = false;
String? _overviewError;
String? _overviewZongpaiNo;
PaichaOverviewResult? _overview;
int _overviewRequestId = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scannerService.scanResults.listen(_onScan); _scanSubscription = _scannerService.scanResults.listen(_onScan);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
}); });
@@ -43,6 +52,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
@override @override
void dispose() { void dispose() {
_scanSubscription?.cancel();
_focusNode.dispose(); _focusNode.dispose();
_feedbackService.dispose(); _feedbackService.dispose();
super.dispose(); super.dispose();
@@ -58,24 +68,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
final parsed = CodeParser.parse(result.barcode); final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) { switch (parsed.type) {
case CodeType.zongpaiNo: case CodeType.zongpaiNo:
_feedbackService.trigger(FeedbackEvent.scanValid); _handleZongpaiScan(parsed.value);
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();
});
case CodeType.locationNormal: case CodeType.locationNormal:
case CodeType.locationTransit: case CodeType.locationTransit:
if (!_isLocked) { if (!_isLocked) {
@@ -96,13 +89,33 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
} }
void _removeZongpai(int index) { void _handleZongpaiScan(String zongpaiNo) {
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() { 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() { void _clearStatusOverride() {
_statusOverrideText = null; _statusOverrideText = null;
@@ -130,15 +143,15 @@ class _RegistrationPageState extends State<RegistrationPage> {
_statusText = '货位已锁定,请扫描下一张执行卡'; _statusText = '货位已锁定,请扫描下一张执行卡';
return; return;
} }
final hasZ = _zongpaiNos.isNotEmpty; final hasZongpai = _zongpaiNos.isNotEmpty;
final hasL = _locationCode != null; final hasLocation = _locationCode != null;
if (hasZ && hasL) { if (hasZongpai && hasLocation) {
_statusDot = StatusDotColor.blue; _statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交'; _statusText = '请确认信息并提交';
} else if (hasZ && !hasL) { } else if (hasZongpai) {
_statusDot = StatusDotColor.blue; _statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号'; _statusText = '请扫描目标货位号';
} else if (!hasZ && hasL) { } else if (hasLocation) {
_statusDot = StatusDotColor.blue; _statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡'; _statusText = '请扫描执行卡';
} else { } else {
@@ -150,12 +163,70 @@ class _RegistrationPageState extends State<RegistrationPage> {
bool get _canSubmit => bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
Future<void> _submit() async { Future<String?> _baseUrl() async {
if (!_canSubmit) return;
final configService = AppConfigService(); final configService = AppConfigService();
final baseUrl = await configService.getString('api_url') ?? ''; final baseUrl = await configService.getString('api_url') ?? '';
if (baseUrl.isEmpty) { if (baseUrl.isEmpty) {
return null;
}
return baseUrl;
}
Future<void> _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<void> _refreshCurrentOverview() async {
final zongpaiNo = _overviewZongpaiNo;
if (zongpaiNo == null) return;
await _loadOverview(zongpaiNo, force: true);
}
Future<void> _submit() async {
if (!_canSubmit) return;
final baseUrl = await _baseUrl();
if (baseUrl == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure); _feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride( _showStatusOverride(
'未配置 API 地址,请前往设置', '未配置 API 地址,请前往设置',
@@ -202,6 +273,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
StatusDotColor.green, StatusDotColor.green,
const Duration(milliseconds: 1500), const Duration(milliseconds: 1500),
); );
await _refreshCurrentOverview();
} else if (result.isDuplicate) { } else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateError); _feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo); _showDuplicateDialog(zongpaiNo, result.duplicateInfo);
@@ -231,10 +303,10 @@ class _RegistrationPageState extends State<RegistrationPage> {
final failed = <String>[]; final failed = <String>[];
final toRemove = <String>[]; final toRemove = <String>[];
for (final zp in List.of(_zongpaiNos)) { for (final zongpaiNo in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation( final result = await _apiService.registerLocation(
baseUrl: baseUrl, baseUrl: baseUrl,
zongpaiNo: zp, zongpaiNo: zongpaiNo,
locationCode: _locationCode!, locationCode: _locationCode!,
); );
if (result.success) { if (result.success) {
@@ -242,21 +314,21 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (result.isOffShelfSuccess) { if (result.isOffShelfSuccess) {
offShelfCount++; offShelfCount++;
} }
toRemove.add(zp); toRemove.add(zongpaiNo);
} else { } else {
failed.add(zp); failed.add(zongpaiNo);
if (result.isDuplicate) { if (result.isDuplicate) {
setState(() { setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e)); _zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false; _isSubmitting = false;
}); });
_feedbackService.trigger(FeedbackEvent.duplicateError); _feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zp, result.duplicateInfo); _showDuplicateDialog(zongpaiNo, result.duplicateInfo);
return; return;
} }
if (result.isAlreadyOffShelf) { if (result.isAlreadyOffShelf) {
setState(() { setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e)); _zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false; _isSubmitting = false;
}); });
_feedbackService.trigger(FeedbackEvent.submitFailure); _feedbackService.trigger(FeedbackEvent.submitFailure);
@@ -273,7 +345,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e)); _zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false; _isSubmitting = false;
}); });
@@ -287,6 +359,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
StatusDotColor.green, StatusDotColor.green,
const Duration(milliseconds: 1500), const Duration(milliseconds: 1500),
); );
await _refreshCurrentOverview();
} else { } else {
_feedbackService.trigger(FeedbackEvent.submitFailure); _feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride( _showStatusOverride(
@@ -351,11 +424,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
setState(() { setState(() {
_isLocked = value; _isLocked = value;
if (!value) { if (!value && _zongpaiNos.length > 1) {
if (_zongpaiNos.length > 1) {
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1); _zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
} }
}
}); });
} }
@@ -369,11 +440,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
return Colors.blue; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Compute effective status
_updateBaseStatus(); _updateBaseStatus();
final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveDot = _statusOverrideDot ?? _statusDot;
final effectiveText = _statusOverrideText ?? _statusText; final effectiveText = _statusOverrideText ?? _statusText;
@@ -398,245 +478,23 @@ class _RegistrationPageState extends State<RegistrationPage> {
), ),
body: Column( body: Column(
children: [ children: [
// Main form area (no top banner) Padding(
Expanded( padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
// ---- 目标货位 (上方) ----
Row(
children: [ children: [
const Text( const Text(
'目标货位', '登记操作区',
style: TextStyle( style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
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), const SizedBox(height: 6),
Container( _isLocked
width: double.infinity, ? _buildLockedInputPanel()
padding: const EdgeInsets.symmetric( : _buildSingleInputPanel(),
horizontal: 12, const SizedBox(height: 10),
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,
),
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( SizedBox(
width: double.infinity, width: double.infinity,
height: 48, height: 46,
child: ElevatedButton( child: ElevatedButton(
onPressed: _canSubmit ? _submit : null, onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -662,21 +520,378 @@ class _RegistrationPageState extends State<RegistrationPage> {
: Text( : Text(
_isLocked && _zongpaiNos.length > 1 _isLocked && _zongpaiNos.length > 1
? '批量上架(${_zongpaiNos.length} 条)' ? '批量上架(${_zongpaiNos.length} 条)'
: '认上架(P1)', : ' 认 上 架',
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 17),
), ),
), ),
), ),
], ],
), ),
), ),
), Divider(height: 1, color: Colors.grey.shade300),
Expanded(child: _buildOverviewSection()),
// Bottom status bar
StatusBar(dotColor: effectiveDot, text: effectiveText), 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);

View File

@@ -46,6 +46,76 @@ class RegistrationResult {
RegistrationResult(success: false, errorMessage: message); 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<String, dynamic> 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<PaichaOverviewItem> items;
PaichaOverviewResult({
required this.success,
this.notFound = false,
this.errorMessage,
this.paichaNo,
this.totalCount = 0,
this.items = const [],
});
factory PaichaOverviewResult.ok(Map<String, dynamic> json) {
final items =
(json['items'] as List<dynamic>?)
?.map(
(item) =>
PaichaOverviewItem.fromJson(item as Map<String, dynamic>),
)
.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<PaichaOverviewResult> 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<String, dynamic>;
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 /// 查询装箱信息 — GET /CargoTrace/box/info
Future<BoxInfoResult> fetchBoxInfo({ Future<BoxInfoResult> fetchBoxInfo({
required String baseUrl, required String baseUrl,

View File

@@ -144,6 +144,86 @@ void main() {
}); });
group('ApiService boxing APIs', () { 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 { test('fetchBoxInfo parses current boxes and box item ids', () async {
final mockClient = _MockClient((request) async { final mockClient = _MockClient((request) async {
return http.Response( return http.Response(