feat: link transit registration to boxing
This commit is contained in:
@@ -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';
|
||||||
@@ -16,6 +18,16 @@ enum BoxingMode {
|
|||||||
multiCode, // 多码凑箱
|
multiCode, // 多码凑箱
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BoxingPageArguments {
|
||||||
|
final BoxingMode initialMode;
|
||||||
|
final List<String> autoScanCodes;
|
||||||
|
|
||||||
|
const BoxingPageArguments({
|
||||||
|
required this.initialMode,
|
||||||
|
required this.autoScanCodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// === 页面阶段 ===
|
// === 页面阶段 ===
|
||||||
|
|
||||||
enum _Phase {
|
enum _Phase {
|
||||||
@@ -63,7 +75,9 @@ class _ManyToOnePackedItem {
|
|||||||
// === 主页面 ===
|
// === 主页面 ===
|
||||||
|
|
||||||
class BoxingPage extends StatefulWidget {
|
class BoxingPage extends StatefulWidget {
|
||||||
const BoxingPage({super.key});
|
final BoxingPageArguments? arguments;
|
||||||
|
|
||||||
|
const BoxingPage({super.key, this.arguments});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BoxingPage> createState() => _BoxingPageState();
|
State<BoxingPage> createState() => _BoxingPageState();
|
||||||
@@ -73,6 +87,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
final _scannerService = ScannerService();
|
final _scannerService = ScannerService();
|
||||||
final _apiService = ApiService();
|
final _apiService = ApiService();
|
||||||
final _feedbackService = FeedbackService();
|
final _feedbackService = FeedbackService();
|
||||||
|
StreamSubscription<ScanResult>? _scanSubscription;
|
||||||
|
|
||||||
// 模式
|
// 模式
|
||||||
BoxingMode _mode = BoxingMode.singleCode;
|
BoxingMode _mode = BoxingMode.singleCode;
|
||||||
@@ -123,17 +138,29 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
String _statusText = '等待扫码';
|
String _statusText = '等待扫码';
|
||||||
String? _statusOverrideText;
|
String? _statusOverrideText;
|
||||||
StatusDotColor? _statusOverrideDot;
|
StatusDotColor? _statusOverrideDot;
|
||||||
|
bool _isAutoProcessing = false;
|
||||||
|
final _autoWarnings = <String>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final arguments = widget.arguments;
|
||||||
|
if (arguments != null) {
|
||||||
|
_mode = arguments.initialMode;
|
||||||
|
}
|
||||||
_boxNoController.addListener(_onBoxNoTextChanged);
|
_boxNoController.addListener(_onBoxNoTextChanged);
|
||||||
_scannerService.scanResults.listen(_onScan);
|
_scanSubscription = _scannerService.scanResults.listen(_onScan);
|
||||||
|
if (arguments != null && arguments.autoScanCodes.isNotEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_processAutoScanCodes(arguments.autoScanCodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_boxNoController.removeListener(_onBoxNoTextChanged);
|
_boxNoController.removeListener(_onBoxNoTextChanged);
|
||||||
|
_scanSubscription?.cancel();
|
||||||
_boxNoController.dispose();
|
_boxNoController.dispose();
|
||||||
_quantityController.dispose();
|
_quantityController.dispose();
|
||||||
_boxNoFocusNode.dispose();
|
_boxNoFocusNode.dispose();
|
||||||
@@ -206,6 +233,10 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// === 扫码处理 ===
|
// === 扫码处理 ===
|
||||||
|
|
||||||
void _onScan(ScanResult result) {
|
void _onScan(ScanResult result) {
|
||||||
|
if (_isAutoProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final parsed = CodeParser.parse(result.barcode);
|
final parsed = CodeParser.parse(result.barcode);
|
||||||
|
|
||||||
if (parsed.type != CodeType.zongpaiNo) {
|
if (parsed.type != CodeType.zongpaiNo) {
|
||||||
@@ -224,7 +255,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
_queryBoxInfo(zongpai);
|
_queryBoxInfo(zongpai);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _queryBoxInfo(String zongpai) async {
|
Future<bool> _queryBoxInfo(String zongpai) async {
|
||||||
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) {
|
||||||
@@ -234,7 +265,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.red,
|
StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await _apiService.fetchBoxInfo(
|
final result = await _apiService.fetchBoxInfo(
|
||||||
@@ -242,7 +273,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
zongpaiNo: zongpai,
|
zongpaiNo: zongpai,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return false;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||||
@@ -254,7 +285,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_feedbackService.trigger(FeedbackEvent.scanValid);
|
_feedbackService.trigger(FeedbackEvent.scanValid);
|
||||||
@@ -281,6 +312,54 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// 根据模式自动填充
|
// 根据模式自动填充
|
||||||
_applyAutoFill();
|
_applyAutoFill();
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isAutoProcessing = true;
|
||||||
|
_autoWarnings.clear();
|
||||||
|
_statusOverrideText = '正在同步转运数据...';
|
||||||
|
_statusOverrideDot = StatusDotColor.orange;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_mode == BoxingMode.singleCode) {
|
||||||
|
final code = codes.first;
|
||||||
|
final ok = await _queryBoxInfo(code);
|
||||||
|
if (!ok && mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (final code in codes) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final queried = await _queryBoxInfo(code);
|
||||||
|
if (!queried) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 未找到排产号信息,已忽略'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!_canSubmit) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 暂不能装箱,已忽略'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final saved = await _submit();
|
||||||
|
if (!saved && mounted) {
|
||||||
|
setState(() => _autoWarnings.add('总排号 $code 自动装箱失败,请手动处理'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isAutoProcessing = false;
|
||||||
|
_statusOverrideText = null;
|
||||||
|
_statusOverrideDot = null;
|
||||||
|
});
|
||||||
|
_focusQuantityInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyAutoFill() {
|
void _applyAutoFill() {
|
||||||
@@ -452,8 +531,8 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
return byItemId.values.toList(growable: false);
|
return byItemId.values.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<bool> _submit() async {
|
||||||
if (!_canSubmit) return;
|
if (!_canSubmit) return false;
|
||||||
|
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
@@ -464,7 +543,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.red,
|
StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final boxNo = int.parse(_boxNoController.text);
|
final boxNo = int.parse(_boxNoController.text);
|
||||||
@@ -487,7 +566,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return false;
|
||||||
|
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
@@ -497,6 +576,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
} else {
|
} else {
|
||||||
_onUpdateSuccess(editing, boxNo, quantity);
|
_onUpdateSuccess(editing, boxNo, quantity);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} else if (result.isDuplicate) {
|
} else if (result.isDuplicate) {
|
||||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||||
_showStatusOverride(
|
_showStatusOverride(
|
||||||
@@ -504,6 +584,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
StatusDotColor.amber,
|
StatusDotColor.amber,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||||
_feedbackService.trigger(
|
_feedbackService.trigger(
|
||||||
@@ -514,6 +595,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||||
const Duration(seconds: 2),
|
const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,6 +1185,11 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
// === 状态文字 ===
|
// === 状态文字 ===
|
||||||
|
|
||||||
void _updateBaseStatus() {
|
void _updateBaseStatus() {
|
||||||
|
if (_isAutoProcessing) {
|
||||||
|
_statusDot = StatusDotColor.orange;
|
||||||
|
_statusText = '正在同步转运数据...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isSubmitting) {
|
if (_isSubmitting) {
|
||||||
_statusDot = StatusDotColor.orange;
|
_statusDot = StatusDotColor.orange;
|
||||||
_statusText = '正在提交…';
|
_statusText = '正在提交…';
|
||||||
@@ -1173,7 +1260,7 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 4),
|
padding: const EdgeInsets.only(right: 4),
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: _cycleMode,
|
onPressed: _isAutoProcessing ? null : _cycleMode,
|
||||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||||
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)),
|
label: Text(_modeLabel, style: const TextStyle(fontSize: 13)),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
@@ -1187,6 +1274,42 @@ class _BoxingPageState extends State<BoxingPage> {
|
|||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (_isAutoProcessing)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'正在同步转运数据...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.orange.shade900,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_autoWarnings.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: Colors.amber.shade100,
|
||||||
|
child: Text(
|
||||||
|
_autoWarnings.join(';'),
|
||||||
|
style: TextStyle(color: Colors.amber.shade900, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
|
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
|
||||||
if (_paichanSwitchNotice != null)
|
if (_paichanSwitchNotice != null)
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:pad_scanner/services/scanner_service.dart';
|
|||||||
import 'package:pad_scanner/services/code_parser.dart';
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
import 'package:pad_scanner/services/api_service.dart';
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
import 'package:pad_scanner/services/feedback_service.dart';
|
import 'package:pad_scanner/services/feedback_service.dart';
|
||||||
|
import 'package:pad_scanner/services/registration_linking.dart';
|
||||||
|
import 'package:pad_scanner/pages/boxing_page.dart';
|
||||||
import 'package:pad_scanner/widgets/status_bar.dart';
|
import 'package:pad_scanner/widgets/status_bar.dart';
|
||||||
|
|
||||||
class RegistrationPage extends StatefulWidget {
|
class RegistrationPage extends StatefulWidget {
|
||||||
@@ -44,7 +46,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scanSubscription = _scannerService.scanResults.listen(_onScan);
|
_startScanListening();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
});
|
});
|
||||||
@@ -58,6 +60,27 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startScanListening() {
|
||||||
|
_scanSubscription ??= _scannerService.scanResults.listen(_onScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopScanListening() async {
|
||||||
|
final subscription = _scanSubscription;
|
||||||
|
_scanSubscription = null;
|
||||||
|
await subscription?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restartScanListening() {
|
||||||
|
_scanSubscription?.cancel();
|
||||||
|
_scanSubscription = null;
|
||||||
|
_startScanListening();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onKeyEvent(KeyEvent event) {
|
void _onKeyEvent(KeyEvent event) {
|
||||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
|
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
|
||||||
_submit();
|
_submit();
|
||||||
@@ -78,7 +101,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
_locationType = parsed.type;
|
_locationType = parsed.type;
|
||||||
_clearStatusOverride();
|
_clearStatusOverride();
|
||||||
});
|
});
|
||||||
}
|
} else {}
|
||||||
case CodeType.invalid:
|
case CodeType.invalid:
|
||||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
_showStatusOverride(
|
_showStatusOverride(
|
||||||
@@ -163,6 +186,8 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
bool get _canSubmit =>
|
bool get _canSubmit =>
|
||||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||||
|
|
||||||
|
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||||
|
|
||||||
Future<String?> _baseUrl() async {
|
Future<String?> _baseUrl() async {
|
||||||
final configService = AppConfigService();
|
final configService = AppConfigService();
|
||||||
final baseUrl = await configService.getString('api_url') ?? '';
|
final baseUrl = await configService.getString('api_url') ?? '';
|
||||||
@@ -239,12 +264,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
if (_isLocked && _zongpaiNos.length > 1) {
|
if (_isLocked && _zongpaiNos.length > 1) {
|
||||||
|
if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||||
|
_showStatusOverride(
|
||||||
|
batchPaichaMismatchMessage,
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _submitBatch(baseUrl);
|
await _submitBatch(baseUrl);
|
||||||
} else {
|
} else {
|
||||||
await _submitOne(baseUrl, _zongpaiNos.first);
|
await _submitOne(baseUrl, _zongpaiNos.first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureBatchSamePaicha(String baseUrl) async {
|
||||||
|
final paichaByCode = <String, String>{};
|
||||||
|
final cachedOverview = _overview;
|
||||||
|
if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) {
|
||||||
|
for (final item in cachedOverview.items) {
|
||||||
|
paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final zongpaiNo in _zongpaiNos) {
|
||||||
|
if (paichaByCode.containsKey(zongpaiNo)) continue;
|
||||||
|
final result = await _apiService.fetchPaichaOverview(
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
zongpaiNo: zongpaiNo,
|
||||||
|
);
|
||||||
|
if (!result.success || result.paichaNo == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final item in result.items) {
|
||||||
|
paichaByCode[item.zongpaiNo] = result.paichaNo!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code]));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||||
final result = await _apiService.registerLocation(
|
final result = await _apiService.registerLocation(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -258,6 +320,16 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
|
if (_isTransitTarget) {
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
await _navigateToBoxing(
|
||||||
|
mode: BoxingMode.singleCode,
|
||||||
|
codes: [zongpaiNo],
|
||||||
|
codesToClear: [zongpaiNo],
|
||||||
|
clearLocation: !_isLocked,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNos.remove(zongpaiNo);
|
_zongpaiNos.remove(zongpaiNo);
|
||||||
if (!_isLocked) {
|
if (!_isLocked) {
|
||||||
@@ -302,6 +374,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
int offShelfCount = 0;
|
int offShelfCount = 0;
|
||||||
final failed = <String>[];
|
final failed = <String>[];
|
||||||
final toRemove = <String>[];
|
final toRemove = <String>[];
|
||||||
|
final transitCodes = <String>[];
|
||||||
|
|
||||||
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
for (final zongpaiNo in List.of(_zongpaiNos)) {
|
||||||
final result = await _apiService.registerLocation(
|
final result = await _apiService.registerLocation(
|
||||||
@@ -315,9 +388,26 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
offShelfCount++;
|
offShelfCount++;
|
||||||
}
|
}
|
||||||
toRemove.add(zongpaiNo);
|
toRemove.add(zongpaiNo);
|
||||||
|
if (_isTransitTarget) {
|
||||||
|
transitCodes.add(zongpaiNo);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
failed.add(zongpaiNo);
|
failed.add(zongpaiNo);
|
||||||
if (result.isDuplicate) {
|
if (result.isDuplicate) {
|
||||||
|
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
await _navigateToBoxing(
|
||||||
|
mode: BoxingMode.multiCode,
|
||||||
|
codes: transitCodes,
|
||||||
|
codesToClear: toRemove,
|
||||||
|
clearLocation: false,
|
||||||
|
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
@@ -327,6 +417,20 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.isAlreadyOffShelf) {
|
if (result.isAlreadyOffShelf) {
|
||||||
|
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
await _navigateToBoxing(
|
||||||
|
mode: BoxingMode.multiCode,
|
||||||
|
codes: transitCodes,
|
||||||
|
codesToClear: toRemove,
|
||||||
|
clearLocation: false,
|
||||||
|
returnMessage: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
@@ -349,6 +453,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_isTransitTarget && transitCodes.isNotEmpty) {
|
||||||
|
await _navigateToBoxing(
|
||||||
|
mode: BoxingMode.multiCode,
|
||||||
|
codes: transitCodes,
|
||||||
|
codesToClear: toRemove,
|
||||||
|
clearLocation: failed.isEmpty,
|
||||||
|
returnMessage: failed.isEmpty
|
||||||
|
? null
|
||||||
|
: '成功 $successCount 条,失败 ${failed.length} 条',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (failed.isEmpty) {
|
if (failed.isEmpty) {
|
||||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||||
final msg = offShelfCount == successCount
|
final msg = offShelfCount == successCount
|
||||||
@@ -370,6 +487,49 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _navigateToBoxing({
|
||||||
|
required BoxingMode mode,
|
||||||
|
required List<String> codes,
|
||||||
|
required List<String> codesToClear,
|
||||||
|
required bool clearLocation,
|
||||||
|
String? returnMessage,
|
||||||
|
}) async {
|
||||||
|
await _refreshCurrentOverview();
|
||||||
|
if (!mounted) return;
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
await _stopScanListening();
|
||||||
|
await navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => BoxingPage(
|
||||||
|
arguments: BoxingPageArguments(
|
||||||
|
initialMode: mode,
|
||||||
|
autoScanCodes: codes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||||
|
if (!mounted) return;
|
||||||
|
_restartScanListening();
|
||||||
|
setState(() {
|
||||||
|
_zongpaiNos.removeWhere((item) => codesToClear.contains(item));
|
||||||
|
if (clearLocation) {
|
||||||
|
_locationCode = null;
|
||||||
|
_locationType = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _refreshCurrentOverview();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (returnMessage != null) {
|
||||||
|
_showStatusOverride(
|
||||||
|
returnMessage,
|
||||||
|
StatusDotColor.red,
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -498,9 +658,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _canSubmit ? _submit : null,
|
onPressed: _canSubmit ? _submit : null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canSubmit
|
backgroundColor: registrationSubmitColor(
|
||||||
? colorScheme.primary
|
canSubmit: _canSubmit,
|
||||||
: Colors.grey.shade300,
|
isTransitTarget: _isTransitTarget,
|
||||||
|
primaryColor: colorScheme.primary,
|
||||||
|
),
|
||||||
foregroundColor: _canSubmit
|
foregroundColor: _canSubmit
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.grey.shade600,
|
: Colors.grey.shade600,
|
||||||
@@ -518,9 +680,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
_isLocked && _zongpaiNos.length > 1
|
registrationSubmitLabel(
|
||||||
? '批量上架(${_zongpaiNos.length} 条)'
|
isTransitTarget: _isTransitTarget,
|
||||||
: '确 认 上 架',
|
isLocked: _isLocked,
|
||||||
|
zongpaiCount: _zongpaiNos.length,
|
||||||
|
),
|
||||||
style: const TextStyle(fontSize: 17),
|
style: const TextStyle(fontSize: 17),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
39
lib/services/registration_linking.dart
Normal file
39
lib/services/registration_linking.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
|
|
||||||
|
const batchPaichaMismatchMessage = '批量凑箱仅支持同排产号产品,请移除不属于同一批次的总排号';
|
||||||
|
|
||||||
|
bool isTransitTarget(CodeType? locationType, String? locationCode) {
|
||||||
|
return locationType == CodeType.locationTransit ||
|
||||||
|
(locationCode?.startsWith('TRANS-') ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String registrationSubmitLabel({
|
||||||
|
required bool isTransitTarget,
|
||||||
|
required bool isLocked,
|
||||||
|
required int zongpaiCount,
|
||||||
|
}) {
|
||||||
|
if (!isTransitTarget) {
|
||||||
|
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||||
|
}
|
||||||
|
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color registrationSubmitColor({
|
||||||
|
required bool canSubmit,
|
||||||
|
required bool isTransitTarget,
|
||||||
|
required Color primaryColor,
|
||||||
|
}) {
|
||||||
|
if (!canSubmit) return Colors.grey.shade300;
|
||||||
|
return isTransitTarget ? Colors.orange : primaryColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool allSamePaicha(Iterable<String?> paichaNos) {
|
||||||
|
String? first;
|
||||||
|
for (final paichaNo in paichaNos) {
|
||||||
|
if (paichaNo == null || paichaNo.isEmpty) return false;
|
||||||
|
first ??= paichaNo;
|
||||||
|
if (paichaNo != first) return false;
|
||||||
|
}
|
||||||
|
return first != null;
|
||||||
|
}
|
||||||
@@ -9,19 +9,24 @@ class ScanResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ScannerService {
|
class ScannerService {
|
||||||
static const _eventChannel =
|
static const _eventChannel = EventChannel('com.example.pad_scanner/scan');
|
||||||
EventChannel('com.example.pad_scanner/scan');
|
static Stream<ScanResult>? _sharedScanStream;
|
||||||
|
|
||||||
Stream<ScanResult>? _scanStream;
|
|
||||||
|
|
||||||
Stream<ScanResult> get scanResults {
|
Stream<ScanResult> get scanResults {
|
||||||
_scanStream ??= _eventChannel
|
_sharedScanStream ??= _createSharedScanStream();
|
||||||
|
return _sharedScanStream!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<ScanResult> _createSharedScanStream() {
|
||||||
|
return _eventChannel
|
||||||
.receiveBroadcastStream()
|
.receiveBroadcastStream()
|
||||||
.map((event) => event as Map)
|
.map((event) => event as Map)
|
||||||
.map((event) => ScanResult(
|
.map(
|
||||||
|
(event) => ScanResult(
|
||||||
barcode: event['barcode'] as String? ?? '',
|
barcode: event['barcode'] as String? ?? '',
|
||||||
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
codeType: event['codeType'] as String? ?? 'UNKNOWN',
|
||||||
));
|
),
|
||||||
return _scanStream!;
|
)
|
||||||
|
.asBroadcastStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
test/services/registration_linking_test.dart
Normal file
85
test/services/registration_linking_test.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
|
import 'package:pad_scanner/services/registration_linking.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('registration linking helpers', () {
|
||||||
|
test('detects transit target by type or code prefix', () {
|
||||||
|
expect(isTransitTarget(CodeType.locationTransit, 'TRANS-01'), isTrue);
|
||||||
|
expect(isTransitTarget(CodeType.locationNormal, 'TRANS-02'), isTrue);
|
||||||
|
expect(isTransitTarget(CodeType.locationNormal, 'A01-02-03'), isFalse);
|
||||||
|
expect(isTransitTarget(null, null), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds submit labels for normal and transit modes', () {
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: false,
|
||||||
|
isLocked: false,
|
||||||
|
zongpaiCount: 1,
|
||||||
|
),
|
||||||
|
'确 认 上 架',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: false,
|
||||||
|
isLocked: true,
|
||||||
|
zongpaiCount: 3,
|
||||||
|
),
|
||||||
|
'批量上架(3 条)',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: true,
|
||||||
|
isLocked: false,
|
||||||
|
zongpaiCount: 1,
|
||||||
|
),
|
||||||
|
'转运并装箱',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitLabel(
|
||||||
|
isTransitTarget: true,
|
||||||
|
isLocked: true,
|
||||||
|
zongpaiCount: 3,
|
||||||
|
),
|
||||||
|
'批量转运并凑箱(3 条)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses orange submit color only for enabled transit target', () {
|
||||||
|
const primary = Colors.blue;
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: true,
|
||||||
|
isTransitTarget: true,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
Colors.orange,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: true,
|
||||||
|
isTransitTarget: false,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
primary,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
registrationSubmitColor(
|
||||||
|
canSubmit: false,
|
||||||
|
isTransitTarget: true,
|
||||||
|
primaryColor: primary,
|
||||||
|
),
|
||||||
|
Colors.grey.shade300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates all codes belong to one paicha', () {
|
||||||
|
expect(allSamePaicha(['R00001', 'R00001']), isTrue);
|
||||||
|
expect(allSamePaicha(['R00001', 'R00002']), isFalse);
|
||||||
|
expect(allSamePaicha(['R00001', null]), isFalse);
|
||||||
|
expect(allSamePaicha(<String>[]), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user