Compare commits

...

9 Commits

Author SHA1 Message Date
Misaka
99ed3bb9ec feat: add temp storage shelf (B prefix) UI support
Recognize B-prefix location codes as temp storage shelves. Add purple
color scheme, conflict detection for temp_stored status, and updated
submit/dialog logic for temp storage target handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 22:48:29 +08:00
Misaka_Company
f6bfd8cb10 style: add curly braces to if/else statements in settings_page
Fix curly_braces_in_flow_control_structures lint warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 09:58:42 +08:00
Misaka_Company
9db50af1e6 chore: document Flutter pre-commit checks 2026-05-22 09:56:03 +08:00
Misaka
4e8be6a684 feat: allow on_shelf items in transit batch transfer
Transit shelf registration is effectively an off-shelf operation. Allow
not_shelved, on_shelf, and their mix when target is a transit shelf.
Only transferred items are blocked since they are already in transit.

Also fix conflict color logic: transferred items now correctly show as
red (conflict) for both transit and normal shelf targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 21:39:44 +08:00
Misaka
5fc5c7de2b fix: skip shelf-switch warning when no previous shelf scanned in multi-code mode
In multi-code shelving mode, if the user has scanned zongpai numbers
but hasn't scanned any shelf yet, scanning the first shelf should not
trigger the "switch shelf" warning dialog since there is no existing
shelf to switch from.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 21:02:22 +08:00
Misaka
4891fe4dfd refactor: split registration page into focused parts 2026-05-21 20:30:01 +08:00
Misaka
305083bf3f test: update registration linking mode parameter 2026-05-21 19:50:40 +08:00
Misaka_Company
a601858225 refactor: unify registration mode concept to single-code/multi-code shelving
Replace the locked/unlocked terminology with RegistrationMode enum
(singleCode/multiCode) to align with boxing module's naming convention.
Multi-code mode now allows switching shelf locations with a confirmation
dialog when pending data exists. Use shared BoxingModePill widget for
consistent UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 17:30:23 +08:00
Misaka_Company
9ab7a51dfb feat: add pre-check to block batch operations when scanned items
already have location bindings (on_shelf or transferred)

- Replace single on_shelf pre-check with unified conflict detection
  for both normal shelf and transit targets
- Show conflict dialog listing duplicate and/or transferred items
  with details before any API calls are made
- Prevents partial batch failures where some items succeed before
  a conflicting item is encountered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 16:57:22 +08:00
15 changed files with 1838 additions and 1275 deletions

View File

@@ -6,6 +6,28 @@
- All new feature development must be done on a `dev` branch, created from `master`
- After verification, merge `dev` back into `master`
## Pre-Commit Checks
Before committing Flutter app changes, run these checks:
```bash
dart format --set-exit-if-changed .
flutter analyze
flutter test
```
If `flutter test` fails with a localhost WebSocket/proxy error, follow the proxy cleanup steps in the Flutter Test Rules section below and run it again.
### Handling Check Failures
- If a check fails because of the current change, fix the issue before committing.
- If `flutter analyze` reports any issue in files changed by the current task, fix it before committing.
- If `flutter analyze` reports only unrelated pre-existing issues, do not fix them in the current commit. Report the file, line, and lint/error name, then handle them in a separate cleanup commit or task.
- If a check fails because of unrelated pre-existing issues, do not include unrelated fixes in the same commit. Report the failing command and the existing issues, then handle them in a separate cleanup commit or task.
- If formatting fails, format only files changed by the current task. Do not run a broad formatting cleanup unless that is the explicit task.
- Treat `flutter test` failures as blocking unless the failure is clearly caused by the proxy issue described below and passes after rerunning with proxy variables cleared.
- When committing or reporting completion, mention which checks were run and whether any remaining failures are unrelated pre-existing issues.
## App Installation Rules
**Always use `adb install -r` to install the app. Never use `flutter install`.**

View File

