Compare commits
7 Commits
f6bfd8cb10
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf974cee92 | ||
|
|
15c29bceb8 | ||
|
|
6b05d6c0e3 | ||
|
|
b7484915c6 | ||
|
|
b383328aed | ||
|
|
a702f80116 | ||
|
|
99ed3bb9ec |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,6 +46,10 @@ app.*.map.json
|
||||
/android/app/release
|
||||
/android/build/
|
||||
|
||||
# Release signing keystore (do NOT commit — contains signing identity)
|
||||
/android/key.properties
|
||||
/android/app/key.jks
|
||||
|
||||
# AI Agents
|
||||
.claude/
|
||||
.agents/
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,11 +8,26 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.pad_scanner"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
@@ -32,9 +50,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,85 @@ class BoxingApiActions {
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<AttachmentBoxResult>> saveAttachmentBox({
|
||||
required String zongpaiNo,
|
||||
required int categoryId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
final result = await _apiService.saveAttachmentBox(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
categoryId: categoryId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
if (result.isDuplicate) {
|
||||
return BoxingActionResult.error(
|
||||
kind: BoxingActionErrorKind.duplicate,
|
||||
message: result.errorMessage ?? '提交失败',
|
||||
boxNo: result.boxNo,
|
||||
);
|
||||
}
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '提交失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<AttachmentBoxResult>> updateAttachmentBox({
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
final result = await _apiService.updateAttachmentBox(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
boxNo: boxNo,
|
||||
quantity: quantity,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '修改失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<AttachmentBoxDeleteResult>> deleteAttachmentBox({
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
final result = await _apiService.deleteAttachmentBox(
|
||||
baseUrl: baseUrl,
|
||||
boxItemId: boxItemId,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '删除失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<BoxingActionResult<AttachmentCategoryResult>>
|
||||
fetchAttachmentCategories() async {
|
||||
final baseUrl = await _requireBaseUrl();
|
||||
if (baseUrl == null) return _missingApiUrl();
|
||||
final result = await _apiService.fetchAttachmentCategories(
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
if (result.success) return BoxingActionResult.ok(result);
|
||||
return BoxingActionResult.error(
|
||||
kind: _kindForMessage(result.errorMessage),
|
||||
message: result.errorMessage ?? '加载失败',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _requireBaseUrl() async {
|
||||
final baseUrl = await _loadApiUrl() ?? '';
|
||||
if (baseUrl.isEmpty) return null;
|
||||
|
||||
86
lib/pages/boxing/dialogs/attachment_category_picker.dart
Normal file
86
lib/pages/boxing/dialogs/attachment_category_picker.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
/// Show a single-category picker for attachment boxing.
|
||||
/// Returns the selected category, or null.
|
||||
Future<AttachmentCategoryData?> showBoxingAttachmentCategoryPicker({
|
||||
required BuildContext context,
|
||||
required List<AttachmentCategoryData> categories,
|
||||
}) {
|
||||
return showDialog<AttachmentCategoryData>(
|
||||
context: context,
|
||||
builder: (ctx) => _BoxingCategoryPickerDialog(categories: categories),
|
||||
);
|
||||
}
|
||||
|
||||
class _BoxingCategoryPickerDialog extends StatefulWidget {
|
||||
final List<AttachmentCategoryData> categories;
|
||||
const _BoxingCategoryPickerDialog({required this.categories});
|
||||
|
||||
@override
|
||||
State<_BoxingCategoryPickerDialog> createState() =>
|
||||
_BoxingCategoryPickerDialogState();
|
||||
}
|
||||
|
||||
class _BoxingCategoryPickerDialogState
|
||||
extends State<_BoxingCategoryPickerDialog> {
|
||||
int? _selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('选择附件类型'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: RadioGroup<int>(
|
||||
groupValue: _selectedId,
|
||||
onChanged: (v) => setState(() => _selectedId = v),
|
||||
child: widget.categories.length <= 6
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _selectedId != null
|
||||
? () {
|
||||
final cat = widget.categories.firstWhere(
|
||||
(c) => c.id == _selectedId,
|
||||
);
|
||||
Navigator.pop(context, cat);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
|
||||
final parsed = CodeParser.parse(result.barcode);
|
||||
|
||||
if (parsed.type == CodeType.attachmentCode) {
|
||||
this._handleAttachmentBoxScan(parsed.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type != CodeType.zongpaiNo) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
this._showStatusOverride(
|
||||
@@ -66,6 +71,13 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
: FeedbackEvent.scanValid,
|
||||
);
|
||||
|
||||
final attSummary = result.attachmentSummary;
|
||||
final pending =
|
||||
attSummary != null &&
|
||||
attSummary['determination'] == 'has' &&
|
||||
attSummary['all_complete'] != true;
|
||||
final pendingCount = attSummary?['pending_count'] as int? ?? 0;
|
||||
|
||||
setState(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
@@ -82,6 +94,8 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
_deletingAssignedItemId = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
_attachmentPending = pending;
|
||||
_attachmentPendingCount = pendingCount;
|
||||
if (_mode == BoxingMode.singleCode) {
|
||||
_paichanSwitchNotice = null;
|
||||
}
|
||||
@@ -92,6 +106,119 @@ extension _BoxingScanPart on _BoxingPageState {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _handleAttachmentBoxScan(String zongpai) async {
|
||||
if (_isAutoProcessing || _crossPaichaPending) return;
|
||||
|
||||
// 1. Fetch categories
|
||||
final catAction = await _apiActions.fetchAttachmentCategories();
|
||||
if (!mounted) return;
|
||||
if (!catAction.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
catAction.errorMessage ?? '加载附件类型失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Pick category
|
||||
final picked = await showBoxingAttachmentCategoryPicker(
|
||||
context: context,
|
||||
categories: catAction.data!.categories,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_attachmentCategoryId = picked.id;
|
||||
_attachmentCategoryName = picked.name;
|
||||
});
|
||||
|
||||
// 3. Fetch box info (server-extended: includes attachment_summary)
|
||||
final action = await _apiActions.fetchBoxInfo(zongpai);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!action.success) {
|
||||
_feedbackService.trigger(switch (action.errorKind) {
|
||||
BoxingActionErrorKind.network => FeedbackEvent.networkError,
|
||||
BoxingActionErrorKind.missingApiUrl => FeedbackEvent.submitFailure,
|
||||
_ => FeedbackEvent.scanInvalid,
|
||||
});
|
||||
_showStatusOverride(
|
||||
action.errorMessage ?? '查询失败',
|
||||
_dotForActionError(action.errorKind),
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = action.data!;
|
||||
final totalQuantity = result.quantity ?? 0;
|
||||
|
||||
// 4. Derive attachment qty context from the box-info attachment_summary
|
||||
// or from a separate status call
|
||||
final baseUrl = await _baseUrl();
|
||||
int expectedQty = totalQuantity;
|
||||
int boxedQty = 0;
|
||||
|
||||
if (baseUrl != null) {
|
||||
final statusResult = await _apiService.fetchAttachmentStatus(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpai,
|
||||
);
|
||||
if (statusResult.success) {
|
||||
for (final item in statusResult.items) {
|
||||
if (item.categoryId == _attachmentCategoryId) {
|
||||
expectedQty = item.expectedQty;
|
||||
boxedQty = item.boxedQty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final remaining = expectedQty - boxedQty;
|
||||
final alreadyCompleted = remaining <= 0;
|
||||
|
||||
_feedbackService.trigger(
|
||||
alreadyCompleted
|
||||
? FeedbackEvent.alreadyCompleted
|
||||
: FeedbackEvent.scanValid,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_zongpaiNo = zongpai;
|
||||
_paichanNo = result.paichanNo;
|
||||
_workOrderNo = result.workOrderNo;
|
||||
_erpQuantity = result.quantity;
|
||||
_currentZongpaiBoxes = result.currentZongpaiBoxes;
|
||||
_existingBoxes = result.existingBoxes;
|
||||
_maxBoxNo = result.maxBoxNo;
|
||||
_attachmentExpectedQty = expectedQty;
|
||||
_attachmentBoxedQty = boxedQty;
|
||||
_phase = BoxingPhase.scanned;
|
||||
_isDuplicateBoxNo = false;
|
||||
_editingBox = null;
|
||||
_completedJumpBoxNo = null;
|
||||
_statusOverrideText = null;
|
||||
});
|
||||
|
||||
// Auto-fill: box_no = maxBoxNo+1, quantity = remaining
|
||||
if (alreadyCompleted) {
|
||||
_boxNoController.clear();
|
||||
_quantityController.clear();
|
||||
_statusOverrideText = '该类型附件已全部装箱完毕';
|
||||
_statusOverrideDot = StatusDotColor.red;
|
||||
return;
|
||||
}
|
||||
_boxNoController.text = (_maxBoxNo + 1).toString();
|
||||
_quantityController.text = remaining.toString();
|
||||
_quantityController.selection = TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: _quantityController.text.length,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
||||
@@ -6,6 +6,53 @@ extension _BoxingSubmitPart on _BoxingPageState {
|
||||
Future<bool> _submit() async {
|
||||
if (!_canSubmit) return false;
|
||||
|
||||
final attachCatId = _attachmentCategoryId;
|
||||
if (attachCatId != null) {
|
||||
// Attachment boxing submit
|
||||
setState(() => _isSubmitting = true);
|
||||
final action = await _apiActions.saveAttachmentBox(
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
categoryId: attachCatId,
|
||||
boxNo: int.parse(_boxNoController.text),
|
||||
quantity: int.parse(_quantityController.text),
|
||||
);
|
||||
if (!mounted) return false;
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
if (action.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
// Reset to waiting for next scan
|
||||
setState(() {
|
||||
_zongpaiNo = null;
|
||||
_phase = BoxingPhase.waiting;
|
||||
_attachmentCategoryId = null;
|
||||
_attachmentCategoryName = null;
|
||||
});
|
||||
_showStatusOverride(
|
||||
'附件装箱成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
_requestFocus();
|
||||
return true;
|
||||
} else if (action.errorKind == BoxingActionErrorKind.duplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateBoxNo);
|
||||
_showStatusOverride(
|
||||
action.errorMessage ?? '该箱号已存在同类型附件',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
} else {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
action.errorMessage ?? '提交失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final boxNo = int.parse(_boxNoController.text);
|
||||
final quantity = int.parse(_quantityController.text);
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
||||
final int remainingQuantity;
|
||||
final String? zongpaiNo;
|
||||
final int? completedJumpBoxNo;
|
||||
final bool attachmentPending;
|
||||
final int attachmentPendingCount;
|
||||
final VoidCallback onJumpToCompletedBox;
|
||||
|
||||
const BoxingNoticeBanners({
|
||||
@@ -26,6 +28,8 @@ class BoxingNoticeBanners extends StatelessWidget {
|
||||
required this.remainingQuantity,
|
||||
required this.zongpaiNo,
|
||||
required this.completedJumpBoxNo,
|
||||
this.attachmentPending = false,
|
||||
this.attachmentPendingCount = 0,
|
||||
required this.onJumpToCompletedBox,
|
||||
});
|
||||
|
||||
@@ -76,6 +80,21 @@ class BoxingNoticeBanners extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (attachmentPending) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
icon: Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
text: '本单有附件,尚未到齐(剩 $attachmentPendingCount 种)',
|
||||
background: Colors.amber.shade50,
|
||||
foreground: Colors.amber.shade900,
|
||||
border: Colors.amber.shade200,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
|
||||
banners.add(
|
||||
_NoticeBanner(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:pad_scanner/services/scanner_service.dart';
|
||||
import 'package:pad_scanner/services/code_parser.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
import 'package:pad_scanner/services/app_config_service.dart';
|
||||
import 'package:pad_scanner/services/boxing_context.dart';
|
||||
import 'package:pad_scanner/services/feedback_service.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.dart';
|
||||
@@ -16,6 +17,7 @@ import 'package:pad_scanner/pages/boxing/boxing_calculations.dart'
|
||||
as boxing_calculations;
|
||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
||||
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
|
||||
import 'package:pad_scanner/pages/boxing/dialogs/attachment_category_picker.dart';
|
||||
import 'package:pad_scanner/pages/boxing/dialogs/cross_paichan_dialog.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
|
||||
@@ -107,6 +109,19 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
// 多码凑箱:已完成装箱时可跳转的箱号
|
||||
int? _completedJumpBoxNo;
|
||||
|
||||
// 附件装箱上下文
|
||||
int? _attachmentCategoryId; // currently selected attachment category
|
||||
// ignore: unused_field -- for display in status bar (consumed by later task)
|
||||
String? _attachmentCategoryName;
|
||||
// ignore: unused_field -- expected_qty for this category (later task)
|
||||
int? _attachmentExpectedQty;
|
||||
// ignore: unused_field -- already boxed qty for this category (later task)
|
||||
int? _attachmentBoxedQty;
|
||||
|
||||
/// Whether to show the attachment-pending banner.
|
||||
bool _attachmentPending = false;
|
||||
int _attachmentPendingCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -148,6 +163,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _baseUrl() async {
|
||||
final url = await AppConfigService().getString('api_url');
|
||||
if (url == null || url.isEmpty) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
@@ -244,6 +265,12 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
_isDuplicateBoxNo = false;
|
||||
_statusOverrideText = null;
|
||||
_statusOverrideDot = null;
|
||||
_attachmentCategoryId = null;
|
||||
_attachmentCategoryName = null;
|
||||
_attachmentExpectedQty = null;
|
||||
_attachmentBoxedQty = null;
|
||||
_attachmentPending = false;
|
||||
_attachmentPendingCount = 0;
|
||||
}
|
||||
|
||||
// === 重复箱号检测 ===
|
||||
@@ -367,6 +394,8 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
||||
remainingQuantity: _remainingQuantity,
|
||||
zongpaiNo: _zongpaiNo,
|
||||
completedJumpBoxNo: _completedJumpBoxNo,
|
||||
attachmentPending: _attachmentPending,
|
||||
attachmentPendingCount: _attachmentPendingCount,
|
||||
onJumpToCompletedBox: () => this._jumpToCompletedBox(),
|
||||
),
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
/// Show a single-category picker. Returns the selected category, or null.
|
||||
Future<AttachmentCategoryData?> showAttachmentCategoryPicker({
|
||||
required BuildContext context,
|
||||
required List<AttachmentCategoryData> categories,
|
||||
}) {
|
||||
return showDialog<AttachmentCategoryData>(
|
||||
context: context,
|
||||
builder: (ctx) => _CategoryPickerDialog(categories: categories),
|
||||
);
|
||||
}
|
||||
|
||||
class _CategoryPickerDialog extends StatefulWidget {
|
||||
final List<AttachmentCategoryData> categories;
|
||||
const _CategoryPickerDialog({required this.categories});
|
||||
|
||||
@override
|
||||
State<_CategoryPickerDialog> createState() => _CategoryPickerDialogState();
|
||||
}
|
||||
|
||||
class _CategoryPickerDialogState extends State<_CategoryPickerDialog> {
|
||||
int? _selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('选择附件类型'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: RadioGroup<int>(
|
||||
groupValue: _selectedId,
|
||||
onChanged: (v) => setState(() => _selectedId = v),
|
||||
child: widget.categories.length <= 6
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.categories
|
||||
.map(
|
||||
(cat) => RadioListTile<int>(
|
||||
dense: true,
|
||||
title: Text(cat.name),
|
||||
value: cat.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _selectedId != null
|
||||
? () {
|
||||
final cat = widget.categories.firstWhere(
|
||||
(c) => c.id == _selectedId,
|
||||
);
|
||||
Navigator.pop(context, cat);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
191
lib/pages/registration/dialogs/attachment_config_dialog.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pad_scanner/services/api_service.dart';
|
||||
|
||||
class AttachmentConfigDialogResult {
|
||||
final String determination; // 'has' or 'none'
|
||||
final List<AttachmentConfigItemData> items; // empty if 'none'
|
||||
|
||||
const AttachmentConfigDialogResult({
|
||||
required this.determination,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the attachment configuration dialog for an order.
|
||||
///
|
||||
/// [categories] — the full active category list from GET /attachment/categories.
|
||||
/// [prefillItems] — existing attachment status items (for pre-fill when
|
||||
/// determination is already 'has' due to earlier attachment arrival).
|
||||
/// [erpQuantity] — default expected_qty per selected type.
|
||||
Future<AttachmentConfigDialogResult?> showAttachmentConfigDialog({
|
||||
required BuildContext context,
|
||||
required String zongpaiNo,
|
||||
required List<AttachmentCategoryData> categories,
|
||||
required List<AttachmentStatusItemData> prefillItems,
|
||||
required int erpQuantity,
|
||||
}) {
|
||||
return showDialog<AttachmentConfigDialogResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _AttachmentConfigDialog(
|
||||
zongpaiNo: zongpaiNo,
|
||||
categories: categories,
|
||||
prefillItems: prefillItems,
|
||||
erpQuantity: erpQuantity,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AttachmentConfigDialog extends StatefulWidget {
|
||||
final String zongpaiNo;
|
||||
final List<AttachmentCategoryData> categories;
|
||||
final List<AttachmentStatusItemData> prefillItems;
|
||||
final int erpQuantity;
|
||||
|
||||
const _AttachmentConfigDialog({
|
||||
required this.zongpaiNo,
|
||||
required this.categories,
|
||||
required this.prefillItems,
|
||||
required this.erpQuantity,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AttachmentConfigDialog> createState() =>
|
||||
_AttachmentConfigDialogState();
|
||||
}
|
||||
|
||||
class _AttachmentConfigDialogState extends State<_AttachmentConfigDialog> {
|
||||
late Map<int, bool> _selected; // category_id → checked
|
||||
late Map<int, TextEditingController> _controllers; // category_id → qty
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = {};
|
||||
_controllers = {};
|
||||
final prefillSet = <int>{};
|
||||
for (final item in widget.prefillItems) {
|
||||
_selected[item.categoryId] = true;
|
||||
prefillSet.add(item.categoryId);
|
||||
}
|
||||
for (final cat in widget.categories) {
|
||||
if (!_selected.containsKey(cat.id)) {
|
||||
_selected[cat.id] = prefillSet.contains(cat.id);
|
||||
}
|
||||
final ctrl = TextEditingController(
|
||||
text: prefillSet.contains(cat.id)
|
||||
? widget.prefillItems
|
||||
.firstWhere((i) => i.categoryId == cat.id)
|
||||
.expectedQty
|
||||
.toString()
|
||||
: widget.erpQuantity.toString(),
|
||||
);
|
||||
_controllers[cat.id] = ctrl;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final ctrl in _controllers.values) {
|
||||
ctrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAnyChecked = _selected.values.any((v) => v);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('${widget.zongpaiNo} — 附件配置'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'请勾选本订单携带的附件类型,并确认每种数量:',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...widget.categories.map(
|
||||
(cat) => CheckboxListTile(
|
||||
dense: true,
|
||||
title: Text(cat.name, style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Text('应到: ', style: TextStyle(fontSize: 12)),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _controllers[cat.id],
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _selected[cat.id] == true,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: _selected[cat.id] ?? false,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selected[cat.id] = v ?? false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
AttachmentConfigDialogResult(determination: 'none', items: []),
|
||||
);
|
||||
},
|
||||
child: const Text('无附件'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: hasAnyChecked
|
||||
? () {
|
||||
final items = <AttachmentConfigItemData>[];
|
||||
for (final cat in widget.categories) {
|
||||
if (_selected[cat.id] == true) {
|
||||
final qty =
|
||||
int.tryParse(_controllers[cat.id]!.text.trim()) ??
|
||||
widget.erpQuantity;
|
||||
items.add(
|
||||
AttachmentConfigItemData(
|
||||
categoryId: cat.id,
|
||||
expectedQty: qty,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Navigator.pop(
|
||||
context,
|
||||
AttachmentConfigDialogResult(
|
||||
determination: 'has',
|
||||
items: items,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ extension _RegistrationDialogsPart on _RegistrationPageState {
|
||||
void _showLocationConflictDialog({
|
||||
required List<PaichaOverviewItem> onShelfItems,
|
||||
required List<PaichaOverviewItem> transferredItems,
|
||||
List<PaichaOverviewItem> tempStoredItems = const [],
|
||||
}) {
|
||||
final contentParts = <Widget>[];
|
||||
|
||||
@@ -142,6 +143,29 @@ extension _RegistrationDialogsPart on _RegistrationPageState {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
185
lib/pages/registration/parts/registration_attachment_part.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
// ignore_for_file: invalid_use_of_protected_member
|
||||
|
||||
part of '../../registration_page.dart';
|
||||
|
||||
extension _RegistrationAttachmentPart on _RegistrationPageState {
|
||||
/// Handle FJ- attachment scan: pick category, then register location.
|
||||
Future<void> _handleAttachmentScan(String zongpaiNo) async {
|
||||
if (_crossPaichaPending) {
|
||||
_triggerDoubleVibration();
|
||||
return;
|
||||
}
|
||||
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null || !mounted) return;
|
||||
|
||||
// 1. Fetch categories (cache in the page state)
|
||||
final catResult = await _apiService.fetchAttachmentCategories(
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
if (!catResult.success || !mounted) {
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
_showStatusOverride(
|
||||
catResult.errorMessage ?? '加载附件类型失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final categories = catResult.categories;
|
||||
|
||||
// 2. Pick category (single choice)
|
||||
final picked = await showAttachmentCategoryPicker(
|
||||
context: context,
|
||||
categories: categories,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
// 3. Now treat this zongpai as "scanned" with the picked category
|
||||
// For the registration flow: append scanned zongpai (for location binding)
|
||||
// and store category context for the submit path
|
||||
_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;
|
||||
}
|
||||
}
|
||||
// Store the last-scanned attachment category for submit
|
||||
_pendingAttachmentCategory = picked.id;
|
||||
_clearStatusOverride();
|
||||
});
|
||||
|
||||
// 4. Refresh the overview (the zongpai is now a product scan for display,
|
||||
// attachment status will be shown via a separate banner/tag)
|
||||
final shouldRefresh = _overviewZongpaiNo != zongpaiNo;
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a product scan with attachment-config integration.
|
||||
/// Called from the existing _handleZongpaiScan when a product code is scanned.
|
||||
Future<void> _checkAndConfigureAttachment(String zongpaiNo) async {
|
||||
final baseUrl = await _baseUrl();
|
||||
if (baseUrl == null || !mounted) return;
|
||||
|
||||
// Fetch attachment status for this zongpai
|
||||
final status = await _apiService.fetchAttachmentStatus(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!status.success) {
|
||||
// Network error or other — defer to product scan flow without blocking
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.determination == 'none') {
|
||||
// Already determined none — nothing to configure
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.determination == 'undetermined' ||
|
||||
status.determination == 'has') {
|
||||
// Fetch categories for the dialog
|
||||
final catResult = await _apiService.fetchAttachmentCategories(
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
if (!catResult.success || !mounted) return;
|
||||
|
||||
// Fetch ERP quantity for default expected_qty
|
||||
final overview = _overview;
|
||||
final erpQty = overview?.success == true
|
||||
? overview!.items
|
||||
.where((i) => i.zongpaiNo == zongpaiNo)
|
||||
.fold<int>(0, (sum, i) => sum + i.quantity)
|
||||
: 0;
|
||||
|
||||
final result = await showAttachmentConfigDialog(
|
||||
context: context,
|
||||
zongpaiNo: zongpaiNo,
|
||||
categories: catResult.categories,
|
||||
prefillItems: status.items,
|
||||
erpQuantity: erpQty > 0 ? erpQty : 1,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
// Submit config
|
||||
final configResult = await _apiService.putAttachmentConfig(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
determination: result.determination,
|
||||
items: result.items,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!configResult.success) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
configResult.errorMessage ?? '配置失败',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit attachment location for the given zongpai.
|
||||
Future<bool> _submitAttachmentLocation(
|
||||
String baseUrl,
|
||||
String zongpaiNo,
|
||||
int categoryId,
|
||||
) async {
|
||||
final result = await _apiService.registerAttachmentLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
categoryId: categoryId,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else if (result.isDuplicate) {
|
||||
_feedbackService.trigger(FeedbackEvent.duplicateError);
|
||||
if (result.conflictInfo != null) {
|
||||
final msg =
|
||||
'附件 $zongpaiNo 已上架至 ${result.conflictInfo!['location_code']}';
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} else if (result.isAlreadyOffShelf) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitFailure);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '该附件已下架至转运区域,不可重新上架',
|
||||
StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
|
||||
_feedbackService.trigger(
|
||||
isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure,
|
||||
);
|
||||
_showStatusOverride(
|
||||
result.errorMessage ?? '提交失败',
|
||||
isNetwork ? StatusDotColor.yellow : StatusDotColor.red,
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ extension _RegistrationOverviewPart on _RegistrationPageState {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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') ?? '';
|
||||
|
||||
@@ -46,8 +46,11 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
||||
switch (parsed.type) {
|
||||
case CodeType.zongpaiNo:
|
||||
_handleZongpaiScan(parsed.value);
|
||||
case CodeType.attachmentCode:
|
||||
_handleAttachmentScan(parsed.value);
|
||||
case CodeType.locationNormal:
|
||||
case CodeType.locationTransit:
|
||||
case CodeType.locationTempStorage:
|
||||
_handleLocationScan(parsed.value, parsed.type);
|
||||
case CodeType.invalid:
|
||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||
@@ -114,6 +117,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
|
||||
if (shouldRefresh) {
|
||||
_loadOverview(zongpaiNo);
|
||||
}
|
||||
// Trigger attachment config check in background (modal dialog if needed)
|
||||
unawaited(_checkAndConfigureAttachment(zongpaiNo));
|
||||
}
|
||||
|
||||
void _triggerDoubleVibration() {
|
||||
|
||||
@@ -52,4 +52,7 @@ extension _RegistrationStatusPart on _RegistrationPageState {
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode);
|
||||
|
||||
bool get _isTempStorageTarget =>
|
||||
isTempStorageTarget(_locationType, _locationCode);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,23 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -47,7 +64,7 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
return;
|
||||
}
|
||||
// Pre-check: block entire batch if any item already has a location binding
|
||||
if (!_isTransitTarget) {
|
||||
if (!_isTransitTarget && !_isTempStorageTarget) {
|
||||
final onShelfItems = _findOnShelfItemsInScanned();
|
||||
final transferredItems = _findTransferredItemsInScanned();
|
||||
if (onShelfItems.isNotEmpty || transferredItems.isNotEmpty) {
|
||||
@@ -93,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
}
|
||||
|
||||
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
|
||||
// Attachment scan: submit via POST /attachment/location instead
|
||||
final pendingCat = _pendingAttachmentCategory;
|
||||
if (pendingCat != null) {
|
||||
final ok = await _submitAttachmentLocation(
|
||||
baseUrl,
|
||||
zongpaiNo,
|
||||
pendingCat,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
_pendingAttachmentCategory = null;
|
||||
});
|
||||
if (ok) {
|
||||
_feedbackService.trigger(FeedbackEvent.submitSuccess);
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
_showStatusOverride(
|
||||
'附件上架成功',
|
||||
StatusDotColor.green,
|
||||
const Duration(milliseconds: 1500),
|
||||
);
|
||||
await _refreshCurrentOverview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _apiService.registerLocation(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: zongpaiNo,
|
||||
@@ -117,13 +166,15 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
|
||||
}
|
||||
setState(() {
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (_mode == RegistrationMode.singleCode) {
|
||||
if (_mode == RegistrationMode.singleCode && !_isTempStorageTarget) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
}
|
||||
});
|
||||
final msg = result.isOffShelfSuccess
|
||||
? '下架成功'
|
||||
: _isTempStorageTarget
|
||||
? '变更货架成功'
|
||||
: (_mode == RegistrationMode.multiCode ? '多码上架模式,请扫描下一张执行卡' : '上架成功');
|
||||
_showStatusOverride(
|
||||
msg,
|
||||
|
||||
@@ -5,11 +5,13 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,17 +33,21 @@ RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +55,7 @@ RegistrationOverviewStats overviewStats(PaichaOverviewResult? overview) {
|
||||
totalCount: overview.totalCount,
|
||||
shelvedCount: shelvedCount,
|
||||
transferredCount: transferredCount,
|
||||
tempStoredCount: tempStoredCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +98,17 @@ List<PaichaOverviewItem> findTransferredItemsInScanned({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -100,7 +118,9 @@ bool hasAnyLocatedInScanned({
|
||||
return overview!.items.any(
|
||||
(item) =>
|
||||
scannedSet.contains(item.zongpaiNo) &&
|
||||
(item.status == 'on_shelf' || item.status == 'transferred'),
|
||||
(item.status == 'on_shelf' ||
|
||||
item.status == 'transferred' ||
|
||||
item.status == 'temp_stored'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,10 +128,13 @@ 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);
|
||||
}
|
||||
@@ -119,6 +142,8 @@ Color registrationBarColor({
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFF2196F3);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFF7B1FA2);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFF9800);
|
||||
default:
|
||||
@@ -130,10 +155,13 @@ 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);
|
||||
}
|
||||
@@ -141,6 +169,8 @@ Color registrationRowBgColor({
|
||||
switch (status) {
|
||||
case 'on_shelf':
|
||||
return const Color(0xFFE3F2FD);
|
||||
case 'temp_stored':
|
||||
return const Color(0xFFF3E5F5);
|
||||
case 'transferred':
|
||||
return const Color(0xFFFFF3E0);
|
||||
default:
|
||||
|
||||
@@ -5,11 +5,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -108,6 +110,12 @@ extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||
const Color(0xFFE65100),
|
||||
hasData,
|
||||
),
|
||||
_buildStatTag(
|
||||
'暂存 ${stats.tempStoredCount}',
|
||||
const Color(0xFFF3E5F5),
|
||||
const Color(0xFF7B1FA2),
|
||||
hasData,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -293,11 +301,13 @@ extension _RegistrationWidgetsPart on _RegistrationPageState {
|
||||
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(
|
||||
|
||||
@@ -14,11 +14,14 @@ import 'package:pad_scanner/widgets/status_bar.dart';
|
||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||
import 'package:pad_scanner/pages/registration/registration_calculations.dart'
|
||||
as registration_calculations;
|
||||
import 'package:pad_scanner/pages/registration/dialogs/attachment_config_dialog.dart';
|
||||
import 'package:pad_scanner/pages/registration/dialogs/attachment_category_picker.dart';
|
||||
|
||||
part 'registration/parts/registration_scan_part.dart';
|
||||
part 'registration/parts/registration_status_part.dart';
|
||||
part 'registration/parts/registration_overview_part.dart';
|
||||
part 'registration/parts/registration_submit_part.dart';
|
||||
part 'registration/parts/registration_attachment_part.dart';
|
||||
part 'registration/parts/registration_mode_part.dart';
|
||||
part 'registration/dialogs/registration_dialogs_part.dart';
|
||||
part 'registration/widgets/registration_widgets_part.dart';
|
||||
@@ -63,6 +66,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
/// Whether a cross-paicha warning dialog is currently showing.
|
||||
bool _crossPaichaPending = false;
|
||||
|
||||
/// Pending attachment category ID set by the FJ- scan branch.
|
||||
/// Consumed by the submit path to call POST /attachment/location
|
||||
/// instead of POST /location.
|
||||
int? _pendingAttachmentCategory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
@@ -195,6 +195,7 @@ class BoxInfoResult {
|
||||
final List<BoxDetailData> existingBoxes;
|
||||
final int maxBoxNo;
|
||||
final int suggestedBoxNo;
|
||||
final Map<String, dynamic>? attachmentSummary;
|
||||
|
||||
BoxInfoResult({
|
||||
required this.success,
|
||||
@@ -207,6 +208,7 @@ class BoxInfoResult {
|
||||
this.existingBoxes = const [],
|
||||
this.maxBoxNo = 0,
|
||||
this.suggestedBoxNo = 1,
|
||||
this.attachmentSummary,
|
||||
});
|
||||
|
||||
factory BoxInfoResult.ok(Map<String, dynamic> json) {
|
||||
@@ -232,6 +234,7 @@ class BoxInfoResult {
|
||||
existingBoxes: boxes,
|
||||
maxBoxNo: json['max_box_no'] as int? ?? 0,
|
||||
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
|
||||
attachmentSummary: json['attachment_summary'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +302,169 @@ class BoxDeleteResult {
|
||||
BoxDeleteResult({required this.success, this.errorMessage});
|
||||
}
|
||||
|
||||
// === 附件模块数据类 ===
|
||||
|
||||
class AttachmentCategoryData {
|
||||
final int id;
|
||||
final String name;
|
||||
final int sortOrder;
|
||||
|
||||
AttachmentCategoryData({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
factory AttachmentCategoryData.fromJson(Map<String, dynamic> json) {
|
||||
return AttachmentCategoryData(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
sortOrder: json['sort_order'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentCategoryResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final List<AttachmentCategoryData> categories;
|
||||
|
||||
AttachmentCategoryResult({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.categories = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentStatusItemData {
|
||||
final int categoryId;
|
||||
final String name;
|
||||
final int expectedQty;
|
||||
final int boxedQty;
|
||||
final String? locationCode;
|
||||
final bool complete;
|
||||
|
||||
AttachmentStatusItemData({
|
||||
required this.categoryId,
|
||||
required this.name,
|
||||
required this.expectedQty,
|
||||
required this.boxedQty,
|
||||
this.locationCode,
|
||||
required this.complete,
|
||||
});
|
||||
|
||||
factory AttachmentStatusItemData.fromJson(Map<String, dynamic> json) {
|
||||
return AttachmentStatusItemData(
|
||||
categoryId: json['category_id'] as int,
|
||||
name: json['name'] as String,
|
||||
expectedQty: json['expected_qty'] as int,
|
||||
boxedQty: json['boxed_qty'] as int,
|
||||
locationCode: json['location_code']?.toString(),
|
||||
complete: json['complete'] as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentStatusResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final String determination;
|
||||
final bool allComplete;
|
||||
final List<AttachmentStatusItemData> items;
|
||||
|
||||
AttachmentStatusResult({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.determination = 'undetermined',
|
||||
this.allComplete = false,
|
||||
this.items = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentConfigItemData {
|
||||
final int categoryId;
|
||||
final int expectedQty;
|
||||
|
||||
const AttachmentConfigItemData({
|
||||
required this.categoryId,
|
||||
required this.expectedQty,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'category_id': categoryId,
|
||||
'expected_qty': expectedQty,
|
||||
};
|
||||
}
|
||||
|
||||
class AttachmentConfigResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final String? errorCode;
|
||||
final String determination;
|
||||
final bool allComplete;
|
||||
final List<AttachmentStatusItemData> items;
|
||||
|
||||
AttachmentConfigResult({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.errorCode,
|
||||
this.determination = 'undetermined',
|
||||
this.allComplete = false,
|
||||
this.items = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentLocationResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final bool isAlreadyOffShelf;
|
||||
final String? errorMessage;
|
||||
final String? locationCode;
|
||||
final String? previousLocation;
|
||||
final Map<String, dynamic>? conflictInfo;
|
||||
|
||||
AttachmentLocationResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.isAlreadyOffShelf = false,
|
||||
this.errorMessage,
|
||||
this.locationCode,
|
||||
this.previousLocation,
|
||||
this.conflictInfo,
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentBoxResult {
|
||||
final bool success;
|
||||
final bool isDuplicate;
|
||||
final String? errorMessage;
|
||||
final int? boxItemId;
|
||||
final String? paichanNo;
|
||||
final int? boxNo;
|
||||
final String? zongpaiNo;
|
||||
final int? categoryId;
|
||||
final int? quantity;
|
||||
|
||||
AttachmentBoxResult({
|
||||
required this.success,
|
||||
this.isDuplicate = false,
|
||||
this.errorMessage,
|
||||
this.boxItemId,
|
||||
this.paichanNo,
|
||||
this.boxNo,
|
||||
this.zongpaiNo,
|
||||
this.categoryId,
|
||||
this.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentBoxDeleteResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
|
||||
AttachmentBoxDeleteResult({required this.success, this.errorMessage});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
final http.Client _client;
|
||||
final Duration timeout;
|
||||
@@ -534,4 +700,341 @@ class ApiService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询附件类型列表 — GET /CargoTrace/attachment/categories
|
||||
Future<AttachmentCategoryResult> fetchAttachmentCategories({
|
||||
required String baseUrl,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/categories');
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
if (response.statusCode == 200) {
|
||||
final list = (jsonDecode(response.body) as List)
|
||||
.map(
|
||||
(e) => AttachmentCategoryData.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
return AttachmentCategoryResult(success: true, categories: list);
|
||||
}
|
||||
return AttachmentCategoryResult(
|
||||
success: false,
|
||||
errorMessage: '加载失败 (${response.statusCode})',
|
||||
);
|
||||
} catch (e) {
|
||||
return AttachmentCategoryResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询附件状态 — GET /CargoTrace/attachment/status
|
||||
Future<AttachmentStatusResult> fetchAttachmentStatus({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/CargoTrace/attachment/status',
|
||||
).replace(queryParameters: {'zongpai_no': zongpaiNo});
|
||||
try {
|
||||
final response = await _client
|
||||
.get(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
if (response.statusCode == 200) {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items =
|
||||
(body['items'] as List?)
|
||||
?.map(
|
||||
(e) => AttachmentStatusItemData.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return AttachmentStatusResult(
|
||||
success: true,
|
||||
determination: body['determination']?.toString() ?? 'undetermined',
|
||||
allComplete: body['all_complete'] as bool? ?? false,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
return AttachmentStatusResult(
|
||||
success: false,
|
||||
errorMessage: '查询失败 (${response.statusCode})',
|
||||
);
|
||||
} catch (e) {
|
||||
return AttachmentStatusResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 定稿附件清单 — PUT /CargoTrace/attachment/config
|
||||
Future<AttachmentConfigResult> putAttachmentConfig({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
required String determination,
|
||||
required List<AttachmentConfigItemData> items,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/config');
|
||||
try {
|
||||
final response = await _client
|
||||
.put(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'zongpai_no': zongpaiNo,
|
||||
'determination': determination,
|
||||
'items': items.map((i) => i.toJson()).toList(),
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode == 200) {
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final respItems =
|
||||
(body['items'] as List?)
|
||||
?.map(
|
||||
(e) => AttachmentStatusItemData.fromJson(
|
||||
e as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return AttachmentConfigResult(
|
||||
success: true,
|
||||
determination: body['determination']?.toString() ?? 'has',
|
||||
allComplete: body['all_complete'] as bool? ?? false,
|
||||
items: respItems,
|
||||
);
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentConfigResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '配置失败',
|
||||
errorCode: body['error_code']?.toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
return AttachmentConfigResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件上架 — POST /CargoTrace/attachment/location
|
||||
Future<AttachmentLocationResult> registerAttachmentLocation({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
required int categoryId,
|
||||
required String locationCode,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/location');
|
||||
try {
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'zongpai_no': zongpaiNo,
|
||||
'category_id': categoryId,
|
||||
'location_code': locationCode,
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentLocationResult(
|
||||
success: true,
|
||||
locationCode: body['location_code']?.toString(),
|
||||
previousLocation: body['previous_location']?.toString(),
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final errorCode = body['error_code']?.toString();
|
||||
if (errorCode == 'DUPLICATE_LOCATION') {
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
conflictInfo: body,
|
||||
);
|
||||
}
|
||||
if (errorCode == 'ALREADY_OFF_SHELF') {
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
isAlreadyOffShelf: true,
|
||||
errorMessage: body['message']?.toString(),
|
||||
conflictInfo: body,
|
||||
);
|
||||
}
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '提交失败',
|
||||
);
|
||||
default:
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
errorMessage: '提交失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AttachmentLocationResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 附件装箱 — POST /CargoTrace/attachment/box
|
||||
Future<AttachmentBoxResult> saveAttachmentBox({
|
||||
required String baseUrl,
|
||||
required String zongpaiNo,
|
||||
required int categoryId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box');
|
||||
try {
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'zongpai_no': zongpaiNo,
|
||||
'category_id': categoryId,
|
||||
'box_no': boxNo,
|
||||
'quantity': quantity,
|
||||
}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentBoxResult(
|
||||
success: true,
|
||||
boxItemId: body['box_item_id'] as int?,
|
||||
paichanNo: body['paichan_no']?.toString(),
|
||||
boxNo: body['box_no'] as int?,
|
||||
zongpaiNo: body['zongpai_no']?.toString(),
|
||||
categoryId: body['category_id'] as int?,
|
||||
quantity: body['quantity'] as int?,
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
);
|
||||
case 404:
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
errorMessage: '未找到该总排号或附件类型',
|
||||
);
|
||||
case 409:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
isDuplicate: true,
|
||||
errorMessage: body['message']?.toString(),
|
||||
paichanNo: body['paichan_no']?.toString(),
|
||||
boxNo: body['box_no'] as int?,
|
||||
);
|
||||
default:
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
errorMessage: '提交失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AttachmentBoxResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改附件装箱明细 — PATCH /CargoTrace/attachment/box/{itemId}
|
||||
Future<AttachmentBoxResult> updateAttachmentBox({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
required int boxNo,
|
||||
required int quantity,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.patch(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
|
||||
)
|
||||
.timeout(timeout);
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentBoxResult(
|
||||
success: true,
|
||||
boxItemId: body['box_item_id'] as int?,
|
||||
paichanNo: body['paichan_no']?.toString(),
|
||||
boxNo: body['box_no'] as int?,
|
||||
zongpaiNo: body['zongpai_no']?.toString(),
|
||||
categoryId: body['category_id'] as int?,
|
||||
quantity: body['quantity'] as int?,
|
||||
);
|
||||
case 400:
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
errorMessage: body['message']?.toString() ?? '请求参数错误',
|
||||
);
|
||||
case 404:
|
||||
return AttachmentBoxResult(success: false, errorMessage: '指定装箱明细不存在');
|
||||
default:
|
||||
return AttachmentBoxResult(
|
||||
success: false,
|
||||
errorMessage: '修改失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AttachmentBoxResult(success: false, errorMessage: '网络异常,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除附件装箱明细 — DELETE /CargoTrace/attachment/box/{itemId}
|
||||
Future<AttachmentBoxDeleteResult> deleteAttachmentBox({
|
||||
required String baseUrl,
|
||||
required int boxItemId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/CargoTrace/attachment/box/$boxItemId');
|
||||
try {
|
||||
final response = await _client
|
||||
.delete(uri, headers: {'Content-Type': 'application/json'})
|
||||
.timeout(timeout);
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return AttachmentBoxDeleteResult(success: true);
|
||||
case 404:
|
||||
return AttachmentBoxDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '指定装箱明细不存在',
|
||||
);
|
||||
default:
|
||||
return AttachmentBoxDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '删除失败 (${response.statusCode})',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return AttachmentBoxDeleteResult(
|
||||
success: false,
|
||||
errorMessage: '网络异常,请检查网络连接',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// 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 final _attachmentRegex = RegExp(
|
||||
r'^FJ-(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$',
|
||||
);
|
||||
|
||||
static ParseResult parse(String code) {
|
||||
@@ -16,6 +13,10 @@ class CodeParser {
|
||||
return ParseResult(type: CodeType.invalid, value: code);
|
||||
}
|
||||
final normalized = trimmed.toUpperCase();
|
||||
if (_attachmentRegex.hasMatch(normalized)) {
|
||||
final zongpai = normalized.substring(3); // strip "FJ-"
|
||||
return ParseResult(type: CodeType.attachmentCode, value: zongpai);
|
||||
}
|
||||
if (_transitRegex.hasMatch(normalized)) {
|
||||
return ParseResult(type: CodeType.locationTransit, value: normalized);
|
||||
}
|
||||
@@ -23,16 +24,31 @@ 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,
|
||||
attachmentCode,
|
||||
locationNormal,
|
||||
locationTransit,
|
||||
locationTempStorage,
|
||||
invalid,
|
||||
}
|
||||
|
||||
class ParseResult {
|
||||
final CodeType type;
|
||||
|
||||
@@ -8,13 +8,20 @@ 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 isMultiCode,
|
||||
required int zongpaiCount,
|
||||
}) {
|
||||
if (!isTransitTarget) {
|
||||
return isMultiCode && zongpaiCount > 1 ? '批量上架($zongpaiCount 条)' : '确 认 上 架';
|
||||
return isMultiCode && zongpaiCount > 1
|
||||
? '批量上架($zongpaiCount 条)'
|
||||
: '确 认 上 架';
|
||||
}
|
||||
return isMultiCode && zongpaiCount > 1 ? '批量转运并凑箱($zongpaiCount 条)' : '转运并装箱';
|
||||
}
|
||||
|
||||
@@ -372,6 +372,248 @@ void main() {
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
|
||||
group('ApiService attachment APIs', () {
|
||||
test('fetchAttachmentCategories parses list', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{'id': 1, 'name': '检验证书', 'sort_order': 1},
|
||||
{'id': 2, 'name': '增发', 'sort_order': 2},
|
||||
]),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchAttachmentCategories(
|
||||
baseUrl: 'http://localhost',
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.categories.length, 2);
|
||||
expect(result.categories.first.name, '检验证书');
|
||||
});
|
||||
|
||||
test('fetchAttachmentCategories handles network failure', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
throw Exception('Connection refused');
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchAttachmentCategories(
|
||||
baseUrl: 'http://localhost',
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.errorMessage, '网络异常,请检查网络连接');
|
||||
});
|
||||
|
||||
test('fetchAttachmentStatus parses undetermined zongpai', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26BW0011',
|
||||
'determination': 'undetermined',
|
||||
'all_complete': false,
|
||||
'items': [],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.fetchAttachmentStatus(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.determination, 'undetermined');
|
||||
expect(result.items, isEmpty);
|
||||
});
|
||||
|
||||
test('putAttachmentConfig sends full payload', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26BW0011',
|
||||
'determination': 'has',
|
||||
'all_complete': false,
|
||||
'items': [
|
||||
{
|
||||
'category_id': 1,
|
||||
'name': '检验证书',
|
||||
'expected_qty': 80,
|
||||
'boxed_qty': 0,
|
||||
'location_code': null,
|
||||
'complete': false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.putAttachmentConfig(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
determination: 'has',
|
||||
items: [AttachmentConfigItemData(categoryId: 1, expectedQty: 80)],
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.items.single.complete, isFalse);
|
||||
expect(body['items'], [
|
||||
{'category_id': 1, 'expected_qty': 80},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerAttachmentLocation returns 200 with data', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'zongpai_no': '26BW0011',
|
||||
'category_id': 1,
|
||||
'location_code': 'A01-01-01',
|
||||
'created_at': '2026-08-12T10:00:00',
|
||||
'previous_location': null,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.registerAttachmentLocation(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
categoryId: 1,
|
||||
locationCode: 'A01-01-01',
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.locationCode, 'A01-01-01');
|
||||
});
|
||||
|
||||
test('saveAttachmentBox sends request with category_id', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 1,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 10,
|
||||
'zongpai_no': '26BW0011',
|
||||
'category_id': 1,
|
||||
'quantity': 30,
|
||||
'created_at': '2026-08-12T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.saveAttachmentBox(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
categoryId: 1,
|
||||
boxNo: 10,
|
||||
quantity: 30,
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(result.boxItemId, 1);
|
||||
expect(body['category_id'], 1);
|
||||
});
|
||||
|
||||
test('saveAttachmentBox returns duplicate on 409', () async {
|
||||
final mockClient = _MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'DUPLICATE_BOX_NO',
|
||||
'message': '排产号 W00009 下箱号 10 已存在',
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 10,
|
||||
}),
|
||||
409,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.saveAttachmentBox(
|
||||
baseUrl: 'http://localhost',
|
||||
zongpaiNo: '26BW0011',
|
||||
categoryId: 1,
|
||||
boxNo: 10,
|
||||
quantity: 30,
|
||||
);
|
||||
expect(result.success, isFalse);
|
||||
expect(result.isDuplicate, isTrue);
|
||||
});
|
||||
|
||||
test('updateAttachmentBox sends PATCH with box_no and quantity', () async {
|
||||
late Map<String, dynamic> body;
|
||||
final mockClient = _MockClient((request) async {
|
||||
final req = request as http.Request;
|
||||
body = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'box_item_id': 1,
|
||||
'paichan_no': 'W00009',
|
||||
'box_no': 11,
|
||||
'zongpai_no': '26BW0011',
|
||||
'category_id': 1,
|
||||
'quantity': 20,
|
||||
'updated_at': '2026-08-12T10:00:00',
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final result = await svc.updateAttachmentBox(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 1,
|
||||
boxNo: 11,
|
||||
quantity: 20,
|
||||
);
|
||||
expect(result.success, isTrue);
|
||||
expect(body, {'box_no': 11, 'quantity': 20});
|
||||
});
|
||||
|
||||
test('deleteAttachmentBox handles success and not found', () async {
|
||||
var calls = 0;
|
||||
final mockClient = _MockClient((request) async {
|
||||
calls += 1;
|
||||
if (calls == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({'box_item_id': 1, 'deleted': true}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'error_code': 'BOX_ITEM_NOT_FOUND',
|
||||
'message': '指定装箱明细不存在',
|
||||
}),
|
||||
404,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
});
|
||||
final svc = ApiService(client: mockClient);
|
||||
final ok = await svc.deleteAttachmentBox(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 1,
|
||||
);
|
||||
final missing = await svc.deleteAttachmentBox(
|
||||
baseUrl: 'http://localhost',
|
||||
boxItemId: 999,
|
||||
);
|
||||
expect(ok.success, isTrue);
|
||||
expect(missing.success, isFalse);
|
||||
expect(missing.errorMessage, '指定装箱明细不存在');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockClient extends http.BaseClient {
|
||||
|
||||
@@ -62,4 +62,50 @@ void main() {
|
||||
expect(CodeParser.isLocation(CodeType.invalid), false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CodeParser.parse attachmentCode (FJ-)', () {
|
||||
test('identifies FJ- with zongpai B type', () {
|
||||
final result = CodeParser.parse('FJ-26B1');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26B1');
|
||||
});
|
||||
test('identifies FJ- with zongpai C type', () {
|
||||
final result = CodeParser.parse('FJ-26C12');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26C12');
|
||||
});
|
||||
test('identifies FJ- with zongpai T type', () {
|
||||
final result = CodeParser.parse('FJ-26T3');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26T3');
|
||||
});
|
||||
test('identifies FJ- with zongpai BW 4-digit', () {
|
||||
final result = CodeParser.parse('FJ-26BW0001');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26BW0001');
|
||||
});
|
||||
test('identifies FJ- with zongpai CW 4-digit', () {
|
||||
final result = CodeParser.parse('FJ-26CW0015');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26CW0015');
|
||||
});
|
||||
test('normalizes lowercase fj- to uppercase', () {
|
||||
final result = CodeParser.parse('fj-26b1');
|
||||
expect(result.type, CodeType.attachmentCode);
|
||||
expect(result.value, '26B1');
|
||||
});
|
||||
test('rejects FJ without hyphen', () {
|
||||
expect(CodeParser.parse('FJ26B1').type, CodeType.invalid);
|
||||
});
|
||||
test('rejects FJ- with invalid zongpai', () {
|
||||
expect(CodeParser.parse('FJ-HELLO').type, CodeType.invalid);
|
||||
});
|
||||
test('rejects FJ- with BW short serial', () {
|
||||
expect(CodeParser.parse('FJ-26BW01').type, CodeType.invalid);
|
||||
});
|
||||
test('attaches original raw code with FJ- prefix for display', () {
|
||||
final result = CodeParser.parse('FJ-26B42360');
|
||||
expect(result.value, '26B42360');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user