feat(boxing): handle multi-code paichan switches

This commit is contained in:
Misaka_Company
2026-05-14 14:04:25 +08:00
parent 28871eb21f
commit a165bfeabe
4 changed files with 148 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/services/scanner_service.dart'; 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/boxing_context.dart';
import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/services/feedback_service.dart';
import 'package:pad_scanner/pages/boxing_detail_page.dart'; import 'package:pad_scanner/pages/boxing_detail_page.dart';
import 'package:pad_scanner/widgets/status_bar.dart'; import 'package:pad_scanner/widgets/status_bar.dart';
@@ -101,6 +102,8 @@ class _BoxingPageState extends State<BoxingPage> {
int? _editingPackedItemId; int? _editingPackedItemId;
String _editingPackedQuantity = ''; String _editingPackedQuantity = '';
int? _deletingPackedItemId; int? _deletingPackedItemId;
String? _lastScannedPaichanNo;
String? _paichanSwitchNotice;
// 提交中 // 提交中
bool _isSubmitting = false; bool _isSubmitting = false;
@@ -184,6 +187,8 @@ class _BoxingPageState extends State<BoxingPage> {
_editingPackedItemId = null; _editingPackedItemId = null;
_editingPackedQuantity = ''; _editingPackedQuantity = '';
_deletingPackedItemId = null; _deletingPackedItemId = null;
_lastScannedPaichanNo = null;
_paichanSwitchNotice = null;
_isSubmitting = false; _isSubmitting = false;
_lastBoxNo = null; _lastBoxNo = null;
_editingBox = null; _editingBox = null;
@@ -260,6 +265,9 @@ class _BoxingPageState extends State<BoxingPage> {
_isDuplicateBoxNo = false; _isDuplicateBoxNo = false;
_editingBox = null; _editingBox = null;
_statusOverrideText = null; _statusOverrideText = null;
if (_mode == BoxingMode.singleCode) {
_paichanSwitchNotice = null;
}
// 根据模式自动填充 // 根据模式自动填充
_applyAutoFill(); _applyAutoFill();
@@ -293,9 +301,27 @@ class _BoxingPageState extends State<BoxingPage> {
} }
}); });
case BoxingMode.multiCode: case BoxingMode.multiCode:
if (_boxNoController.text.trim().isEmpty) { final decision = decideMultiCodePaichanContext(
_boxNoController.text = (_maxBoxNo + 1).toString(); lastPaichanNo: _lastScannedPaichanNo,
currentPaichanNo: _paichanNo,
currentBoxNoText: _boxNoController.text,
maxBoxNo: _maxBoxNo,
);
if (decision.isSwitched) {
_manyToOnePackedItems.clear();
_editingPackedItemId = null;
_editingPackedQuantity = '';
_deletingPackedItemId = null;
_paichanSwitchNotice = '排产号已切换:${_paichanNo ?? "--"}';
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
} else {
_paichanSwitchNotice = null;
} }
final boxNoToApply = decision.boxNoToApply;
if (boxNoToApply != null) {
_boxNoController.text = boxNoToApply.toString();
}
_lastScannedPaichanNo = _paichanNo;
_quantityController.text = (_erpQuantity ?? 0).toString(); _quantityController.text = (_erpQuantity ?? 0).toString();
_quantityController.selection = TextSelection( _quantityController.selection = TextSelection(
baseOffset: 0, baseOffset: 0,
@@ -508,6 +534,7 @@ class _BoxingPageState extends State<BoxingPage> {
} }
case BoxingMode.multiCode: case BoxingMode.multiCode:
_lastScannedPaichanNo = _paichanNo;
setState(() { setState(() {
if (boxItemId != null) { if (boxItemId != null) {
_manyToOnePackedItems.add( _manyToOnePackedItems.add(
@@ -643,6 +670,7 @@ class _BoxingPageState extends State<BoxingPage> {
void _finishManyToOneBox() { void _finishManyToOneBox() {
final currentBoxNo = int.tryParse(_boxNoController.text.trim()); final currentBoxNo = int.tryParse(_boxNoController.text.trim());
setState(() { setState(() {
_manyToOnePackedItems.clear();
_editingPackedItemId = null; _editingPackedItemId = null;
_editingPackedQuantity = ''; _editingPackedQuantity = '';
_deletingPackedItemId = null; _deletingPackedItemId = null;
@@ -913,6 +941,11 @@ class _BoxingPageState extends State<BoxingPage> {
_statusText = '超出可装数量上限'; _statusText = '超出可装数量上限';
return; return;
} }
if (_paichanSwitchNotice != null) {
_statusDot = StatusDotColor.blue;
_statusText = '排产号已切换,箱号已重置';
return;
}
switch (_phase) { switch (_phase) {
case _Phase.waiting: case _Phase.waiting:
_statusDot = StatusDotColor.blue; _statusDot = StatusDotColor.blue;
@@ -978,6 +1011,16 @@ class _BoxingPageState extends State<BoxingPage> {
body: Column( body: Column(
children: [ children: [
// 重复箱号警告条(保留,因为这是输入区关联的即时反馈) // 重复箱号警告条(保留,因为这是输入区关联的即时反馈)
if (_paichanSwitchNotice != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Colors.blue.shade50,
child: Text(
_paichanSwitchNotice!,
style: TextStyle(color: Colors.blue.shade900, fontSize: 13),
),
),
if (_isDuplicateBoxNo && _phase == _Phase.scanned) if (_isDuplicateBoxNo && _phase == _Phase.scanned)
Container( Container(
width: double.infinity, width: double.infinity,

View 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,
);
}

View File

@@ -11,6 +11,7 @@ enum FeedbackEvent {
networkError, // Network error → failure sound + short vibrate networkError, // Network error → failure sound + short vibrate
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
modeSwitch, // Mode toggle → beep only modeSwitch, // Mode toggle → beep only
paichanSwitch, // Paichan changed → beep + short vibrate
} }
/// Centralized feedback dispatcher. /// Centralized feedback dispatcher.
@@ -56,6 +57,10 @@ class FeedbackService {
case FeedbackEvent.modeSwitch: case FeedbackEvent.modeSwitch:
_soundService.playBeep(); _soundService.playBeep();
case FeedbackEvent.paichanSwitch:
_soundService.playBeep();
_vibrateShort();
} }
} }

View 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);
},
);
});
}