feat(android): add attachment boxing FJ- scan and submit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-08-12 15:02:00 +08:00
parent 6b05d6c0e3
commit 15c29bceb8
5 changed files with 351 additions and 0 deletions

View File

@@ -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;

View 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('确认'),
),
],
);
}
}

View File

@@ -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(() {

View File

@@ -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);

View File

@@ -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;
} }
// === 重复箱号检测 === // === 重复箱号检测 ===