Compare commits

...

12 Commits

Author SHA1 Message Date
Misaka
9dc714dbeb feat: extend boxing page for accessory boxing support
Add pending accessories section to both single-code and multi-code boxing
modes. Accessories display with orange styling and a "加入本箱" button that
calls saveBoxRecord with item_type=accessory. Extend API service with
PendingAccessory model, itemType fields on data classes, and optional
accessory_id parameter on saveBoxRecord. Show accessories with visual
indicator (wrench icon + orange background) in boxing detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 18:01:01 +08:00
Misaka
1416cff52c feat: add accessory module card to home page
Add 附件登记 (accessory registration) card to the home page module
registry with developing status, keyboard shortcut 3, and navigation
to AccessoryPage. Refactor _moduleFromShortcut to use dynamic index
lookup for better maintainability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:53:25 +08:00
Misaka
12cab16888 feat: add accessory registration page
Create accessory_page.dart with step-by-step form for registering
product accessories: paichan_no query, work order selection, zongpai_no
auto-fill, accessory type selector (preset + custom), quantity input,
location code (scanner + manual), and submit. Includes registered
accessories list with edit/delete actions and boxed-item indicators.
Integrates with ScannerService, FeedbackService, and StatusBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:51:03 +08:00
Misaka
9aa5d86fde feat: add accessory API methods and data classes to ApiService
Add accessory-related data classes (WorkOrderGroup, AccessoryTypeInfo,
AccessoryRecord), result classes, and 8 API methods for the accessory
module. Also add pendingAccessories field to BoxInfoResult.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 17:46:14 +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
24 changed files with 3814 additions and 1307 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`.**

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,8 @@ class BoxingApiActions {
required String zongpaiNo,
required int boxNo,
required int quantity,
String itemType = 'product',
int? accessoryId,
}) async {
final baseUrl = await _requireBaseUrl();
if (baseUrl == null) return _missingApiUrl();
@@ -74,6 +76,8 @@ class BoxingApiActions {
zongpaiNo: zongpaiNo,
boxNo: boxNo,
quantity: quantity,
itemType: itemType,
accessoryId: accessoryId,
);
return _saveResult(result, fallbackMessage: '提交失败');
}

View File

@@ -74,6 +74,7 @@ extension _BoxingScanPart on _BoxingPageState {
_currentZongpaiBoxes = result.currentZongpaiBoxes;
_existingBoxes = result.existingBoxes;
_maxBoxNo = result.maxBoxNo;
_pendingAccessories = result.pendingAccessories;
_phase = BoxingPhase.scanned;
_isDuplicateBoxNo = false;
_editingBox = null;

View File

@@ -158,4 +158,47 @@ extension _BoxingSubmitPart on _BoxingPageState {
const Duration(milliseconds: 1500),
);
}
Future<void> _submitAccessory(PendingAccessory accessory) async {
if (_zongpaiNo == null || _isSubmitting) return;
final boxNo = int.tryParse(_boxNoController.text);
if (boxNo == null || boxNo <= 0) {
this._showStatusOverride(
'请先输入有效箱号',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
setState(() => _isSubmitting = true);
final action = await _apiActions.saveBoxRecord(
zongpaiNo: _zongpaiNo!,
boxNo: boxNo,
quantity: accessory.quantity,
itemType: 'accessory',
accessoryId: accessory.accessoryId,
);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (action.success) {
_feedbackService.trigger(FeedbackEvent.submitSuccess);
// Refresh box info to update pending accessories
await this._queryBoxInfo(_zongpaiNo!);
if (mounted) {
this._showStatusOverride(
'${accessory.accessoryType} 已加入箱号 $boxNo',
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
}
} else {
this._showActionError(action, fallback: '附件装箱失败');
}
}
}

View File

@@ -36,6 +36,8 @@ class MultiCodeBody extends StatelessWidget {
final ValueChanged<ManyToOnePackedItem> onStartDeletePackedItem;
final ValueChanged<ManyToOnePackedItem> onDeletePackedItem;
final VoidCallback onCancelDeletePackedItem;
final List<PendingAccessory> pendingAccessories;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const MultiCodeBody({
super.key,
@@ -69,6 +71,8 @@ class MultiCodeBody extends StatelessWidget {
required this.onStartDeletePackedItem,
required this.onDeletePackedItem,
required this.onCancelDeletePackedItem,
required this.pendingAccessories,
required this.onSubmitAccessory,
});
@override
@@ -87,7 +91,7 @@ class MultiCodeBody extends StatelessWidget {
),
const _MultiListHeader(),
Expanded(
child: visibleItems.isEmpty
child: visibleItems.isEmpty && pendingAccessories.isEmpty
? Center(
child: Text(
'尚未装入任何物料',
@@ -99,22 +103,32 @@ class MultiCodeBody extends StatelessWidget {
),
)
: ListView.builder(
itemCount: visibleItems.length,
itemCount: visibleItems.length + pendingAccessories.length,
itemBuilder: (context, index) {
final item = visibleItems[index];
return MultiPackedRow(
item: item,
editing: editingPackedItemId == item.boxItemId,
deleting: deletingPackedItemId == item.boxItemId,
if (index < visibleItems.length) {
final item = visibleItems[index];
return MultiPackedRow(
item: item,
editing: editingPackedItemId == item.boxItemId,
deleting: deletingPackedItemId == item.boxItemId,
isSubmitting: isSubmitting,
editingQuantity: editingPackedQuantity,
onEditingQuantityChanged:
onEditingPackedQuantityChanged,
onSave: () => onSavePackedItem(item),
onCancelEdit: onCancelEditPackedItem,
onStartEdit: () => onStartEditPackedItem(item),
onStartDelete: () => onStartDeletePackedItem(item),
onDelete: () => onDeletePackedItem(item),
onCancelDelete: onCancelDeletePackedItem,
);
}
final accIndex = index - visibleItems.length;
final acc = pendingAccessories[accIndex];
return _MultiAccessoryRow(
accessory: acc,
isSubmitting: isSubmitting,
editingQuantity: editingPackedQuantity,
onEditingQuantityChanged: onEditingPackedQuantityChanged,
onSave: () => onSavePackedItem(item),
onCancelEdit: onCancelEditPackedItem,
onStartEdit: () => onStartEditPackedItem(item),
onStartDelete: () => onStartDeletePackedItem(item),
onDelete: () => onDeletePackedItem(item),
onCancelDelete: onCancelDeletePackedItem,
onSubmit: () => onSubmitAccessory(acc),
);
},
),
@@ -604,3 +618,72 @@ class _MultiSubmitRow extends StatelessWidget {
);
}
}
class _MultiAccessoryRow extends StatelessWidget {
final PendingAccessory accessory;
final bool isSubmitting;
final VoidCallback onSubmit;
const _MultiAccessoryRow({
required this.accessory,
required this.isSubmitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Icon(Icons.build, size: 14, color: Colors.orange.shade600),
const SizedBox(width: 6),
Expanded(
flex: 40,
child: Text(
accessory.accessoryType,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 20,
child: Text(
'\u00d7${accessory.quantity}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: isSubmitting ? null : onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: isSubmitting
? Colors.grey.shade300
: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'加入本箱',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}

View File

@@ -38,6 +38,8 @@ class SingleCodeBody extends StatelessWidget {
final ValueChanged<CurrentZongpaiBoxData> onStartDeleteAssigned;
final ValueChanged<CurrentZongpaiBoxData> onDeleteAssignedItem;
final VoidCallback onCancelDeleteAssigned;
final List<PendingAccessory> pendingAccessories;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const SingleCodeBody({
super.key,
@@ -74,6 +76,8 @@ class SingleCodeBody extends StatelessWidget {
required this.onStartDeleteAssigned,
required this.onDeleteAssignedItem,
required this.onCancelDeleteAssigned,
required this.pendingAccessories,
required this.onSubmitAccessory,
});
@override
@@ -114,6 +118,12 @@ class SingleCodeBody extends StatelessWidget {
onDeleteAssignedItem: onDeleteAssignedItem,
onCancelDeleteAssigned: onCancelDeleteAssigned,
),
if (pendingAccessories.isNotEmpty && !isWaiting)
_PendingAccessoriesSection(
pendingAccessories: pendingAccessories,
isSubmitting: isSubmitting,
onSubmitAccessory: onSubmitAccessory,
),
_SingleBottomArea(
disabled: isWaiting || isFinished,
boxNoController: boxNoController,
@@ -239,9 +249,7 @@ class _SingleInfoBar extends StatelessWidget {
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: isWaiting
? Colors.grey.shade400
: Colors.black87,
color: isWaiting ? Colors.grey.shade400 : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
@@ -252,9 +260,7 @@ class _SingleInfoBar extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
isWaiting
? '已有 -- 箱'
: '已有 ${existingBoxes.length}',
isWaiting ? '已有 -- 箱' : '已有 ${existingBoxes.length}',
style: TextStyle(
fontSize: 12,
color: isWaiting
@@ -263,9 +269,7 @@ class _SingleInfoBar extends StatelessWidget {
),
),
Text(
isWaiting
? '最大箱号 --'
: '最大箱号 $maxBoxNo',
isWaiting ? '最大箱号 --' : '最大箱号 $maxBoxNo',
style: TextStyle(
fontSize: 12,
color: isWaiting
@@ -576,3 +580,120 @@ class _SingleBottomArea extends StatelessWidget {
);
}
}
class _PendingAccessoriesSection extends StatelessWidget {
final List<PendingAccessory> pendingAccessories;
final bool isSubmitting;
final ValueChanged<PendingAccessory> onSubmitAccessory;
const _PendingAccessoriesSection({
required this.pendingAccessories,
required this.isSubmitting,
required this.onSubmitAccessory,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.orange.shade200)),
),
child: Row(
children: [
Icon(Icons.build, size: 14, color: Colors.orange.shade700),
const SizedBox(width: 6),
Text(
'待装箱附件(${pendingAccessories.length} 项)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.orange.shade800,
),
),
],
),
),
...pendingAccessories.map(
(acc) => _AccessoryRow(
accessory: acc,
isSubmitting: isSubmitting,
onSubmit: () => onSubmitAccessory(acc),
),
),
],
);
}
}
class _AccessoryRow extends StatelessWidget {
final PendingAccessory accessory;
final bool isSubmitting;
final VoidCallback onSubmit;
const _AccessoryRow({
required this.accessory,
required this.isSubmitting,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Expanded(
flex: 40,
child: Text(
accessory.accessoryType,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
flex: 20,
child: Text(
'\u00d7${accessory.quantity}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: isSubmitting ? null : onSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: isSubmitting
? Colors.grey.shade300
: Colors.orange.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'加入本箱',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}

View File

@@ -103,15 +103,40 @@ class BoxingDetailPage extends StatelessWidget {
),
child: Column(
children: box.items.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
final isAccessory = item.itemType == 'accessory';
return Container(
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 4,
),
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: isAccessory
? BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(4),
)
: null,
child: Row(
children: [
if (isAccessory)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Icon(
Icons.build,
size: 14,
color: Colors.orange.shade700,
),
),
Expanded(
flex: 3,
child: Text(
item.zongpaiNo,
style: const TextStyle(fontSize: 14),
style: TextStyle(
fontSize: 14,
fontWeight: isAccessory
? FontWeight.w600
: FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),

View File

@@ -107,6 +107,9 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
// 多码凑箱:已完成装箱时可跳转的箱号
int? _completedJumpBoxNo;
// 待装箱附件
List<PendingAccessory> _pendingAccessories = [];
@override
void initState() {
super.initState();
@@ -244,6 +247,7 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
_isDuplicateBoxNo = false;
_statusOverrideText = null;
_statusOverrideDot = null;
_pendingAccessories = [];
}
// === 重复箱号检测 ===
@@ -415,6 +419,9 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
this._deletePackedItem(item),
onCancelDeletePackedItem: () =>
setState(() => _deletingPackedItemId = null),
pendingAccessories: _pendingAccessories,
onSubmitAccessory: (accessory) =>
this._submitAccessory(accessory),
)
: SingleCodeBody(
isWaiting:
@@ -465,6 +472,9 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
this._deleteAssignedItem(item),
onCancelDeleteAssigned: () =>
setState(() => _deletingAssignedItemId = null),
pendingAccessories: _pendingAccessories,
onSubmitAccessory: (accessory) =>
this._submitAccessory(accessory),
),
),

View File

@@ -4,10 +4,11 @@ import 'package:pad_scanner/services/app_config_service.dart';
import 'package:pad_scanner/pages/settings_page.dart';
import 'package:pad_scanner/pages/registration_page.dart';
import 'package:pad_scanner/pages/boxing_page.dart';
import 'package:pad_scanner/pages/accessory_page.dart';
import 'package:pad_scanner/services/api_service.dart';
/// Module type enum for each available feature card
enum ModuleType { registration, boxing, shelfQuery }
enum ModuleType { registration, boxing, accessory, shelfQuery }
/// Module status for PRD state tracking
enum ModuleStatus { online, developing, planning }
@@ -64,8 +65,16 @@ class _HomePageState extends State<HomePage> {
route: '/boxing',
),
FeatureCard(
type: ModuleType.shelfQuery,
type: ModuleType.accessory,
shortcut: '3',
icon: Icons.build,
title: '附件登记',
status: ModuleStatus.developing,
route: '/accessory',
),
FeatureCard(
type: ModuleType.shelfQuery,
shortcut: '4',
icon: Icons.search_outlined,
title: '货架查询',
status: ModuleStatus.planning,
@@ -101,16 +110,10 @@ class _HomePageState extends State<HomePage> {
FeatureCard? _moduleFromShortcut(KeyEvent event) {
final shortcut = _shortcutNumberFromKeyEvent(event);
if (shortcut == 1) {
return _modules[0];
if (shortcut == null || shortcut < 1 || shortcut > _modules.length) {
return null;
}
if (shortcut == 2) {
return _modules[1];
}
if (shortcut == 3) {
return _modules[2];
}
return null;
return _modules[shortcut - 1];
}
int? _shortcutNumberFromKeyEvent(KeyEvent event) {
@@ -130,6 +133,11 @@ class _HomePageState extends State<HomePage> {
event.character == '3') {
return 3;
}
if (key == LogicalKeyboardKey.digit4 ||
key == LogicalKeyboardKey.numpad4 ||
event.character == '4') {
return 4;
}
return null;
}
@@ -181,6 +189,9 @@ class _HomePageState extends State<HomePage> {
case ModuleType.boxing:
targetPage = const BoxingPage();
break;
case ModuleType.accessory:
targetPage = const AccessoryPage();
break;
case ModuleType.shelfQuery:
return; // 规划中,不导航
}

View File

@@ -0,0 +1,333 @@
// 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,
}) {
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),
]);
}
}
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,80 @@
// 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,
);
}
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,128 @@
// 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:
_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,55 @@
// 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);
}

View File

@@ -0,0 +1,317 @@
// 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;
}
}
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) {
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) {
_locationCode = null;
_locationType = null;
}
});
final msg = result.isOffShelfSuccess
? '下架成功'
: (_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,163 @@
import 'package:flutter/material.dart';
import 'package:pad_scanner/services/api_service.dart';
class RegistrationOverviewStats {
final int totalCount;
final int shelvedCount;
final int transferredCount;
const RegistrationOverviewStats({
required this.totalCount,
required this.shelvedCount,
required this.transferredCount,
});
}
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,
);
}
var shelvedCount = 0;
var transferredCount = 0;
for (final item in overview!.items) {
switch (item.status) {
case 'on_shelf':
shelvedCount++;
case 'transferred':
transferredCount++;
}
}
return RegistrationOverviewStats(
totalCount: overview.totalCount,
shelvedCount: shelvedCount,
transferredCount: transferredCount,
);
}
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',
);
}
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'),
);
}
Color registrationBarColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red : const Color(0xFF43A047);
}
switch (status) {
case 'on_shelf':
return const Color(0xFF2196F3);
case 'transferred':
return const Color(0xFFFF9800);
default:
return Colors.grey.shade400;
}
}
Color registrationRowBgColor({
required String status,
required bool isScanned,
required bool isTransitTarget,
}) {
if (isScanned) {
final isConflict = isTransitTarget
? status == 'transferred'
: (status == 'on_shelf' || status == 'transferred');
return isConflict ? Colors.red.shade50 : const Color(0xFFF1F8E9);
}
switch (status) {
case 'on_shelf':
return const Color(0xFFE3F2FD);
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,453 @@
// 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 '转运区域';
return '普通货架';
}
Color _locationLabelColor(CodeType? type) {
if (type == CodeType.locationTransit) return Colors.orange;
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,
),
],
),
],
),
),
],
),
);
}
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,
);
final bgColor = registration_calculations.registrationRowBgColor(
status: item.status,
isScanned: isScanned,
isTransitTarget: _isTransitTarget,
);
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

@@ -118,6 +118,30 @@ class PaichaOverviewResult {
// === 装箱模块数据类 ===
/// 待装箱附件
class PendingAccessory {
final int accessoryId;
final String accessoryType;
final int quantity;
final String? locationCode;
PendingAccessory({
required this.accessoryId,
required this.accessoryType,
required this.quantity,
this.locationCode,
});
factory PendingAccessory.fromJson(Map<String, dynamic> json) {
return PendingAccessory(
accessoryId: json['accessory_id'] as int,
accessoryType: json['accessory_type'] as String,
quantity: json['quantity'] as int,
locationCode: json['location_code']?.toString(),
);
}
}
/// 箱号内单个总排号明细
class BoxItemData {
final int? boxItemId;
@@ -125,6 +149,7 @@ class BoxItemData {
final String? workOrderNo;
final int quantity;
final int? totalQuantity;
final String itemType;
BoxItemData({
this.boxItemId,
@@ -132,6 +157,7 @@ class BoxItemData {
this.workOrderNo,
required this.quantity,
this.totalQuantity,
this.itemType = 'product',
});
factory BoxItemData.fromJson(Map<String, dynamic> json) {
@@ -141,6 +167,7 @@ class BoxItemData {
workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int,
totalQuantity: json['total_quantity'] as int?,
itemType: json['item_type'] as String? ?? 'product',
);
}
}
@@ -167,11 +194,13 @@ class CurrentZongpaiBoxData {
final int boxItemId;
final int boxNo;
final int quantity;
final String itemType;
CurrentZongpaiBoxData({
required this.boxItemId,
required this.boxNo,
required this.quantity,
this.itemType = 'product',
});
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
@@ -179,6 +208,7 @@ class CurrentZongpaiBoxData {
boxItemId: json['box_item_id'] as int,
boxNo: json['box_no'] as int,
quantity: json['quantity'] as int,
itemType: json['item_type'] as String? ?? 'product',
);
}
}
@@ -195,6 +225,7 @@ class BoxInfoResult {
final List<BoxDetailData> existingBoxes;
final int maxBoxNo;
final int suggestedBoxNo;
final List<PendingAccessory> pendingAccessories;
BoxInfoResult({
required this.success,
@@ -207,6 +238,7 @@ class BoxInfoResult {
this.existingBoxes = const [],
this.maxBoxNo = 0,
this.suggestedBoxNo = 1,
this.pendingAccessories = const [],
});
factory BoxInfoResult.ok(Map<String, dynamic> json) {
@@ -222,6 +254,11 @@ class BoxInfoResult {
)
.toList() ??
[];
final pendingAccessories =
(json['pending_accessories'] as List<dynamic>?)
?.map((a) => PendingAccessory.fromJson(a as Map<String, dynamic>))
.toList() ??
[];
return BoxInfoResult(
success: true,
zongpaiNo: json['zongpai_no'] as String?,
@@ -232,6 +269,7 @@ class BoxInfoResult {
existingBoxes: boxes,
maxBoxNo: json['max_box_no'] as int? ?? 0,
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
pendingAccessories: pendingAccessories,
);
}
@@ -299,6 +337,163 @@ class BoxDeleteResult {
BoxDeleteResult({required this.success, this.errorMessage});
}
// === 附件模块数据类 ===
/// 工令号分组(工令号 → 总排号列表)
class WorkOrderGroup {
final String workOrderNo;
final List<String> zongpaiNos;
WorkOrderGroup({required this.workOrderNo, required this.zongpaiNos});
factory WorkOrderGroup.fromJson(Map<String, dynamic> json) => WorkOrderGroup(
workOrderNo: json['work_order_no'] as String,
zongpaiNos: (json['zongpai_nos'] as List).map((e) => e as String).toList(),
);
}
/// 附件类型预设
class AccessoryTypeInfo {
final int id;
final String name;
final int sortOrder;
AccessoryTypeInfo({
required this.id,
required this.name,
required this.sortOrder,
});
factory AccessoryTypeInfo.fromJson(Map<String, dynamic> json) =>
AccessoryTypeInfo(
id: json['id'] as int,
name: json['name'] as String,
sortOrder: json['sort_order'] as int,
);
}
/// 附件记录
class AccessoryRecord {
final int id;
final String paichanNo;
final String zongpaiNo;
final String accessoryType;
final int quantity;
final String? locationCode;
final bool isBoxed;
final String createdAt;
AccessoryRecord({
required this.id,
required this.paichanNo,
required this.zongpaiNo,
required this.accessoryType,
required this.quantity,
this.locationCode,
required this.isBoxed,
required this.createdAt,
});
factory AccessoryRecord.fromJson(Map<String, dynamic> json) =>
AccessoryRecord(
id: json['id'] as int,
paichanNo: json['paichan_no'] as String,
zongpaiNo: json['zongpai_no'] as String,
accessoryType: json['accessory_type'] as String,
quantity: json['quantity'] as int,
locationCode: json['location_code'] as String?,
isBoxed: json['is_boxed'] as bool,
createdAt: json['created_at'] as String,
);
}
// === 附件模块结果类 ===
class WorkOrderQueryResult {
final bool success;
final String? paichanNo;
final List<WorkOrderGroup> workOrders;
final String? errorMessage;
final String? errorCode;
WorkOrderQueryResult({
required this.success,
this.paichanNo,
this.workOrders = const [],
this.errorMessage,
this.errorCode,
});
}
class AccessoryListResult {
final bool success;
final List<AccessoryRecord> items;
final String? errorMessage;
final String? errorCode;
AccessoryListResult({
required this.success,
this.items = const [],
this.errorMessage,
this.errorCode,
});
}
class AccessoryCreateResult {
final bool success;
final AccessoryRecord? record;
final String? errorMessage;
final String? errorCode;
AccessoryCreateResult({
required this.success,
this.record,
this.errorMessage,
this.errorCode,
});
}
class AccessoryUpdateResult {
final bool success;
final AccessoryRecord? record;
final String? errorMessage;
final String? errorCode;
AccessoryUpdateResult({
required this.success,
this.record,
this.errorMessage,
this.errorCode,
});
}
class AccessoryDeleteResult {
final bool success;
final String? errorMessage;
final String? errorCode;
AccessoryDeleteResult({
required this.success,
this.errorMessage,
this.errorCode,
});
}
class AccessoryTypeListResult {
final bool success;
final List<AccessoryTypeInfo> types;
final String? errorMessage;
final String? errorCode;
AccessoryTypeListResult({
required this.success,
this.types = const [],
this.errorMessage,
this.errorCode,
});
}
class AccessoryTypeCreateResult {
final bool success;
final AccessoryTypeInfo? type;
final String? errorMessage;
final String? errorCode;
AccessoryTypeCreateResult({
required this.success,
this.type,
this.errorMessage,
this.errorCode,
});
}
class ApiService {
final http.Client _client;
final Duration timeout;
@@ -423,18 +618,25 @@ class ApiService {
required String zongpaiNo,
required int boxNo,
required int quantity,
String itemType = 'product',
int? accessoryId,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box');
try {
final bodyMap = <String, dynamic>{
'zongpai_no': zongpaiNo,
'box_no': boxNo,
'quantity': quantity,
'item_type': itemType,
};
if (accessoryId != null) {
bodyMap['accessory_id'] = accessoryId;
}
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'zongpai_no': zongpaiNo,
'box_no': boxNo,
'quantity': quantity,
}),
body: jsonEncode(bodyMap),
)
.timeout(timeout);
@@ -524,6 +726,403 @@ class ApiService {
}
}
// === 附件模块 API 方法 ===
/// 工令号查询 — GET /CargoTrace/accessory/work-orders
Future<WorkOrderQueryResult> queryWorkOrders({
required String baseUrl,
required String paichanNo,
}) async {
final uri = Uri.parse(
'$baseUrl/CargoTrace/accessory/work-orders',
).replace(queryParameters: {'paichan_no': paichanNo});
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final workOrders =
(body['work_orders'] as List<dynamic>?)
?.map(
(w) => WorkOrderGroup.fromJson(w as Map<String, dynamic>),
)
.toList() ??
[];
return WorkOrderQueryResult(
success: true,
paichanNo: body['paichan_no'] as String?,
workOrders: workOrders,
);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return WorkOrderQueryResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
case 404:
return WorkOrderQueryResult(
success: false,
errorMessage: '未找到该排产号对应的工令号信息',
errorCode: 'NOT_FOUND',
);
default:
return WorkOrderQueryResult(
success: false,
errorMessage: '查询失败 (${response.statusCode})',
);
}
} catch (e) {
return WorkOrderQueryResult(success: false, errorMessage: '网络异常,请检查网络连接');
}
}
/// 附件列表查询 — GET /CargoTrace/accessory
Future<AccessoryListResult> listAccessories({
required String baseUrl,
required String paichanNo,
}) async {
final uri = Uri.parse(
'$baseUrl/CargoTrace/accessory',
).replace(queryParameters: {'paichan_no': paichanNo});
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items =
(body['items'] as List<dynamic>?)
?.map(
(i) => AccessoryRecord.fromJson(i as Map<String, dynamic>),
)
.toList() ??
[];
return AccessoryListResult(success: true, items: items);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryListResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
case 404:
return AccessoryListResult(
success: false,
errorMessage: '未找到该排产号信息',
errorCode: 'NOT_FOUND',
);
default:
return AccessoryListResult(
success: false,
errorMessage: '查询失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryListResult(success: false, errorMessage: '网络异常,请检查网络连接');
}
}
/// 附件创建(上架) — POST /CargoTrace/accessory
Future<AccessoryCreateResult> createAccessory({
required String baseUrl,
required String paichanNo,
required String zongpaiNo,
required String accessoryType,
required int quantity,
String? locationCode,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory');
try {
final bodyMap = <String, dynamic>{
'paichan_no': paichanNo,
'zongpai_no': zongpaiNo,
'accessory_type': accessoryType,
'quantity': quantity,
};
if (locationCode != null) {
bodyMap['location_code'] = locationCode;
}
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(bodyMap),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryCreateResult(
success: true,
record: AccessoryRecord.fromJson(body),
);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryCreateResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
case 404:
return AccessoryCreateResult(
success: false,
errorMessage: '未找到该排产号对应的工令号信息',
errorCode: 'NOT_FOUND',
);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryCreateResult(
success: false,
errorMessage: body['message']?.toString() ?? '冲突',
errorCode: body['error_code']?.toString(),
);
default:
return AccessoryCreateResult(
success: false,
errorMessage: '创建失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryCreateResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// 附件更新 — PATCH /CargoTrace/accessory/{id}
Future<AccessoryUpdateResult> updateAccessory({
required String baseUrl,
required int id,
String? accessoryType,
int? quantity,
String? locationCode,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
try {
final bodyMap = <String, dynamic>{};
if (accessoryType != null) bodyMap['accessory_type'] = accessoryType;
if (quantity != null) bodyMap['quantity'] = quantity;
if (locationCode != null) bodyMap['location_code'] = locationCode;
final response = await _client
.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(bodyMap),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryUpdateResult(
success: true,
record: AccessoryRecord.fromJson(body),
);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryUpdateResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
case 404:
return AccessoryUpdateResult(
success: false,
errorMessage: '指定附件记录不存在',
errorCode: 'NOT_FOUND',
);
default:
return AccessoryUpdateResult(
success: false,
errorMessage: '更新失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryUpdateResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// 附件删除 — DELETE /CargoTrace/accessory/{id}
Future<AccessoryDeleteResult> deleteAccessory({
required String baseUrl,
required int id,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory/$id');
try {
final response = await _client
.delete(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
return AccessoryDeleteResult(success: true);
case 404:
return AccessoryDeleteResult(
success: false,
errorMessage: '指定附件记录不存在',
errorCode: 'NOT_FOUND',
);
default:
return AccessoryDeleteResult(
success: false,
errorMessage: '删除失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryDeleteResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// 附件类型列表 — GET /CargoTrace/accessory-type
Future<AccessoryTypeListResult> listAccessoryTypes({
required String baseUrl,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
try {
final response = await _client
.get(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final types =
(body['types'] as List<dynamic>?)
?.map(
(t) =>
AccessoryTypeInfo.fromJson(t as Map<String, dynamic>),
)
.toList() ??
[];
return AccessoryTypeListResult(success: true, types: types);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryTypeListResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
default:
return AccessoryTypeListResult(
success: false,
errorMessage: '查询失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryTypeListResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// 附件类型创建 — POST /CargoTrace/accessory-type
Future<AccessoryTypeCreateResult> createAccessoryType({
required String baseUrl,
required String name,
int sortOrder = 0,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type');
try {
final response = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'name': name, 'sort_order': sortOrder}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryTypeCreateResult(
success: true,
type: AccessoryTypeInfo.fromJson(body),
);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryTypeCreateResult(
success: false,
errorMessage: body['message']?.toString() ?? '请求参数错误',
errorCode: body['error_code']?.toString(),
);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryTypeCreateResult(
success: false,
errorMessage: body['message']?.toString() ?? '附件类型已存在',
errorCode: body['error_code']?.toString(),
);
default:
return AccessoryTypeCreateResult(
success: false,
errorMessage: '创建失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryTypeCreateResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// 附件类型删除 — DELETE /CargoTrace/accessory-type/{id}
Future<AccessoryDeleteResult> deleteAccessoryType({
required String baseUrl,
required int id,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/accessory-type/$id');
try {
final response = await _client
.delete(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
return AccessoryDeleteResult(success: true);
case 404:
return AccessoryDeleteResult(
success: false,
errorMessage: '指定附件类型不存在',
errorCode: 'NOT_FOUND',
);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return AccessoryDeleteResult(
success: false,
errorMessage: body['message']?.toString() ?? '该类型下存在附件记录,无法删除',
errorCode: body['error_code']?.toString(),
);
default:
return AccessoryDeleteResult(
success: false,
errorMessage: '删除失败 (${response.statusCode})',
);
}
} catch (e) {
return AccessoryDeleteResult(
success: false,
errorMessage: '网络异常,请检查网络连接',
);
}
}
/// Test connectivity by making a HEAD request to the base URL.
Future<bool> testConnection(String baseUrl) async {
try {

View File

@@ -10,13 +10,13 @@ bool isTransitTarget(CodeType? locationType, String? locationCode) {
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 条)',