@@ -0,0 +1,357 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationDialogsPart on _RegistrationPageState {
void _showLocationSwitchDialog(
String newLocationCode,
CodeType newLocationType,
) {
final count = _zongpaiNos.length;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => 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: Text(
'当前已有 $count 条待上架数据,切换货架号不会清除已扫描的总排号。',
style: const TextStyle(fontSize: 14),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = newLocationCode;
_locationType = newLocationType;
_clearStatusOverride();
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('确认切换'),
),
],
),
);
}
void _showDuplicateDialog(String zongpaiNo, Map<String, dynamic>? info) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('重复上架', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('总排号:$zongpaiNo'),
const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4),
Text('登记时间:${info?["registered_at"] ?? "未知"}'),
const SizedBox(height: 12),
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
void _showLocationConflictDialog({
required List<PaichaOverviewItem> onShelfItems,
required List<PaichaOverviewItem> transferredItems,
List<PaichaOverviewItem> tempStoredItems = const [],
}) {
final contentParts = <Widget>[];
if (onShelfItems.isNotEmpty) {
contentParts.add(
Text(
'重复上架',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red.shade700,
),
),
);
for (final item in onShelfItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('已登记货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (transferredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'货物已转运',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
);
for (final item in transferredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('转运货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
if (tempStoredItems.isNotEmpty) {
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
contentParts.add(const Divider());
contentParts.add(const SizedBox(height: 4));
}
contentParts.add(
Text(
'暂存中',
style: TextStyle(
fontWeight: FontWeight.bold,
color: const Color(0xFF7B1FA2),
),
),
);
for (final item in tempStoredItems) {
contentParts.addAll([
Text('总排号:${item.zongpaiNo}'),
Text('暂存货位:${item.locationCode ?? "未知"}'),
const SizedBox(height: 6),
]);
}
}
contentParts.add(const SizedBox(height: 4));
contentParts.add(
const Text(
'请核查实物,确认是否操作错误。',
style: TextStyle(fontWeight: FontWeight.bold),
),
);
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: Colors.red.shade50,
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('操作冲突', style: TextStyle(color: Colors.red)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentParts,
),
actions: [
TextButton(
onPressed: () {
_feedbackService.stopAlert();
Navigator.pop(ctx);
},
child: const Text('关闭'),
),
],
),
);
}
Future<void> _showCrossPaichaDialog(String newZongpaiNo) async {
final currentPaicha = _overview?.paichaNo ?? '--';
final shouldSwitch = await _confirmCrossPaichaSwitch(
newZongpaiNo: newZongpaiNo,
currentPaicha: currentPaicha,
);
if (!mounted) return;
if (shouldSwitch) {
_switchToNewPaicha(newZongpaiNo);
return;
}
setState(() {
_crossPaichaPending = false;
});
}
Future<bool> _confirmCrossPaichaSwitch({
required String newZongpaiNo,
required String currentPaicha,
}) async {
final dialogFocus = FocusNode();
var dismissed = false;
var selectedIndex = 0; // 0 = "否" (default), 1 = "是"
void dismissAsNo() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(false);
}
void confirmSwitch() {
if (dismissed) return;
dismissed = true;
Navigator.of(context).pop(true);
}
final dialogFuture = showDialog<bool>(
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(
'总排号 $newZongpaiNo 不属于当前排产号($currentPaicha'
'是否切换到新的排产号?\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();
});
final shouldSwitch = await dialogFuture;
return shouldSwitch ?? false;
}
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,
),
),
),
);
}
void _switchToNewPaicha(String newZongpaiNo) {
_feedbackService.trigger(FeedbackEvent.paichanSwitch);
setState(() {
_crossPaichaPending = false;
_zongpaiNos.clear();
_zongpaiNos.add(newZongpaiNo);
_overview = null;
_overviewNotFound = false;
_overviewError = null;
_clearStatusOverride();
});
_loadOverview(newZongpaiNo);
}
}

View File

