import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/scanner_service.dart'; import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/boxing_context.dart'; import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/pages/boxing/boxing_models.dart'; import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart'; import 'package:pad_scanner/pages/boxing/widgets/multi_code_body.dart'; import 'package:pad_scanner/pages/boxing/widgets/single_code_body.dart'; import 'package:pad_scanner/pages/boxing_detail_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; // === 装箱模式 === enum BoxingMode { singleCode, // 单码装箱 multiCode, // 多码凑箱 } class BoxingPageArguments { final BoxingMode initialMode; final List autoScanCodes; const BoxingPageArguments({ required this.initialMode, required this.autoScanCodes, }); } // === 页面阶段 === enum _Phase { waiting, // 等待扫码 scanned, // 已扫码,显示信息 submitted, // 已提交成功 } // === 主页面 === class BoxingPage extends StatefulWidget { final BoxingPageArguments? arguments; const BoxingPage({super.key, this.arguments}); @override State createState() => _BoxingPageState(); } class _BoxingPageState extends State with WidgetsBindingObserver { final _scannerService = ScannerService(); final _apiService = ApiService(); final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); StreamSubscription? _scanSubscription; // 模式 BoxingMode _mode = BoxingMode.singleCode; // 阶段 _Phase _phase = _Phase.waiting; // 当前总排号 String? _zongpaiNo; // 排产号信息(来自后端查询) String? _paichanNo; String? _workOrderNo; int? _erpQuantity; List _currentZongpaiBoxes = []; List _existingBoxes = []; int _maxBoxNo = 0; // 输入 final _boxNoController = TextEditingController(); final _quantityController = TextEditingController(); final _boxNoFocusNode = FocusNode(); final _quantityFocusNode = FocusNode(); // 多码凑箱:本次操作已确认装入当前箱的明细 final _manyToOnePackedItems = []; int? _editingPackedItemId; String _editingPackedQuantity = ''; int? _deletingPackedItemId; int? _editingAssignedItemId; String _editingAssignedQuantity = ''; int? _deletingAssignedItemId; String? _lastScannedPaichanNo; String? _paichanSwitchNotice; bool _crossPaichaPending = false; // 提交中 bool _isSubmitting = false; CurrentZongpaiBoxData? _editingBox; // 重复箱号 bool _isDuplicateBoxNo = false; // Status bar state StatusDotColor _statusDot = StatusDotColor.blue; String _statusText = '等待扫码'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; bool _isAutoProcessing = false; final _autoWarnings = []; // 多码凑箱:已完成装箱时可跳转的箱号 int? _completedJumpBoxNo; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); final arguments = widget.arguments; if (arguments != null) { _mode = arguments.initialMode; } _boxNoController.addListener(_onBoxNoTextChanged); _scanSubscription = _scannerService.scanResults.listen(_onScan); if (arguments != null && arguments.autoScanCodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { _processAutoScanCodes(arguments.autoScanCodes); }); } WidgetsBinding.instance.addPostFrameCallback((_) => _requestFocus()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _boxNoController.removeListener(_onBoxNoTextChanged); _scanSubscription?.cancel(); _focusNode.dispose(); _boxNoController.dispose(); _quantityController.dispose(); _boxNoFocusNode.dispose(); _quantityFocusNode.dispose(); _feedbackService.dispose(); super.dispose(); } void _requestFocus() { if (mounted) { _focusNode.requestFocus(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _requestFocus(); } } void _onKeyEvent(KeyEvent event) { if (event is! KeyDownEvent || _isAutoProcessing) return; if (_isModeSwitchKey(event)) { _cycleMode(); } if (_isFinishBoxKey(event)) { final hasItems = _visibleManyToOnePackedItems.isNotEmpty; if (hasItems && _mode == BoxingMode.multiCode) { _finishManyToOneBox(); } } if (_completedJumpBoxNo != null && _isJumpToBoxKey(event)) { _jumpToCompletedBox(); } } bool _isModeSwitchKey(KeyEvent event) { final key = event.logicalKey; return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonRight1; } bool _isFinishBoxKey(KeyEvent event) { return event.logicalKey == LogicalKeyboardKey.gameButtonLeft2; } bool _isJumpToBoxKey(KeyEvent event) { return event.logicalKey == LogicalKeyboardKey.gameButtonRight2; } void _onBoxNoTextChanged() { if (!mounted || _mode != BoxingMode.multiCode) return; setState(() { _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; }); } // === 模式切换 === String get _modeLabel { switch (_mode) { case BoxingMode.singleCode: return '单码装箱'; case BoxingMode.multiCode: return '多码凑箱'; } } void _cycleMode() { _feedbackService.trigger(FeedbackEvent.modeSwitch); setState(() { switch (_mode) { case BoxingMode.singleCode: _mode = BoxingMode.multiCode; case BoxingMode.multiCode: _mode = BoxingMode.singleCode; } _resetState(); }); } void _resetState() { _phase = _Phase.waiting; _zongpaiNo = null; _paichanNo = null; _workOrderNo = null; _erpQuantity = null; _currentZongpaiBoxes = []; _existingBoxes = []; _maxBoxNo = 0; _boxNoController.clear(); _quantityController.clear(); _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _editingAssignedItemId = null; _editingAssignedQuantity = ''; _deletingAssignedItemId = null; _lastScannedPaichanNo = null; _paichanSwitchNotice = null; _crossPaichaPending = false; _isSubmitting = false; _editingBox = null; _isDuplicateBoxNo = false; _statusOverrideText = null; _statusOverrideDot = null; } // === 扫码处理 === void _onScan(ScanResult result) { if (_isAutoProcessing) { return; } if (_crossPaichaPending) { _feedbackService.trigger(FeedbackEvent.alreadyCompleted); return; } final parsed = CodeParser.parse(result.barcode); if (parsed.type != CodeType.zongpaiNo) { _feedbackService.trigger(FeedbackEvent.scanInvalid); _showStatusOverride( '无效码,请重新扫描', StatusDotColor.red, const Duration(seconds: 2), ); return; } final zongpai = parsed.value; // 三种模式都允许后扫入的总排号覆盖当前扫码区;未确认数据不会入库。 _queryBoxInfo(zongpai); } Future _queryBoxInfo(String zongpai) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return false; } final result = await _apiService.fetchBoxInfo( baseUrl: baseUrl, zongpaiNo: zongpai, ); if (!mounted) return false; if (!result.success) { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.scanInvalid, ); _showStatusOverride( result.errorMessage ?? '查询失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); return false; } // 判断该总牌号是否已全部装箱完毕 final packedQuantity = result.currentZongpaiBoxes.fold( 0, (sum, item) => sum + item.quantity, ); final totalQuantity = result.quantity ?? 0; final alreadyCompleted = totalQuantity > 0 && packedQuantity >= totalQuantity; _feedbackService.trigger( alreadyCompleted ? FeedbackEvent.alreadyCompleted : FeedbackEvent.scanValid, ); setState(() { _zongpaiNo = zongpai; _paichanNo = result.paichanNo; _workOrderNo = result.workOrderNo; _erpQuantity = result.quantity; _currentZongpaiBoxes = result.currentZongpaiBoxes; _existingBoxes = result.existingBoxes; _maxBoxNo = result.maxBoxNo; _phase = _Phase.scanned; _isDuplicateBoxNo = false; _editingBox = null; _editingAssignedItemId = null; _editingAssignedQuantity = ''; _deletingAssignedItemId = null; _completedJumpBoxNo = null; _statusOverrideText = null; if (_mode == BoxingMode.singleCode) { _paichanSwitchNotice = null; } // 根据模式自动填充 _applyAutoFill(); }); return true; } Future _processAutoScanCodes(List 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(); _requestFocus(); } void _applyAutoFill() { switch (_mode) { case BoxingMode.singleCode: final remaining = _remainingQuantity; if (remaining <= 0) { _boxNoController.clear(); _quantityController.clear(); _statusOverrideText = '该总排号已全部装箱完毕'; _statusOverrideDot = StatusDotColor.red; return; } _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mode == BoxingMode.singleCode) { _quantityFocusNode.requestFocus(); } }); case BoxingMode.multiCode: final decision = decideMultiCodePaichanContext( lastPaichanNo: _lastScannedPaichanNo, currentPaichanNo: _paichanNo, currentBoxNoText: _boxNoController.text, maxBoxNo: _maxBoxNo, ); if (decision.isSwitched) { _crossPaichaPending = true; _feedbackService.trigger(FeedbackEvent.alreadyCompleted); _showCrossPaichaDialog(); return; } else { _paichanSwitchNotice = null; } final boxNoToApply = decision.boxNoToApply; if (boxNoToApply != null) { _boxNoController.text = boxNoToApply.toString(); } _lastScannedPaichanNo = _paichanNo; final remaining = _remainingQuantity; if (remaining <= 0) { _quantityController.clear(); if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) { _completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo; } _statusOverrideText = '该总排号已全部装箱完毕'; _statusOverrideDot = StatusDotColor.red; return; } _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mode == BoxingMode.multiCode) { _quantityFocusNode.requestFocus(); } }); } _isDuplicateBoxNo = _boxNoIsDuplicate(); } // === 跨排产号确认对话框 === void _showCrossPaichaDialog() { final currentPaicha = _lastScannedPaichanNo ?? '--'; final newPaicha = _paichanNo ?? '--'; final dialogFocus = FocusNode(); var dismissed = false; var selectedIndex = 0; // 0 = "否" (default), 1 = "是" void dismissAsNo() { if (dismissed) return; dismissed = true; Navigator.of(context).pop(); _cancelPaichanSwitch(); } void confirmSwitch() { if (dismissed) return; dismissed = true; Navigator.of(context).pop(); _confirmPaichanSwitch(); } showDialog( context: context, barrierDismissible: false, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) { return KeyboardListener( focusNode: dialogFocus, onKeyEvent: (event) { if (event is! KeyDownEvent) return; if (event.logicalKey == LogicalKeyboardKey.arrowUp) { setDialogState(() => selectedIndex = 0); } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { setDialogState(() => selectedIndex = 1); } else if (event.logicalKey == LogicalKeyboardKey.enter) { selectedIndex == 0 ? dismissAsNo() : confirmSwitch(); } else if (event.logicalKey == LogicalKeyboardKey.escape) { dismissAsNo(); } }, child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) dismissAsNo(); }, child: AlertDialog( backgroundColor: Colors.orange.shade50, title: Row( children: [ Icon(Icons.warning_amber, color: Colors.orange.shade800), const SizedBox(width: 8), Text( '跨排产号扫描', style: TextStyle(color: Colors.orange.shade900), ), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '当前排产号:$currentPaicha\n' '新排产号:$newPaicha\n\n' '是否切换到新的排产号?\n\n' '选择「是」将放弃当前已扫描的所有数据。', style: const TextStyle(fontSize: 14), ), const SizedBox(height: 16), _dialogOptionBtn( label: '否', selected: selectedIndex == 0, onTap: dismissAsNo, ), const SizedBox(height: 8), _dialogOptionBtn( label: '是,切换排产号', selected: selectedIndex == 1, onTap: confirmSwitch, ), ], ), ), ), ); }, ), ); WidgetsBinding.instance.addPostFrameCallback((_) { dialogFocus.requestFocus(); }); } void _confirmPaichanSwitch() { setState(() { _crossPaichaPending = false; _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}'; _boxNoController.text = (_maxBoxNo + 1).toString(); _lastScannedPaichanNo = _paichanNo; }); _feedbackService.trigger(FeedbackEvent.paichanSwitch); // Continue with remaining quantity flow final remaining = _remainingQuantity; if (remaining <= 0) { _quantityController.clear(); if (_manyToOnePackedItems.isEmpty && _currentZongpaiBoxes.isNotEmpty) { _completedJumpBoxNo = _currentZongpaiBoxes.first.boxNo; } setState(() { _statusOverrideText = '该总排号已全部装箱完毕'; _statusOverrideDot = StatusDotColor.red; }); return; } setState(() { _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); _isDuplicateBoxNo = _boxNoIsDuplicate(); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _mode == BoxingMode.multiCode) { _quantityFocusNode.requestFocus(); } }); } void _cancelPaichanSwitch() { setState(() { _crossPaichaPending = false; _zongpaiNo = null; _paichanNo = _lastScannedPaichanNo; _phase = _Phase.waiting; }); } Widget _dialogOptionBtn({ required String label, required bool selected, required VoidCallback onTap, }) { return SizedBox( width: double.infinity, height: 40, child: ElevatedButton( onPressed: onTap, style: ElevatedButton.styleFrom( backgroundColor: selected ? Colors.blue.shade700 : Colors.grey.shade200, foregroundColor: selected ? Colors.white : Colors.black87, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: Text( label, style: TextStyle( fontWeight: selected ? FontWeight.w800 : FontWeight.w600, ), ), ), ); } // === 重复箱号检测 === bool _boxNoIsDuplicate() { final boxNo = int.tryParse(_boxNoController.text); if (boxNo == null) { return false; } return switch (_mode) { BoxingMode.multiCode => false, BoxingMode.singleCode => _currentZongpaiBoxes.any((b) { if (b.boxNo != boxNo) return false; return _editingBox == null || b.boxItemId != _editingBox!.boxItemId; }), }; } void _checkDuplicateBoxNo() { setState(() => _isDuplicateBoxNo = _boxNoIsDuplicate()); } // === 提交 === int get _packedQuantity { return _currentZongpaiBoxes.fold( 0, (sum, item) => sum + item.quantity, ); } int get _remainingQuantity { final total = _erpQuantity ?? 0; return total - _packedQuantity; } bool get _quantityTooHigh { if (_remainingQuantity <= 0) return false; final qty = int.tryParse(_quantityController.text); return qty != null && qty > _remainingQuantity; } bool get _canSubmit { if (_isSubmitting || _phase != _Phase.scanned) return false; if (_zongpaiNo == null) return false; final boxNo = int.tryParse(_boxNoController.text); final qty = int.tryParse(_quantityController.text); if (boxNo == null || boxNo <= 0 || qty == null || qty <= 0) return false; if (_remainingQuantity <= 0) return false; if (_quantityTooHigh) return false; if (_isDuplicateBoxNo) return false; return true; } void _submitFromKeyboard() { if (_canSubmit) { _submit(); } } void _focusQuantityInput() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _quantityFocusNode.requestFocus(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); }); } int? get _currentManyToOneBoxNo { final boxNo = int.tryParse(_boxNoController.text.trim()); if (boxNo == null || boxNo <= 0) return null; return boxNo; } List get _visibleManyToOnePackedItems { final boxNo = _currentManyToOneBoxNo; if (boxNo == null) return const []; final byItemId = {}; for (final box in _existingBoxes.where((box) => box.boxNo == boxNo)) { for (final item in box.items) { final boxItemId = item.boxItemId; if (boxItemId == null) continue; byItemId[boxItemId] = ManyToOnePackedItem( boxItemId: boxItemId, zongpaiNo: item.zongpaiNo, paichanNo: _paichanNo, workOrderNo: item.workOrderNo, boxNo: boxNo, quantity: item.quantity, totalQuantity: item.totalQuantity, ); } } for (final item in _manyToOnePackedItems.where( (item) => item.boxNo == boxNo, )) { byItemId[item.boxItemId] = item; } return byItemId.values.toList(growable: false); } Future _submit() async { if (!_canSubmit) return false; final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return false; } final boxNo = int.parse(_boxNoController.text); final quantity = int.parse(_quantityController.text); setState(() => _isSubmitting = true); final editing = _editingBox; final result = editing == null ? await _apiService.saveBoxRecord( baseUrl: baseUrl, zongpaiNo: _zongpaiNo!, boxNo: boxNo, quantity: quantity, ) : await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: editing.boxItemId, boxNo: boxNo, quantity: quantity, ); if (!mounted) return false; setState(() => _isSubmitting = false); if (result.success) { if (editing == null) { _onSubmitSuccess(boxNo, quantity, result.boxItemId); } else { _onUpdateSuccess(editing, boxNo, quantity); } return true; } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateBoxNo); _showStatusOverride( '该总排号在箱号 ${result.boxNo ?? ""} 已存在,请重新输入', StatusDotColor.amber, const Duration(seconds: 2), ); return false; } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure, ); _showStatusOverride( result.errorMessage ?? '提交失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); return false; } } void _onSubmitSuccess(int boxNo, int quantity, int? boxItemId) { _feedbackService.trigger(FeedbackEvent.submitSuccess); setState(() { if (_mode == BoxingMode.singleCode && boxItemId != null) { _currentZongpaiBoxes = List.from(_currentZongpaiBoxes) ..add( CurrentZongpaiBoxData( boxItemId: boxItemId, boxNo: boxNo, quantity: quantity, ), ); } _addExistingBoxItem(boxNo, quantity, boxItemId); _maxBoxNo = _maxBoxNo > boxNo ? _maxBoxNo : boxNo; }); switch (_mode) { case BoxingMode.singleCode: final remaining = _remainingQuantity; if (remaining <= 0) { _showStatusOverride( '装箱成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { setState(() { _phase = _Phase.scanned; _boxNoController.text = (_maxBoxNo + 1).toString(); _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); _isDuplicateBoxNo = false; }); _focusQuantityInput(); _showStatusOverride( '请继续完成剩余数量装箱', StatusDotColor.green, const Duration(milliseconds: 1500), ); } case BoxingMode.multiCode: _lastScannedPaichanNo = _paichanNo; setState(() { if (boxItemId != null) { _manyToOnePackedItems.add( ManyToOnePackedItem( boxItemId: boxItemId, zongpaiNo: _zongpaiNo!, paichanNo: _paichanNo, workOrderNo: _workOrderNo, boxNo: boxNo, quantity: quantity, totalQuantity: _erpQuantity, ), ); } _phase = _Phase.waiting; _zongpaiNo = null; _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; }); _showStatusOverride( '装箱成功,请继续扫码', StatusDotColor.green, const Duration(milliseconds: 1500), ); } } void _onUpdateSuccess( CurrentZongpaiBoxData editing, int boxNo, int quantity, ) { _feedbackService.trigger(FeedbackEvent.submitSuccess); setState(() { _currentZongpaiBoxes = _currentZongpaiBoxes.map((item) { if (item.boxItemId != editing.boxItemId) return item; return CurrentZongpaiBoxData( boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, ); }).toList(); _replaceExistingBoxItem(editing, boxNo, quantity); _editingBox = null; _boxNoController.text = (_maxBoxNo + 1).toString(); final remaining = _remainingQuantity; if (remaining > 0) { _quantityController.text = remaining.toString(); } else { _quantityController.clear(); } _isDuplicateBoxNo = false; }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } void _replaceExistingBoxItem( CurrentZongpaiBoxData editing, int boxNo, int quantity, ) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items.where((item) { return item.boxItemId != editing.boxItemId; }).toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); final updatedItem = BoxItemData( boxItemId: editing.boxItemId, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, totalQuantity: _erpQuantity, ); if (targetIndex >= 0) { final target = nextBoxes[targetIndex]; nextBoxes[targetIndex] = BoxDetailData( boxNo: target.boxNo, items: [...target.items, updatedItem], ); } else { nextBoxes.add(BoxDetailData(boxNo: boxNo, items: [updatedItem])); } nextBoxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } void _addExistingBoxItem(int boxNo, int quantity, int? boxItemId) { final item = BoxItemData( boxItemId: boxItemId, zongpaiNo: _zongpaiNo!, workOrderNo: _workOrderNo, quantity: quantity, totalQuantity: _erpQuantity, ); final index = _existingBoxes.indexWhere((box) => box.boxNo == boxNo); if (index < 0) { _existingBoxes = [ ..._existingBoxes, BoxDetailData(boxNo: boxNo, items: [item]), ]..sort((a, b) => a.boxNo.compareTo(b.boxNo)); return; } final next = List.from(_existingBoxes); final box = next[index]; next[index] = BoxDetailData(boxNo: box.boxNo, items: [...box.items, item]); _existingBoxes = next; } void _finishManyToOneBox() { setState(() { _manyToOnePackedItems.clear(); _editingPackedItemId = null; _editingPackedQuantity = ''; _deletingPackedItemId = null; _zongpaiNo = null; _phase = _Phase.waiting; _quantityController.clear(); _boxNoController.text = (_maxBoxNo + 1).toString(); _statusOverrideText = null; _statusOverrideDot = null; }); } void _startEditAssigned(CurrentZongpaiBoxData item) { setState(() { _editingAssignedItemId = item.boxItemId; _editingAssignedQuantity = item.quantity.toString(); _deletingAssignedItemId = null; _statusOverrideText = null; _statusOverrideDot = null; }); } void _cancelEditAssigned() { setState(() { _editingAssignedItemId = null; _editingAssignedQuantity = ''; }); } int _maxAssignedQuantity(CurrentZongpaiBoxData item) { final total = _erpQuantity ?? 0; final packedWithoutItem = _packedQuantity - item.quantity; return total - packedWithoutItem; } void _refreshSingleCodeNewInput({bool focusQuantity = true}) { _boxNoController.text = (_maxBoxNo + 1).toString(); final remaining = _remainingQuantity; if (remaining > 0) { _quantityController.text = remaining.toString(); _quantityController.selection = TextSelection( baseOffset: 0, extentOffset: _quantityController.text.length, ); if (focusQuantity) { _focusQuantityInput(); } } else { _quantityController.clear(); } _isDuplicateBoxNo = false; } Future _saveAssignedItem(CurrentZongpaiBoxData item) async { final quantity = int.tryParse(_editingAssignedQuantity); if (quantity == null || quantity <= 0) { _showStatusOverride( '请输入有效数量', StatusDotColor.red, const Duration(seconds: 2), ); return; } if (quantity > _maxAssignedQuantity(item)) { _showStatusOverride( '超出可装数量上限', StatusDotColor.red, const Duration(seconds: 2), ); return; } final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, boxNo: item.boxNo, quantity: quantity, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { setState(() { _currentZongpaiBoxes = _currentZongpaiBoxes.map((box) { if (box.boxItemId != item.boxItemId) return box; return CurrentZongpaiBoxData( boxItemId: box.boxItemId, boxNo: box.boxNo, quantity: quantity, ); }).toList(); _replaceExistingBoxItem(item, item.boxNo, quantity); _editingAssignedItemId = null; _editingAssignedQuantity = ''; _refreshSingleCodeNewInput(); }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { final dot = result.isDuplicate ? StatusDotColor.amber : StatusDotColor.red; _showStatusOverride( result.errorMessage ?? '修改失败', dot, const Duration(seconds: 2), ); } } Future _deleteAssignedItem(CurrentZongpaiBoxData item) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.deleteBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, ); if (!mounted) return; setState(() { _isSubmitting = false; if (result.success) { _currentZongpaiBoxes = _currentZongpaiBoxes .where((box) => box.boxItemId != item.boxItemId) .toList(); _removeExistingBoxItem(item.boxItemId); if (_editingAssignedItemId == item.boxItemId) { _editingAssignedItemId = null; _editingAssignedQuantity = ''; } _deletingAssignedItemId = null; _refreshSingleCodeNewInput(); } }); if (result.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { _showStatusOverride( result.errorMessage ?? '删除失败', StatusDotColor.red, const Duration(seconds: 2), ); } } void _startEditPackedItem(ManyToOnePackedItem item) { setState(() { _editingPackedItemId = item.boxItemId; _editingPackedQuantity = item.quantity.toString(); _deletingPackedItemId = null; }); } void _cancelEditPackedItem() { setState(() { _editingPackedItemId = null; _editingPackedQuantity = ''; }); } Future _savePackedItem(ManyToOnePackedItem item) async { final quantity = int.tryParse(_editingPackedQuantity); final boxNo = int.tryParse(_boxNoController.text.trim()); if (quantity == null || quantity <= 0 || boxNo == null || boxNo <= 0) { _showStatusOverride( '请输入有效箱号和数量', StatusDotColor.red, const Duration(seconds: 2), ); return; } final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.updateBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, boxNo: boxNo, quantity: quantity, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { setState(() { final index = _manyToOnePackedItems.indexWhere( (packed) => packed.boxItemId == item.boxItemId, ); if (index >= 0) { _manyToOnePackedItems[index] = _manyToOnePackedItems[index].copyWith( boxNo: boxNo, quantity: quantity, ); } _replaceExistingPackedItem(item, boxNo, quantity); _editingPackedItemId = null; _editingPackedQuantity = ''; }); _showStatusOverride( '修改成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { final dot = result.isDuplicate ? StatusDotColor.amber : StatusDotColor.red; _showStatusOverride( result.errorMessage ?? '修改失败', dot, const Duration(seconds: 2), ); } } void _replaceExistingPackedItem( ManyToOnePackedItem item, int boxNo, int quantity, ) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items.where((boxItem) { return boxItem.boxItemId != item.boxItemId; }).toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } final updatedItem = BoxItemData( boxItemId: item.boxItemId, zongpaiNo: item.zongpaiNo, workOrderNo: item.workOrderNo, quantity: quantity, totalQuantity: item.totalQuantity, ); final targetIndex = nextBoxes.indexWhere((box) => box.boxNo == boxNo); if (targetIndex >= 0) { final target = nextBoxes[targetIndex]; nextBoxes[targetIndex] = BoxDetailData( boxNo: target.boxNo, items: [...target.items, updatedItem], ); } else { nextBoxes.add(BoxDetailData(boxNo: boxNo, items: [updatedItem])); } nextBoxes.sort((a, b) => a.boxNo.compareTo(b.boxNo)); _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } Future _deletePackedItem(ManyToOnePackedItem item) async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); final result = await _apiService.deleteBoxRecord( baseUrl: baseUrl, boxItemId: item.boxItemId, ); if (!mounted) return; setState(() { _isSubmitting = false; if (result.success) { _manyToOnePackedItems.removeWhere( (packed) => packed.boxItemId == item.boxItemId, ); _removeExistingBoxItem(item.boxItemId); if (_editingPackedItemId == item.boxItemId) { _editingPackedItemId = null; _editingPackedQuantity = ''; } _deletingPackedItemId = null; } }); if (result.success) { _showStatusOverride( '删除成功', StatusDotColor.green, const Duration(milliseconds: 1500), ); } else { _showStatusOverride( result.errorMessage ?? '删除失败', StatusDotColor.red, const Duration(seconds: 2), ); } } void _removeExistingBoxItem(int boxItemId) { final nextBoxes = []; for (final box in _existingBoxes) { final items = box.items .where((item) => item.boxItemId != boxItemId) .toList(); if (items.isNotEmpty) { nextBoxes.add(BoxDetailData(boxNo: box.boxNo, items: items)); } } _existingBoxes = nextBoxes; _maxBoxNo = nextBoxes.fold( 0, (max, box) => box.boxNo > max ? box.boxNo : max, ); } // === Status bar management === void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { _statusOverrideText = text; _statusOverrideDot = dot; }); Future.delayed(duration, () { if (mounted) { setState(() { _statusOverrideText = null; _statusOverrideDot = null; }); } }); } void _jumpToCompletedBox() { final boxNo = _completedJumpBoxNo; if (boxNo == null) return; setState(() { _boxNoController.text = boxNo.toString(); _completedJumpBoxNo = null; _statusOverrideText = null; _statusOverrideDot = null; }); } // === 导航到详情页 === void _openDetail() { Navigator.push( context, MaterialPageRoute( builder: (_) => BoxingDetailPage( paichanNo: _paichanNo ?? '', existingBoxes: _existingBoxes, ), ), ); } // === 状态文字 === void _updateBaseStatus() { if (_isAutoProcessing) { _statusDot = StatusDotColor.orange; _statusText = '正在同步转运数据...'; return; } if (_isSubmitting) { _statusDot = StatusDotColor.orange; _statusText = '正在提交…'; return; } if (_isDuplicateBoxNo && _phase == _Phase.scanned) { _statusDot = StatusDotColor.amber; _statusText = '箱号 ${_boxNoController.text} 已存在,请重新输入'; return; } if (_quantityTooHigh && _phase == _Phase.scanned) { _statusDot = StatusDotColor.red; _statusText = '超出可装数量上限'; return; } if (_editingAssignedItemId != null) { _statusDot = StatusDotColor.amber; _statusText = '正在编辑已分配记录'; return; } if (_deletingAssignedItemId != null) { _statusDot = StatusDotColor.red; _statusText = '请确认是否删除该箱记录'; return; } if (_paichanSwitchNotice != null) { _statusDot = StatusDotColor.blue; _statusText = '排产号已切换,箱号已重置'; return; } switch (_phase) { case _Phase.waiting: _statusDot = StatusDotColor.blue; if (_mode == BoxingMode.multiCode && _visibleManyToOnePackedItems.isNotEmpty) { _statusText = '请继续扫码或完成本箱'; } else { _statusText = '等待扫码'; } case _Phase.scanned: _statusDot = StatusDotColor.blue; if (_remainingQuantity <= 0) { _statusDot = StatusDotColor.red; _statusText = '该总排号已全部装箱完毕'; return; } _statusText = _mode == BoxingMode.multiCode ? '数量已填入,请确认或修改' : '数量已填入,请确认或修改'; case _Phase.submitted: _statusDot = StatusDotColor.green; switch (_mode) { case BoxingMode.singleCode: _statusText = '装箱成功,可继续扫码'; case BoxingMode.multiCode: _statusText = '请扫描下一个总排号'; } } } // === Build === @override Widget build(BuildContext context) { _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; return KeyboardListener( focusNode: _focusNode, onKeyEvent: _onKeyEvent, child: Scaffold( appBar: AppBar( title: Row( children: [ const Text('装箱编号'), const SizedBox(width: 8), BoxingModePill( isSingle: _mode == BoxingMode.singleCode, label: _modeLabel, enabled: !_isAutoProcessing, onTap: _cycleMode, ), ], ), toolbarHeight: 44, titleSpacing: 12, ), body: Column( children: [ ..._buildNoticeBanners(), Expanded( child: _mode == BoxingMode.multiCode ? MultiCodeBody( hasScan: _zongpaiNo != null && _phase == _Phase.scanned, zongpaiNo: _zongpaiNo, paichanNo: _paichanNo, workOrderNo: _workOrderNo, erpQuantity: _erpQuantity, maxBoxNo: _maxBoxNo, existingBoxes: _existingBoxes, visibleItems: _visibleManyToOnePackedItems, boxNoController: _boxNoController, quantityController: _quantityController, boxNoFocusNode: _boxNoFocusNode, quantityFocusNode: _quantityFocusNode, isSubmitting: _isSubmitting, quantityTooHigh: _quantityTooHigh, canSubmit: _canSubmit, editingPackedItemId: _editingPackedItemId, editingPackedQuantity: _editingPackedQuantity, deletingPackedItemId: _deletingPackedItemId, onOpenDetail: _openDetail, onSubmitFromKeyboard: _submitFromKeyboard, onSubmit: _submit, onQuantityChanged: () => setState(() {}), onFinishBox: _finishManyToOneBox, onEditingPackedQuantityChanged: (value) => _editingPackedQuantity = value, onSavePackedItem: _savePackedItem, onCancelEditPackedItem: _cancelEditPackedItem, onStartEditPackedItem: _startEditPackedItem, onStartDeletePackedItem: (item) { setState(() { _deletingPackedItemId = item.boxItemId; _editingPackedItemId = null; _editingPackedQuantity = ''; }); }, onDeletePackedItem: _deletePackedItem, onCancelDeletePackedItem: () => setState(() => _deletingPackedItemId = null), ) : SingleCodeBody( isWaiting: _phase == _Phase.waiting && _zongpaiNo == null, isFinished: _phase == _Phase.scanned && _zongpaiNo != null && _remainingQuantity <= 0, zongpaiNo: _zongpaiNo, paichanNo: _paichanNo, workOrderNo: _workOrderNo, erpQuantity: _erpQuantity, packedQuantity: _packedQuantity, maxBoxNo: _maxBoxNo, assignedItems: _currentZongpaiBoxes, existingBoxes: _existingBoxes, boxNoController: _boxNoController, quantityController: _quantityController, boxNoFocusNode: _boxNoFocusNode, quantityFocusNode: _quantityFocusNode, isSubmitting: _isSubmitting, isDuplicateBoxNo: _isDuplicateBoxNo, quantityTooHigh: _quantityTooHigh, canSubmit: _canSubmit, editingAssignedItemId: _editingAssignedItemId, editingAssignedQuantity: _editingAssignedQuantity, deletingAssignedItemId: _deletingAssignedItemId, onOpenDetail: _openDetail, onCheckDuplicateBoxNo: _checkDuplicateBoxNo, onSubmitFromKeyboard: _submitFromKeyboard, onSubmit: _submit, onQuantityChanged: () => setState(() {}), onEditingAssignedQuantityChanged: (value) => _editingAssignedQuantity = value, onSaveAssignedItem: _saveAssignedItem, onCancelEditAssigned: _cancelEditAssigned, onStartEditAssigned: _startEditAssigned, onStartDeleteAssigned: (item) { setState(() { _deletingAssignedItemId = item.boxItemId; _editingAssignedItemId = null; _editingAssignedQuantity = ''; }); }, onDeleteAssignedItem: _deleteAssignedItem, onCancelDeleteAssigned: () => setState(() => _deletingAssignedItemId = null), ), ), // 底部状态栏 StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ), ); } List _buildNoticeBanners() { final banners = []; if (_isAutoProcessing) { banners.add( _noticeBanner( icon: SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 1.5, color: Colors.orange.shade700, ), ), text: '正在同步转运数据...', background: Colors.orange.shade50, foreground: Colors.orange.shade900, border: Colors.orange.shade100, ), ); } if (_autoWarnings.isNotEmpty) { banners.add( _noticeBanner( icon: Icon( Icons.warning_amber, size: 14, color: Colors.amber.shade900, ), text: _autoWarnings.join(';'), background: Colors.amber.shade100, foreground: Colors.amber.shade900, border: Colors.amber.shade200, ), ); } if (_paichanSwitchNotice != null) { banners.add( _noticeBanner( icon: Icon(Icons.info_outline, size: 14, color: Colors.blue.shade900), text: _paichanSwitchNotice!, background: Colors.blue.shade50, foreground: Colors.blue.shade900, border: Colors.blue.shade100, ), ); } if (_isDuplicateBoxNo && _phase == _Phase.scanned) { banners.add( _noticeBanner( icon: Icon( Icons.warning_amber, size: 14, color: Colors.amber.shade900, ), text: '箱号 ${_boxNoController.text} 已存在,请重新输入', background: Colors.amber.shade100, foreground: Colors.amber.shade900, border: Colors.amber.shade200, ), ); } if (_quantityTooHigh && _phase == _Phase.scanned) { banners.add( _noticeBanner( icon: Icon(Icons.close, size: 14, color: Colors.red.shade800), text: '超出可装数量上限(最多可装 $_remainingQuantity 件)', background: Colors.red.shade50, foreground: Colors.red.shade800, border: Colors.red.shade100, ), ); } if (_phase == _Phase.scanned && _zongpaiNo != null && _remainingQuantity <= 0) { final jumpBoxNo = _completedJumpBoxNo; if (jumpBoxNo != null) { banners.add( GestureDetector( onTap: _jumpToCompletedBox, behavior: HitTestBehavior.opaque, child: _noticeBanner( icon: Icon(Icons.warning, size: 14, color: Colors.amber.shade800), text: '${_zongpaiNo!} 已完成装箱。(P4跳转所在箱号)', background: Colors.amber.shade50, foreground: Colors.amber.shade800, border: Colors.amber.shade200, ), ), ); } else { banners.add( _noticeBanner( icon: Icon(Icons.warning, size: 14, color: Colors.red.shade800), text: '${_zongpaiNo!} 已全部装箱完毕,无需操作', background: Colors.red.shade50, foreground: Colors.red.shade800, border: Colors.red.shade100, ), ); } } return banners; } Widget _noticeBanner({ required Widget icon, required String text, required Color background, required Color foreground, required Color border, }) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), decoration: BoxDecoration( color: background, border: Border(bottom: BorderSide(color: border)), ), child: Row( children: [ icon, const SizedBox(width: 6), Expanded( child: Text( text, style: TextStyle(fontSize: 12, color: foreground), overflow: TextOverflow.ellipsis, ), ), ], ), ); } }