Compare commits
9 Commits
e4a8816b98
...
b2f2c536cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f2c536cb | ||
|
|
ce80e1a1f6 | ||
|
|
a165bfeabe | ||
|
|
28871eb21f | ||
|
|
077a1ab1b9 | ||
|
|
42a9cc7204 | ||
|
|
396a19ff43 | ||
|
|
b32c9c5aad | ||
|
|
b975b15ba7 |
@@ -22,9 +22,10 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
paichanNo,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -60,14 +61,37 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBoxGroup(BuildContext context, BoxDetailData box) {
|
||||
final boxTotalQuantity = box.items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.quantity,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'箱号 ${box.boxNo}',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'共 $boxTotalQuantity 件',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
@@ -102,7 +126,7 @@ class BoxingDetailPage extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'数量:${item.quantity}',
|
||||
'${item.quantity}/${item.totalQuantity ?? '--'}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<RegistrationPage> {
|
||||
final _feedbackService = FeedbackService();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
StreamSubscription<ScanResult>? _scanSubscription;
|
||||
final _zongpaiNos = <String>[];
|
||||
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,14 +52,14 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_focusNode.dispose();
|
||||
_feedbackService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onKeyEvent(KeyEvent event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
@@ -59,24 +68,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
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) {
|
||||
@@ -97,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(() {
|
||||
_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;
|
||||
@@ -131,15 +143,15 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
_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 {
|
||||
@@ -151,12 +163,70 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
|
||||
Future<String?> _baseUrl() async {
|
||||
final configService = AppConfigService();
|
||||
final baseUrl = await configService.getString('api_url') ?? '';
|
||||
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);
|
||||
_showStatusOverride(
|
||||
'未配置 API 地址,请前往设置',
|
||||
@@ -195,11 +265,25 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
final msg = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功';
|
||||
_showStatusOverride(msg, StatusDotColor.green, const Duration(milliseconds: 1500));
|
||||
final msg = result.isOffShelfSuccess
|
||||
? '下架成功'
|
||||
: (_isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功');
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
@@ -215,27 +299,44 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
|
||||
Future<void> _submitBatch(String baseUrl) async {
|
||||
int successCount = 0;
|
||||
int offShelfCount = 0;
|
||||
final failed = <String>[];
|
||||
final toRemove = <String>[];
|
||||
|
||||
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) {
|
||||
successCount++;
|
||||
toRemove.add(zp);
|
||||
if (result.isOffShelfSuccess) {
|
||||
offShelfCount++;
|
||||
}
|
||||
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((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -244,17 +345,21 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
|
||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (failed.isEmpty) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
final msg = offShelfCount == successCount
|
||||
? '批量下架成功($successCount 条)'
|
||||
: '批量上架成功($successCount 条)';
|
||||
_showStatusOverride(
|
||||
'批量上架成功($successCount 条)',
|
||||
msg,
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
} else {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
@@ -319,11 +424,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
}
|
||||
setState(() {
|
||||
_isLocked = value;
|
||||
if (!value) {
|
||||
if (_zongpaiNos.length > 1) {
|
||||
if (!value && _zongpaiNos.length > 1) {
|
||||
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -337,11 +440,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
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;
|
||||
@@ -366,241 +478,23 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Main form area (no top banner)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||
child: Column(
|
||||
children: [
|
||||
// ---- 目标货位 (上方) ----
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
'登记操作区',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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
|
||||
_isLocked
|
||||
? _buildLockedInputPanel()
|
||||
: _buildSingleInputPanel(),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
height: 46,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -626,21 +520,378 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
: Text(
|
||||
_isLocked && _zongpaiNos.length > 1
|
||||
? '批量上架(${_zongpaiNos.length} 条)'
|
||||
: '确认上架(P1)',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
: '确 认 上 架',
|
||||
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);
|
||||
|
||||
@@ -57,14 +57,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _saveUrl() async {
|
||||
final url = _controller.text.trim();
|
||||
final url = _controller.text.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
if (url.isEmpty) {
|
||||
_showSnackBar('请输入 API 地址', isError: true);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
// Load config once, apply all changes, then save once to avoid race conditions
|
||||
final configService = AppConfigService();
|
||||
await configService.setString('api_url', url);
|
||||
final config = await configService.loadConfig();
|
||||
config['api_url'] = url;
|
||||
if (_successPath != null) config['sound_success'] = _successPath;
|
||||
else config.remove('sound_success');
|
||||
if (_failurePath != null) config['sound_failure'] = _failurePath;
|
||||
else config.remove('sound_failure');
|
||||
if (_beepPath != null) config['sound_beep'] = _beepPath;
|
||||
else config.remove('sound_beep');
|
||||
if (_errorPath != null) config['sound_error'] = _errorPath;
|
||||
else config.remove('sound_error');
|
||||
if (_alertPath != null) config['sound_alert'] = _alertPath;
|
||||
else config.remove('sound_alert');
|
||||
await configService.saveConfig(config);
|
||||
setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
_showSnackBar('设置已保存');
|
||||
@@ -91,18 +104,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final path = result.files.single.path!;
|
||||
switch (key) {
|
||||
case 'success':
|
||||
await _soundService.setSuccessPath(path);
|
||||
case 'failure':
|
||||
await _soundService.setFailurePath(path);
|
||||
case 'beep':
|
||||
await _soundService.setBeepPath(path);
|
||||
case 'error':
|
||||
await _soundService.setErrorPath(path);
|
||||
case 'alert':
|
||||
await _soundService.setAlertPath(path);
|
||||
}
|
||||
setState(() {
|
||||
switch (key) {
|
||||
case 'success':
|
||||
@@ -121,18 +122,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _clearSound(String key) async {
|
||||
switch (key) {
|
||||
case 'success':
|
||||
await _soundService.setSuccessPath(null);
|
||||
case 'failure':
|
||||
await _soundService.setFailurePath(null);
|
||||
case 'beep':
|
||||
await _soundService.setBeepPath(null);
|
||||
case 'error':
|
||||
await _soundService.setErrorPath(null);
|
||||
case 'alert':
|
||||
await _soundService.setAlertPath(null);
|
||||
}
|
||||
setState(() {
|
||||
switch (key) {
|
||||
case 'success':
|
||||
|
||||
@@ -5,48 +5,142 @@ import 'package:http/http.dart' as http;
|
||||
class RegistrationResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final bool isAlreadyOffShelf;
|
||||
final bool isOffShelfSuccess;
|
||||
final String? errorMessage;
|
||||
final Map<String, dynamic>? duplicateInfo;
|
||||
final Map<String, dynamic>? conflictInfo;
|
||||
|
||||
RegistrationResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.isAlreadyOffShelf = false,
|
||||
this.isOffShelfSuccess = false,
|
||||
this.errorMessage,
|
||||
this.duplicateInfo,
|
||||
this.conflictInfo,
|
||||
});
|
||||
|
||||
factory RegistrationResult.ok() => RegistrationResult(success: true);
|
||||
|
||||
factory RegistrationResult.offShelfSuccess() =>
|
||||
RegistrationResult(success: true, isOffShelfSuccess: true);
|
||||
|
||||
factory RegistrationResult.duplicate(Map<String, dynamic> info) =>
|
||||
RegistrationResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
duplicateInfo: info,
|
||||
conflictInfo: info,
|
||||
);
|
||||
|
||||
factory RegistrationResult.alreadyOffShelf(Map<String, dynamic> info) =>
|
||||
RegistrationResult(
|
||||
success: false,
|
||||
isAlreadyOffShelf: true,
|
||||
errorMessage: info['message']?.toString() ?? '该总排号已下架至转运区域,不可重新上架',
|
||||
conflictInfo: info,
|
||||
);
|
||||
|
||||
factory RegistrationResult.error(String 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);
|
||||
}
|
||||
|
||||
// === 装箱模块数据类 ===
|
||||
|
||||
/// 箱号内单个总排号明细
|
||||
class BoxItemData {
|
||||
final int? boxItemId;
|
||||
final String zongpaiNo;
|
||||
final String? workOrderNo;
|
||||
final int quantity;
|
||||
final int? totalQuantity;
|
||||
|
||||
BoxItemData({
|
||||
this.boxItemId,
|
||||
required this.zongpaiNo,
|
||||
this.workOrderNo,
|
||||
required this.quantity,
|
||||
this.totalQuantity,
|
||||
});
|
||||
|
||||
factory BoxItemData.fromJson(Map<String, dynamic> json) {
|
||||
return BoxItemData(
|
||||
boxItemId: json['box_item_id'] as int?,
|
||||
zongpaiNo: json['zongpai_no'] as String,
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int,
|
||||
totalQuantity: json['total_quantity'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +162,27 @@ class BoxDetailData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前总排号已分配的装箱明细
|
||||
class CurrentZongpaiBoxData {
|
||||
final int boxItemId;
|
||||
final int boxNo;
|
||||
final int quantity;
|
||||
|
||||
CurrentZongpaiBoxData({
|
||||
required this.boxItemId,
|
||||
required this.boxNo,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
|
||||
return CurrentZongpaiBoxData(
|
||||
boxItemId: json['box_item_id'] as int,
|
||||
boxNo: json['box_no'] as int,
|
||||
quantity: json['quantity'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 装箱信息查询结果
|
||||
class BoxInfoResult {
|
||||
final bool success;
|
||||
@@ -76,6 +191,7 @@ class BoxInfoResult {
|
||||
final String? paichanNo;
|
||||
final String? workOrderNo;
|
||||
final int? quantity;
|
||||
final List<CurrentZongpaiBoxData> currentZongpaiBoxes;
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final int suggestedBoxNo;
|
||||
@@ -87,6 +203,7 @@ class BoxInfoResult {
|
||||
this.paichanNo,
|
||||
this.workOrderNo,
|
||||
this.quantity,
|
||||
this.currentZongpaiBoxes = const [],
|
||||
this.existingBoxes = const [],
|
||||
this.maxBoxNo = 0,
|
||||
this.suggestedBoxNo = 1,
|
||||
@@ -98,12 +215,20 @@ class BoxInfoResult {
|
||||
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
final currentBoxes =
|
||||
(json['current_zongpai_boxes'] as List<dynamic>?)
|
||||
?.map(
|
||||
(b) => CurrentZongpaiBoxData.fromJson(b as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return BoxInfoResult(
|
||||
success: true,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
workOrderNo: json['work_order_no']?.toString(),
|
||||
quantity: json['quantity'] as int?,
|
||||
currentZongpaiBoxes: currentBoxes,
|
||||
existingBoxes: boxes,
|
||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
||||
@@ -120,22 +245,33 @@ class BoxSaveResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
final int? boxItemId;
|
||||
final String? paichanNo;
|
||||
final String? zongpaiNo;
|
||||
final int? boxNo;
|
||||
final int? quantity;
|
||||
|
||||
BoxSaveResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
this.boxItemId,
|
||||
this.paichanNo,
|
||||
this.zongpaiNo,
|
||||
this.boxNo,
|
||||
this.quantity,
|
||||
});
|
||||
|
||||
factory BoxSaveResult.ok(Map<String, dynamic> json) {
|
||||
return BoxSaveResult(
|
||||
success: true,
|
||||
boxItemId: json['box_item_id'] as int?,
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
boxNo: json['box_no'] as int?,
|
||||
quantity: json['quantity'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +279,10 @@ class BoxSaveResult {
|
||||
return BoxSaveResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
errorCode: json['error_code']?.toString(),
|
||||
errorMessage: json['message']?.toString(),
|
||||
paichanNo: json['paichan_no'] as String?,
|
||||
zongpaiNo: json['zongpai_no'] as String?,
|
||||
boxNo: json['box_no'] as int?,
|
||||
);
|
||||
}
|
||||
@@ -153,6 +292,13 @@ class BoxSaveResult {
|
||||
}
|
||||
}
|
||||
|
||||
class BoxDeleteResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
|
||||
BoxDeleteResult({required this.success, this.errorMessage});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
final http.Client _client;
|
||||
final Duration timeout;
|
||||
@@ -181,6 +327,10 @@ class ApiService {
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (body['previous_location'] != null) {
|
||||
return RegistrationResult.offShelfSuccess();
|
||||
}
|
||||
return RegistrationResult.ok();
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
@@ -188,7 +338,15 @@ class ApiService {
|
||||
return RegistrationResult.error(msg);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final errorCode = body['error_code']?.toString();
|
||||
if (errorCode == 'DUPLICATE_LOCATION') {
|
||||
return RegistrationResult.duplicate(body);
|
||||
}
|
||||
if (errorCode == 'ALREADY_OFF_SHELF') {
|
||||
return RegistrationResult.alreadyOffShelf(body);
|
||||
}
|
||||
final msg = body['message']?.toString() ?? '提交失败';
|
||||
return RegistrationResult.error(msg);
|
||||
default:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final msg =
|
||||
@@ -202,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
|
||||
Future<BoxInfoResult> fetchBoxInfo({
|
||||
required String baseUrl,
|
||||
@@ -237,7 +423,6 @@ class ApiService {
|
||||
required String zongpaiNo,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
required String boxMode,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box');
|
||||
try {
|
||||
@@ -249,7 +434,6 @@ class ApiService {
|
||||
'zongpai_no': zongpaiNo,
|
||||
'box_no': boxNo,
|
||||
'quantity': quantity,
|
||||
'box_mode': boxMode,
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
@@ -275,6 +459,71 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新装箱明细 — PATCH /CargoTrace/box/{boxItemId}
|
||||
Future<BoxSaveResult> updateBoxRecord({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.patch(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return BoxSaveResult.ok(body);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final msg = body['message']?.toString() ?? '请求参数错误';
|
||||
return BoxSaveResult.error(msg);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return BoxSaveResult.duplicate(body);
|
||||
case 404:
|
||||
return BoxSaveResult.error('指定装箱明细不存在');
|
||||
default:
|
||||
return BoxSaveResult.error('修改失败 (${response.statusCode})');
|
||||
}
|
||||
} catch (e) {
|
||||
return BoxSaveResult.error('网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除装箱明细 — DELETE /CargoTrace/box/{boxItemId}
|
||||
Future<BoxDeleteResult> deleteBoxRecord({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return BoxDeleteResult(success: true);
|
||||
case 404:
|
||||
return BoxDeleteResult(success: false, errorMessage: '指定装箱明细不存在');
|
||||
default:
|
||||
return BoxDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '删除失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return BoxDeleteResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connectivity by making a HEAD request to the base URL.
|
||||
Future<bool> testConnection(String baseUrl) async {
|
||||
try {
|
||||
|
||||
@@ -38,11 +38,11 @@ class AppConfigService {
|
||||
Future<void> setString(String key, String value) async {
|
||||
final config = await loadConfig();
|
||||
config[key.toString()] = value.toString();
|
||||
await _saveConfig(config);
|
||||
await saveConfig(config);
|
||||
}
|
||||
|
||||
/// 完整写入 JSON 文件
|
||||
Future<void> _saveConfig(Map<String, dynamic> config) async {
|
||||
Future<void> saveConfig(Map<String, dynamic> config) async {
|
||||
try {
|
||||
final file = File(await _filePath);
|
||||
await file.writeAsString(jsonEncode(config));
|
||||
|
||||
38
lib/services/boxing_context.dart
Normal file
38
lib/services/boxing_context.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
enum PaichanContextChange { firstScan, samePaichan, switchedPaichan }
|
||||
|
||||
class PaichanContextDecision {
|
||||
final PaichanContextChange change;
|
||||
final int? boxNoToApply;
|
||||
|
||||
const PaichanContextDecision({required this.change, this.boxNoToApply});
|
||||
|
||||
bool get isSwitched => change == PaichanContextChange.switchedPaichan;
|
||||
}
|
||||
|
||||
PaichanContextDecision decideMultiCodePaichanContext({
|
||||
required String? lastPaichanNo,
|
||||
required String? currentPaichanNo,
|
||||
required String currentBoxNoText,
|
||||
required int maxBoxNo,
|
||||
}) {
|
||||
final recommendedBoxNo = maxBoxNo + 1;
|
||||
if (currentPaichanNo == null || currentPaichanNo.isEmpty) {
|
||||
return const PaichanContextDecision(change: PaichanContextChange.firstScan);
|
||||
}
|
||||
if (lastPaichanNo == null || lastPaichanNo.isEmpty) {
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.firstScan,
|
||||
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
|
||||
);
|
||||
}
|
||||
if (lastPaichanNo == currentPaichanNo) {
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.samePaichan,
|
||||
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
|
||||
);
|
||||
}
|
||||
return PaichanContextDecision(
|
||||
change: PaichanContextChange.switchedPaichan,
|
||||
boxNoToApply: recommendedBoxNo,
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ enum FeedbackEvent {
|
||||
networkError, // Network error → failure sound + short vibrate
|
||||
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
|
||||
modeSwitch, // Mode toggle → beep only
|
||||
paichanSwitch, // Paichan changed → beep + short vibrate
|
||||
}
|
||||
|
||||
/// Centralized feedback dispatcher.
|
||||
@@ -56,6 +57,10 @@ class FeedbackService {
|
||||
|
||||
case FeedbackEvent.modeSwitch:
|
||||
_soundService.playBeep();
|
||||
|
||||
case FeedbackEvent.paichanSwitch:
|
||||
_soundService.playBeep();
|
||||
_vibrateShort();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ void main() {
|
||||
test('returns ok on 200', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'zongpai_no': '26B1', 'location_code': 'A01-02-03', 'created_at': '2026-05-11T10:00:00'}),
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'location_code': 'A01-02-03',
|
||||
'created_at': '2026-05-11T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
@@ -21,9 +25,36 @@ void main() {
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.isDuplicate, isFalse);
|
||||
expect(result.isOffShelfSuccess, isFalse);
|
||||
});
|
||||
|
||||
test('returns duplicate on 409 with location_code and registered_at', () async {
|
||||
test('returns off-shelf success on 200 with previous_location', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26B1',
|
||||
'location_code': 'TRANS-01',
|
||||
'previous_location': 'A01-02-03',
|
||||
'created_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.registerLocation(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
locationCode: 'TRANS-01',
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.isOffShelfSuccess, isTrue);
|
||||
expect(result.isDuplicate, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns duplicate on 409 with location_code and registered_at',
|
||||
() async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@@ -46,12 +77,42 @@ void main() {
|
||||
expect(result.isDuplicate, isTrue);
|
||||
expect(result.duplicateInfo?['location_code'], 'A01-02-03');
|
||||
expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00');
|
||||
},
|
||||
);
|
||||
|
||||
test('returns already off shelf on 409 ALREADY_OFF_SHELF', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'ALREADY_OFF_SHELF',
|
||||
'message': '该总排号已下架至转运区域,不可重新上架',
|
||||
'location_code': 'TRANS-01',
|
||||
'registered_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.registerLocation(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26B1',
|
||||
locationCode: 'A02-01-01',
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.isDuplicate, isFalse);
|
||||
expect(result.isAlreadyOffShelf, isTrue);
|
||||
expect(result.errorMessage, '该总排号已下架至转运区域,不可重新上架');
|
||||
expect(result.conflictInfo?['location_code'], 'TRANS-01');
|
||||
});
|
||||
|
||||
test('returns error on 400 with message', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'error_code': 'INVALID_ZONGPAI', 'message': 'INVALID_ZONGPAI'}),
|
||||
jsonEncode({
|
||||
'error_code': 'INVALID_ZONGPAI',
|
||||
'message': 'INVALID_ZONGPAI',
|
||||
}),
|
||||
400,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
@@ -81,6 +142,236 @@ void main() {
|
||||
expect(result.errorMessage, '网络异常,请检查网络连接');
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26BW0011',
|
||||
'paichan_no': 'W00009',
|
||||
'work_order_no': '6-1(7)',
|
||||
'quantity': 80,
|
||||
'current_zongpai_boxes': [
|
||||
{'box_item_id': 101, 'box_no': 3, 'quantity': 30},
|
||||
],
|
||||
'existing_boxes': [
|
||||
{
|
||||
'box_no': 3,
|
||||
'items': [
|
||||
{
|
||||
'box_item_id': 101,
|
||||
'zongpai_no': '26BW0011',
|
||||
'work_order_no': '6-1(7)',
|
||||
'quantity': 30,
|
||||
'total_quantity': 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'max_box_no': 3,
|
||||
'suggested_box_no': 4,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchBoxInfo(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.currentZongpaiBoxes.single.boxItemId, 101);
|
||||
expect(result.existingBoxes.single.items.single.boxItemId, 101);
|
||||
expect(result.existingBoxes.single.items.single.totalQuantity, 80);
|
||||
expect(result.suggestedBoxNo, 4);
|
||||
});
|
||||
|
||||
test('saveBoxRecord sends simplified request body', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 102,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 4,
|
||||
'zongpai_no': '26BW0012',
|
||||
'quantity': 34,
|
||||
'created_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.saveBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0012',
|
||||
boxNo: 4,
|
||||
quantity: 34,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.boxItemId, 102);
|
||||
expect(result.zongpaiNo, '26BW0012');
|
||||
expect(body, {'zongpai_no': '26BW0012', 'box_no': 4, 'quantity': 34});
|
||||
expect(body.containsKey('box_mode'), isFalse);
|
||||
});
|
||||
|
||||
test('updateBoxRecord sends simplified request body', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 102,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 4,
|
||||
'zongpai_no': '26BW0012',
|
||||
'quantity': 20,
|
||||
'updated_at': '2026-05-13T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.updateBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 102,
|
||||
boxNo: 4,
|
||||
quantity: 20,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(body, {'box_no': 4, 'quantity': 20});
|
||||
expect(body.containsKey('box_mode'), isFalse);
|
||||
});
|
||||
|
||||
test('deleteBoxRecord handles success and not found', () async {
|
||||
var calls = 0;
|
||||
final mockClient = _MockClient((request) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 102, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'BOX_ITEM_NOT_FOUND',
|
||||
'message': '指定装箱明细不存在',
|
||||
}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
|
||||
final svc = ApiService(client: mockClient);
|
||||
final ok = await svc.deleteBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 102,
|
||||
);
|
||||
final missing = await svc.deleteBoxRecord(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 999,
|
||||
);
|
||||
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
|
||||
60
test/services/boxing_context_test.dart
Normal file
60
test/services/boxing_context_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pad_scanner/services/boxing_context.dart';
|
||||
|
||||
void main() {
|
||||
group('decideMultiCodePaichanContext', () {
|
||||
test('first scan records context and fills recommended box if empty', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: null,
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '',
|
||||
maxBoxNo: 3,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.firstScan);
|
||||
expect(decision.boxNoToApply, 4);
|
||||
expect(decision.isSwitched, isFalse);
|
||||
});
|
||||
|
||||
test('same paichan keeps current box number', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '8',
|
||||
maxBoxNo: 3,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.samePaichan);
|
||||
expect(decision.boxNoToApply, isNull);
|
||||
expect(decision.isSwitched, isFalse);
|
||||
});
|
||||
|
||||
test('switched paichan resets to new paichan max box plus one', () {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00010',
|
||||
currentBoxNoText: '8',
|
||||
maxBoxNo: 1,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.switchedPaichan);
|
||||
expect(decision.boxNoToApply, 2);
|
||||
expect(decision.isSwitched, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'same paichan defensively fills recommended box when box is empty',
|
||||
() {
|
||||
final decision = decideMultiCodePaichanContext(
|
||||
lastPaichanNo: 'W00009',
|
||||
currentPaichanNo: 'W00009',
|
||||
currentBoxNoText: '',
|
||||
maxBoxNo: 4,
|
||||
);
|
||||
|
||||
expect(decision.change, PaichanContextChange.samePaichan);
|
||||
expect(decision.boxNoToApply, 5);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user