@@ -0,0 +1,21 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationModePart on _RegistrationPageState {
void _cycleMode() {
_feedbackService.trigger(FeedbackEvent.modeSwitch);
setState(() {
switch (_mode) {
case RegistrationMode.singleCode:
_mode = RegistrationMode.multiCode;
case RegistrationMode.multiCode:
_mode = RegistrationMode.singleCode;
// 切换到单码时,只保留最后一个总排号
if (_zongpaiNos.length > 1) {
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
}
}
});
}
}

View File

@@ -0,0 +1,88 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationOverviewPart on _RegistrationPageState {
/// Find all scanned items that are already on shelf.
List<PaichaOverviewItem> _findOnShelfItemsInScanned() {
return registration_calculations.findOnShelfItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
/// Find all scanned items that are already transferred.
List<PaichaOverviewItem> _findTransferredItemsInScanned() {
return registration_calculations.findTransferredItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
/// Find all scanned items that are in temp storage.
List<PaichaOverviewItem> _findTempStoredItemsInScanned() {
return registration_calculations.findTempStoredItemsInScanned(
overview: _overview,
zongpaiNos: _zongpaiNos,
);
}
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);
}
}

View File

@@ -0,0 +1,129 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationScanPart on _RegistrationPageState {
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) {
if (event is! KeyDownEvent) return;
if (_isLockToggleKey(event)) {
_cycleMode();
return;
}
if (event.logicalKey == LogicalKeyboardKey.enter) {
_submit();
}
}
bool _isLockToggleKey(KeyEvent event) {
final key = event.logicalKey;
return key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.gameButtonRight1;
}
void _onScan(ScanResult result) {
final parsed = CodeParser.parse(result.barcode);
switch (parsed.type) {
case CodeType.zongpaiNo:
_handleZongpaiScan(parsed.value);
case CodeType.locationNormal:
case CodeType.locationTransit:
case CodeType.locationTempStorage:
_handleLocationScan(parsed.value, parsed.type);
case CodeType.invalid:
_feedbackService.trigger(FeedbackEvent.scanInvalid);
_showStatusOverride(
'无效码:${result.barcode}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
void _handleLocationScan(String locationCode, CodeType locationType) {
// In singleCode mode or no existing data or no previous shelf, switch directly
if (_mode == RegistrationMode.singleCode ||
_zongpaiNos.isEmpty ||
_locationCode == null) {
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
_locationCode = locationCode;
_locationType = locationType;
_clearStatusOverride();
});
return;
}
// In multiCode mode with existing data, confirm before switching
_showLocationSwitchDialog(locationCode, locationType);
}
void _handleZongpaiScan(String zongpaiNo) {
// If cross-paicha dialog is showing, just vibrate and discard
if (_crossPaichaPending) {
_triggerDoubleVibration();
return;
}
// In multiCode mode with existing data, check for cross-paicha scan
if (_mode == RegistrationMode.multiCode &&
_zongpaiNos.isNotEmpty &&
_overview?.success == true &&
!_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) {
_crossPaichaPending = true;
_triggerDoubleVibration();
_showCrossPaichaDialog(zongpaiNo);
return;
}
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
_feedbackService.trigger(FeedbackEvent.scanValid);
setState(() {
if (_mode == RegistrationMode.multiCode) {
if (!_zongpaiNos.contains(zongpaiNo)) {
_zongpaiNos.add(zongpaiNo);
}
} else {
if (_zongpaiNos.isEmpty) {
_zongpaiNos.add(zongpaiNo);
} else {
_zongpaiNos[0] = zongpaiNo;
}
}
_clearStatusOverride();
});
if (shouldRefresh) {
_loadOverview(zongpaiNo);
}
}
void _triggerDoubleVibration() {
Vibration.vibrate(pattern: [0, 200, 100, 200]);
}
void _removeZongpai(String zongpaiNo) {
setState(() {
_zongpaiNos.remove(zongpaiNo);
});
}
}

View File

