feat(android): add attachment boxing FJ- scan and submit
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
Future<String?> _requireBaseUrl() async {
|
||||||
final baseUrl = await _loadApiUrl() ?? '';
|
final baseUrl = await _loadApiUrl() ?? '';
|
||||||
if (baseUrl.isEmpty) return null;
|
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);
|
final parsed = CodeParser.parse(result.barcode);
|
||||||
|
|
||||||
|
if (parsed.type == CodeType.attachmentCode) {
|
||||||
|
this._handleAttachmentBoxScan(parsed.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.type != CodeType.zongpaiNo) {
|
if (parsed.type != CodeType.zongpaiNo) {
|
||||||
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
_feedbackService.trigger(FeedbackEvent.scanInvalid);
|
||||||
this._showStatusOverride(
|
this._showStatusOverride(
|
||||||
@@ -92,6 +97,119 @@ extension _BoxingScanPart on _BoxingPageState {
|
|||||||
return true;
|
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 {
|
Future<void> _processAutoScanCodes(List<String> codes) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -6,6 +6,53 @@ extension _BoxingSubmitPart on _BoxingPageState {
|
|||||||
Future<bool> _submit() async {
|
Future<bool> _submit() async {
|
||||||
if (!_canSubmit) return false;
|
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 boxNo = int.parse(_boxNoController.text);
|
||||||
final quantity = int.parse(_quantityController.text);
|
final quantity = int.parse(_quantityController.text);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:pad_scanner/services/scanner_service.dart';
|
import 'package:pad_scanner/services/scanner_service.dart';
|
||||||
import 'package:pad_scanner/services/code_parser.dart';
|
import 'package:pad_scanner/services/code_parser.dart';
|
||||||
import 'package:pad_scanner/services/api_service.dart';
|
import 'package:pad_scanner/services/api_service.dart';
|
||||||
|
import 'package:pad_scanner/services/app_config_service.dart';
|
||||||
import 'package:pad_scanner/services/boxing_context.dart';
|
import 'package:pad_scanner/services/boxing_context.dart';
|
||||||
import 'package:pad_scanner/services/feedback_service.dart';
|
import 'package:pad_scanner/services/feedback_service.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/boxing_api_actions.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;
|
as boxing_calculations;
|
||||||
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
|
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/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/dialogs/cross_paichan_dialog.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
import 'package:pad_scanner/pages/boxing/widgets/boxing_shared_widgets.dart';
|
||||||
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
|
import 'package:pad_scanner/pages/boxing/widgets/boxing_notice_banners.dart';
|
||||||
@@ -107,6 +109,15 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
// 多码凑箱:已完成装箱时可跳转的箱号
|
// 多码凑箱:已完成装箱时可跳转的箱号
|
||||||
int? _completedJumpBoxNo;
|
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;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -148,6 +159,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
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
@@ -244,6 +261,10 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
|
|||||||
_isDuplicateBoxNo = false;
|
_isDuplicateBoxNo = false;
|
||||||
_statusOverrideText = null;
|
_statusOverrideText = null;
|
||||||
_statusOverrideDot = null;
|
_statusOverrideDot = null;
|
||||||
|
_attachmentCategoryId = null;
|
||||||
|
_attachmentCategoryName = null;
|
||||||
|
_attachmentExpectedQty = null;
|
||||||
|
_attachmentBoxedQty = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 重复箱号检测 ===
|
// === 重复箱号检测 ===
|
||||||
|
|||||||
Reference in New Issue
Block a user