@@ -0,0 +1,58 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationStatusPart on _RegistrationPageState {
void _clearStatusOverride() {
_statusOverrideText = null;
_statusOverrideDot = null;
}
void _showStatusOverride(String text, StatusDotColor dot, Duration duration) {
setState(() {
_statusOverrideText = text;
_statusOverrideDot = dot;
});
Future.delayed(duration, () {
if (mounted) setState(() => _clearStatusOverride());
});
}
void _updateBaseStatus() {
if (_isSubmitting) {
_statusDot = StatusDotColor.orange;
_statusText = '正在提交…';
return;
}
if (_mode == RegistrationMode.multiCode &&
_locationCode != null &&
_zongpaiNos.isEmpty) {
_statusDot = StatusDotColor.blue;
_statusText = '多码上架模式,请扫描下一张执行卡';
return;
}
final hasZongpai = _zongpaiNos.isNotEmpty;
final hasLocation = _locationCode != null;
if (hasZongpai && hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请确认信息并提交';
} else if (hasZongpai) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描目标货位号';
} else if (hasLocation) {
_statusDot = StatusDotColor.blue;
_statusText = '请扫描执行卡';
} else {
_statusDot = StatusDotColor.blue;
_statusText = '等待扫描总排号或货位号…';
}
}
bool get _canSubmit =>
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
bool get _isTempStorageTarget =>
isTempStorageTarget(_locationType, _locationCode);
}

View File

@@ -0,0 +1,336 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationSubmitPart on _RegistrationPageState {
Future<void> _submit() async {
if (!_canSubmit) return;
final baseUrl = await _baseUrl();
if (baseUrl == null) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'未配置 API 地址,请前往设置',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
// For transit target: block if any scanned item is already transferred
// (on_shelf items are allowed — transit registration acts as off-shelf)
if (_isTransitTarget) {
final transferredItems = _findTransferredItemsInScanned();
if (transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showLocationConflictDialog(
onShelfItems: [],
transferredItems: transferredItems,
);
return;
}
}
// For temp storage target: block if any scanned item is transferred or temp_stored
// (on_shelf items are allowed — temp storage acts as shelf change)
if (_isTempStorageTarget) {
final transferredItems = _findTransferredItemsInScanned();
final tempStoredItems = _findTempStoredItemsInScanned();
if (transferredItems.isNotEmpty || tempStoredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showLocationConflictDialog(
onShelfItems: [],
transferredItems: transferredItems,
tempStoredItems: tempStoredItems,
);
return;
}
}
if (_mode == RegistrationMode.multiCode && _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;
}
// Pre-check: block entire batch if any item already has a location binding
if (!_isTransitTarget && !_isTempStorageTarget) {
final onShelfItems = _findOnShelfItemsInScanned();
final transferredItems = _findTransferredItemsInScanned();
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
setState(() => _isSubmitting = false);
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showLocationConflictDialog(
onShelfItems: onShelfItems,
transferredItems: transferredItems,
);
return;
}
}
await _submitBatch(baseUrl);
} else {
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 {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (result.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
if (_isTransitTarget) {
setState(() => _isSubmitting = false);
await _navigateToBoxing(
mode: BoxingMode.singleCode,
codes: [zongpaiNo],
codesToClear: [zongpaiNo],
clearLocation: _mode == RegistrationMode.singleCode,
);
return;
}
setState(() {
_zongpaiNos.remove(zongpaiNo);
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
_locationCode = null;
_locationType = null;
}
});
final msg = result.isOffShelfSuccess
? '下架成功'
: _isTempStorageTarget
? '变更货架成功'
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
_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(
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
);
_showStatusOverride(
result.errorMessage ?? '提交失败',
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
Future<void> _submitBatch(String baseUrl) async {
int successCount = 0;
int offShelfCount = 0;
final failed = <String>[];
final toRemove = <String>[];
final transitCodes = <String>[];
for (final zongpaiNo in List.of(_zongpaiNos)) {
final result = await _apiService.registerLocation(
baseUrl: baseUrl,
zongpaiNo: zongpaiNo,
locationCode: _locationCode!,
);
if (result.success) {
successCount++;
if (result.isOffShelfSuccess) {
offShelfCount++;
}
toRemove.add(zongpaiNo);
if (_isTransitTarget) {
transitCodes.add(zongpaiNo);
}
} else {
failed.add(zongpaiNo);
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(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo);
return;
}
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(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
}
}
if (!mounted) return;
setState(() {
_zongpaiNos.removeWhere((item) => toRemove.contains(item));
_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) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
final msg = offShelfCount == successCount
? '批量下架成功($successCount 条)'
: '批量上架成功($successCount 条)';
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
await _refreshCurrentOverview();
} else {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
'成功 $successCount 条,失败 ${failed.length}',
StatusDotColor.red,
const Duration(seconds: 2),
);
}
}
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),
);
}
}
}

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class RegistrationOverviewStats {
final int totalCount;
final int shelvedCount;
final int transferredCount;
final int tempStoredCount;
const RegistrationOverviewStats({
required this.totalCount,
required this.shelvedCount,
required this.transferredCount,
required this.tempStoredCount,
});
}
class RegistrationOverviewSections {
final List<PaichaOverviewItem> scanned;
final List<PaichaOverviewItem> unscanned;
const RegistrationOverviewSections({
required this.scanned,
required this.unscanned,
});
bool get hasDivider => scanned.isNotEmpty && unscanned.isNotEmpty;
}
RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
if (overview?.success != true) {
return const RegistrationOverviewStats(
totalCount: 0,
shelvedCount: 0,
transferredCount: 0,
tempStoredCount: 0,
);
}
var shelvedCount = 0;
var transferredCount = 0;
var tempStoredCount = 0;
for (final item in overview!.items) {
switch (item.status) {
case 'on_shelf':
shelvedCount++;
case 'transferred':
transferredCount++;
case 'temp_stored':
tempStoredCount++;
}
}
return RegistrationOverviewStats(
totalCount: overview.totalCount,
shelvedCount: shelvedCount,
transferredCount: transferredCount,
tempStoredCount: tempStoredCount,
);
}
RegistrationOverviewSections splitOverviewItems({
required PaichaOverviewResult overview,
required Iterable<String> zongpaiNos,
}) {
final scannedSet = zongpaiNos.toSet();
final scanned = <PaichaOverviewItem>[];
final unscanned = <PaichaOverviewItem>[];
for (final item in overview.items) {
if (scannedSet.contains(item.zongpaiNo)) {
scanned.add(item);
} else {
unscanned.add(item);
}
}
return RegistrationOverviewSections(scanned: scanned, unscanned: unscanned);
}
List<PaichaOverviewItem> findOnShelfItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'on_shelf',
);
}
List<PaichaOverviewItem> findTransferredItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'transferred',
);
}
List<PaichaOverviewItem> findTempStoredItemsInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
return _findScannedItemsByStatus(
overview: overview,
zongpaiNos: zongpaiNos,
status: 'temp_stored',
);
}
bool hasAnyLocatedInScanned({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
}) {
if (overview?.success != true) return false;
final scannedSet = zongpaiNos.toSet();
return overview!.items.any(
(item) =>
scannedSet.contains(item.zongpaiNo) &&
(item.status == 'on_shelf' ||
item.status == 'transferred' ||
item.status == 'temp_stored'),
);
}
Color registrationBarColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
bool isTempStorageTarget = false,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: isTempStorageTarget
? (status == 'transferred' || status == 'temp_stored')
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red : const Color(0xFF43A047);
}
switch (status) {
case 'on_shelf':
return const Color(0xFF2196F3);
case 'temp_stored':
return const Color(0xFF7B1FA2);
case 'transferred':
return const Color(0xFFFF9800);
default:
return Colors.grey.shade400;
}
}
Color registrationRowBgColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
bool isTempStorageTarget = false,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: isTempStorageTarget
? (status == 'transferred' || status == 'temp_stored')
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
}
switch (status) {
case 'on_shelf':
return const Color(0xFFE3F2FD);
case 'temp_stored':
return const Color(0xFFF3E5F5);
case 'transferred':
return const Color(0xFFFFF3E0);
default:
return const Color(0xFFF5F5F5);
}
}
List<PaichaOverviewItem> _findScannedItemsByStatus({
required PaichaOverviewResult? overview,
required Iterable<String> zongpaiNos,
required String status,
}) {
if (overview?.success != true) return [];
final scannedSet = zongpaiNos.toSet();
return overview!.items
.where(
(item) => scannedSet.contains(item.zongpaiNo) && item.status == status,
)
.toList();
}

View File

@@ -0,0 +1,463 @@
// ignore_for_file: invalid_use_of_protected_member
part of '../../registration_page.dart';
extension _RegistrationWidgetsPart on _RegistrationPageState {
String _locationLabel(CodeType? type) {
if (type == CodeType.locationTransit) return '转运区域';
if (type == CodeType.locationTempStorage) return '暂存货架';
return '普通货架';
}
Color _locationLabelColor(CodeType? type) {
if (type == CodeType.locationTransit) return Colors.orange;
if (type == CodeType.locationTempStorage) return const Color(0xFF7B1FA2);
return Colors.blue;
}
Widget _buildModePill() {
return BoxingModePill(
isSingle: _mode == RegistrationMode.singleCode,
label: _mode == RegistrationMode.singleCode ? '单码上架' : '多码上架',
enabled: !_isSubmitting,
onTap: _cycleMode,
);
}
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,
),
),
);
}
// === Paicha Header ===
Widget _buildPaichaHeader() {
final overview = _overview;
final hasData = overview?.success == true;
final hasLoc = _locationCode != null;
final stats = registration_calculations.overviewStats(overview);
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
hasData ? (overview!.paichaNo ?? '--') : '--',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: hasData ? Colors.black87 : Colors.grey.shade400,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (hasLoc)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_locationCode!,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
const SizedBox(width: 5),
_buildLocationChip(),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatTag(
'${stats.totalCount}',
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
hasData,
),
_buildStatTag(
'上架 ${stats.shelvedCount}',
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
hasData,
),
_buildStatTag(
'转运 ${stats.transferredCount}',
const Color(0xFFFFF3E0),
const Color(0xFFE65100),
hasData,
),
_buildStatTag(
'暂存 ${stats.tempStoredCount}',
const Color(0xFFF3E5F5),
const Color(0xFF7B1FA2),
hasData,
),
],
),
],
),
),
],
),
);
}
Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
margin: const EdgeInsets.only(left: 5),
decoration: BoxDecoration(
color: hasData ? bg : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: hasData ? fg : Colors.grey.shade400,
),
),
);
}
// === Table Head ===
Widget _buildTableHead() {
return Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
border: Border(bottom: BorderSide(color: Colors.grey.shade400)),
),
child: const Row(
children: [
Expanded(
flex: 22,
child: Text(
'总排号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 18,
child: Text(
'工令号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
Expanded(
flex: 12,
child: Text('数量', textAlign: TextAlign.center, style: _headerStyle),
),
Expanded(
flex: 28,
child: Text(
'货位号',
textAlign: TextAlign.center,
style: _headerStyle,
),
),
],
),
);
}
// === List Body ===
Widget _buildListBody() {
if (_overviewZongpaiNo == null) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
if (_overviewLoading && _overview == null) {
return _buildListMessage('正在加载排产号上架情况…');
}
if (_overviewNotFound) {
return _buildListMessage('暂无排产信息');
}
if (_overviewError != null && _overview == null) {
return _buildListError(_overviewError!);
}
final overview = _overview;
if (overview == null || !overview.success) {
return _buildListMessage('扫描总排号后自动显示排产号上架情况');
}
final sections = registration_calculations.splitOverviewItems(
overview: overview,
zongpaiNos: _zongpaiNos,
);
final scanned = sections.scanned;
final unscanned = sections.unscanned;
return Column(
children: [
if (_overviewError != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
color: Colors.red.shade50,
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('重试'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount:
scanned.length +
(sections.hasDivider ? 1 : 0) +
unscanned.length,
itemBuilder: (context, index) {
if (index < scanned.length) {
return _buildListRow(scanned[index], isScanned: true);
}
if (index == scanned.length && sections.hasDivider) {
return _buildSectionDivider(unscanned.length);
}
final unscannedIdx =
index - scanned.length - (sections.hasDivider ? 1 : 0);
return _buildListRow(unscanned[unscannedIdx], isScanned: false);
},
),
),
],
);
}
Widget _buildSectionDivider(int count) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
const Expanded(child: Divider(height: 0)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'以下 $count 项未操作',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.grey.shade500,
),
),
),
const Expanded(child: Divider(height: 0)),
],
),
);
}
Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) {
final rowStyle = TextStyle(
fontSize: 15,
fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500,
color: Colors.black87,
);
final barColor = registration_calculations.registrationBarColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
isTempStorageTarget: _isTempStorageTarget,
);
final bgColor = registration_calculations.registrationRowBgColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
isTempStorageTarget: _isTempStorageTarget,
);
return Container(
constraints: const BoxConstraints(minHeight: 36),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
border: Border(
left: BorderSide(color: barColor, width: 3),
bottom: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
Expanded(
flex: 22,
child: Text(
item.zongpaiNo,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 18,
child: Text(
item.workOrderNo ?? '--',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 12,
child: Text(
item.quantity.toString(),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
Expanded(
flex: 28,
child: Text(
item.locationCode ?? '\u2014',
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: rowStyle,
),
),
if (isScanned && _mode == RegistrationMode.multiCode) ...[
const SizedBox(width: 6),
SizedBox(
width: 28,
height: 28,
child: IconButton(
icon: Icon(Icons.delete_outline, size: 16),
color: Colors.red,
onPressed: () => _removeZongpai(item.zongpaiNo),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
),
],
],
),
);
}
Widget _buildListMessage(String text) {
return Center(
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
);
}
Widget _buildListError(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('重试'),
),
],
),
);
}
// === Bottom Bar ===
Widget _buildBottomBar(ColorScheme colorScheme) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
width: double.infinity,
height: 34,
child: ElevatedButton(
onPressed: _canSubmit ? _submit : null,
style: ElevatedButton.styleFrom(
backgroundColor: registrationSubmitColor(
canSubmit: _canSubmit,
isTransitTarget: _isTransitTarget,
primaryColor: colorScheme.primary,
),
foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
registrationSubmitLabel(
isTransitTarget: _isTransitTarget,
isMultiCode: _mode == RegistrationMode.multiCode,
zongpaiCount: _zongpaiNos.length,
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}
const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);

File diff suppressed because it is too large Load Diff

View File

@@ -67,16 +67,31 @@ class _SettingsPageState extends State<SettingsPage> {
final configService = AppConfigService();
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');
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) {

View File

@@ -1,14 +1,8 @@
// lib/services/code_parser.dart
class CodeParser {
static final _zongpaiRegex = RegExp(
r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$',
);
static final _locationRegex = RegExp(
r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$',
);
static final _transitRegex = RegExp(
r'^TRANS-',
);
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$');
static final _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
static final _transitRegex = RegExp(r'^TRANS-');
static ParseResult parse(String code) {
final trimmed = code.trim();
@@ -23,16 +17,30 @@ class CodeParser {
return ParseResult(type: CodeType.zongpaiNo, value: normalized);
}
if (_locationRegex.hasMatch(normalized)) {
if (normalized.startsWith('B')) {
return ParseResult(
type: CodeType.locationTempStorage,
value: normalized,
);
}
return ParseResult(type: CodeType.locationNormal, value: normalized);
}
return ParseResult(type: CodeType.invalid, value: code);
}
static bool isLocation(CodeType type) =>
type == CodeType.locationNormal || type == CodeType.locationTransit;
type == CodeType.locationNormal ||
type == CodeType.locationTransit ||
type == CodeType.locationTempStorage;
}
enum CodeType { zongpaiNo, locationNormal, locationTransit, invalid }
enum CodeType {
zongpaiNo,
locationNormal,
locationTransit,
locationTempStorage,
invalid,
}
class ParseResult {
final CodeType type;

View File

@@ -8,15 +8,22 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
(locationCode?.startsWith('TRANS-') ?? false);
}
bool isTempStorageTarget(CodeType? locationType, String? locationCode) {
return locationType == CodeType.locationTempStorage ||
(locationCode?.startsWith('B') ?? false);
}
String registrationSubmitLabel({
required bool isTransitTarget,
required bool isLocked,
required bool isMultiCode,
required int zongpaiCount,
}) {
if (!isTransitTarget) {
return isLocked && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
return isMultiCode && zongpaiCount > 1
? '批量上架($zongpaiCount 条)'
: '确 认 上 架';
}
return isLocked && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
}
Color registrationSubmitColor({

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/registration/registration_calculations.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('registration calculations', () {
test('calculates overview stats by item status', () {
final stats = overviewStats(
_overview([
_item('R1', status: 'not_shelved'),
_item('R2', status: 'on_shelf'),
_item('R3', status: 'transferred'),
]),
);
expect(stats.totalCount, 3);
expect(stats.shelvedCount, 1);
expect(stats.transferredCount, 1);
});
test('splits overview items into scanned and unscanned groups', () {
final sections = splitOverviewItems(
overview: _overview([_item('R1'), _item('R2'), _item('R3')]),
zongpaiNos: ['R1', 'R3'],
);
expect(sections.scanned.map((item) => item.zongpaiNo), ['R1', 'R3']);
expect(sections.unscanned.map((item) => item.zongpaiNo), ['R2']);
expect(sections.hasDivider, isTrue);
});
test('finds located scanned items', () {
final overview = _overview([
_item('R1', status: 'on_shelf'),
_item('R2', status: 'transferred'),
_item('R3', status: 'on_shelf'),
]);
expect(
findOnShelfItemsInScanned(
overview: overview,
zongpaiNos: ['R1', 'R2'],
).map((item) => item.zongpaiNo),
['R1'],
);
expect(
findTransferredItemsInScanned(
overview: overview,
zongpaiNos: ['R1', 'R2'],
).map((item) => item.zongpaiNo),
['R2'],
);
expect(
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R1']),
isTrue,
);
expect(
hasAnyLocatedInScanned(overview: overview, zongpaiNos: ['R4']),
isFalse,
);
});
test('maps row colors for scanned and unscanned states', () {
expect(
registrationBarColor(
status: 'on_shelf',
isScanned: true,
isTransitTarget: false,
),
Colors.red,
);
expect(
registrationRowBgColor(
status: 'transferred',
isScanned: false,
isTransitTarget: false,
),
const Color(0xFFFFF3E0),
);
});
});
}
PaichaOverviewResult _overview(List<PaichaOverviewItem> items) {
return PaichaOverviewResult(
success: true,
totalCount: items.length,
items: items,
);
}
PaichaOverviewItem _item(String zongpaiNo, {String status = 'not_shelved'}) {
return PaichaOverviewItem(zongpaiNo: zongpaiNo, quantity: 1, status: status);
}

View File

@@ -16,7 +16,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: false,
isLocked: false,
isMultiCode: false,
zongpaiCount: 1,
),
'确 认 上 架',
@@ -24,7 +24,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: false,
isLocked: true,
isMultiCode: true,
zongpaiCount: 3,
),
'批量上架3 条)',
@@ -32,7 +32,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: true,
isLocked: false,
isMultiCode: false,
zongpaiCount: 1,
),
'转运并装箱',
@@ -40,7 +40,7 @@ void main() {
expect(
registrationSubmitLabel(
isTransitTarget: true,
isLocked: true,
isMultiCode: true,
zongpaiCount: 3,
),
'批量转运并凑箱3 条)',