Compare commits

..

5 Commits

Author SHA1 Message Date
Misaka_Company
cf974cee92 feat(android): add attachment pending banner on boxing page
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 15:06:15 +08:00
Misaka_Company
15c29bceb8 feat(android): add attachment boxing FJ- scan and submit
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 15:02:00 +08:00
Misaka_Company
6b05d6c0e3 feat(android): add attachment config dialog and FJ- scan in registration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:56:28 +08:00
Misaka_Company
b7484915c6 feat(android): add attachment API methods and result classes
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:51:41 +08:00
Misaka_Company
b383328aed feat(android): add FJ- attachment code type to CodeParser
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:48:23 +08:00
16 changed files with 1689 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(
@@ -66,6 +71,13 @@ extension _BoxingScanPart on _BoxingPageState {
: FeedbackEvent.scanValid, : 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(() { setState(() {
_zongpaiNo = zongpai; _zongpaiNo = zongpai;
_paichanNo = result.paichanNo; _paichanNo = result.paichanNo;
@@ -82,6 +94,8 @@ extension _BoxingScanPart on _BoxingPageState {
_deletingAssignedItemId = null; _deletingAssignedItemId = null;
_completedJumpBoxNo = null; _completedJumpBoxNo = null;
_statusOverrideText = null; _statusOverrideText = null;
_attachmentPending = pending;
_attachmentPendingCount = pendingCount;
if (_mode == BoxingMode.singleCode) { if (_mode == BoxingMode.singleCode) {
_paichanSwitchNotice = null; _paichanSwitchNotice = null;
} }
@@ -92,6 +106,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

@@ -12,6 +12,8 @@ class BoxingNoticeBanners extends StatelessWidget {
final int remainingQuantity; final int remainingQuantity;
final String? zongpaiNo; final String? zongpaiNo;
final int? completedJumpBoxNo; final int? completedJumpBoxNo;
final bool attachmentPending;
final int attachmentPendingCount;
final VoidCallback onJumpToCompletedBox; final VoidCallback onJumpToCompletedBox;
const BoxingNoticeBanners({ const BoxingNoticeBanners({
@@ -26,6 +28,8 @@ class BoxingNoticeBanners extends StatelessWidget {
required this.remainingQuantity, required this.remainingQuantity,
required this.zongpaiNo, required this.zongpaiNo,
required this.completedJumpBoxNo, required this.completedJumpBoxNo,
this.attachmentPending = false,
this.attachmentPendingCount = 0,
required this.onJumpToCompletedBox, 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) { if (isDuplicateBoxNo && phase == BoxingPhase.scanned) {
banners.add( banners.add(
_NoticeBanner( _NoticeBanner(

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,19 @@ 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;
/// Whether to show the attachment-pending banner.
bool _attachmentPending = false;
int _attachmentPendingCount = 0;
@override @override
void initState() { void initState() {
super.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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
@@ -244,6 +265,12 @@ 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;
_attachmentPending = false;
_attachmentPendingCount = 0;
} }
// === 重复箱号检测 === // === 重复箱号检测 ===
@@ -367,6 +394,8 @@ class _BoxingPageState extends State<BoxingPage> with WidgetsBindingObserver {
remainingQuantity: _remainingQuantity, remainingQuantity: _remainingQuantity,
zongpaiNo: _zongpaiNo, zongpaiNo: _zongpaiNo,
completedJumpBoxNo: _completedJumpBoxNo, completedJumpBoxNo: _completedJumpBoxNo,
attachmentPending: _attachmentPending,
attachmentPendingCount: _attachmentPendingCount,
onJumpToCompletedBox: () => this._jumpToCompletedBox(), onJumpToCompletedBox: () => this._jumpToCompletedBox(),
), ),

View File

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

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

View 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;
}
}
}

View File

@@ -46,6 +46,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
switch (parsed.type) { switch (parsed.type) {
case CodeType.zongpaiNo: case CodeType.zongpaiNo:
_handleZongpaiScan(parsed.value); _handleZongpaiScan(parsed.value);
case CodeType.attachmentCode:
_handleAttachmentScan(parsed.value);
case CodeType.locationNormal: case CodeType.locationNormal:
case CodeType.locationTransit: case CodeType.locationTransit:
case CodeType.locationTempStorage: case CodeType.locationTempStorage:
@@ -115,6 +117,8 @@ extension _RegistrationScanPart on _RegistrationPageState {
if (shouldRefresh) { if (shouldRefresh) {
_loadOverview(zongpaiNo); _loadOverview(zongpaiNo);
} }
// Trigger attachment config check in background (modal dialog if needed)
unawaited(_checkAndConfigureAttachment(zongpaiNo));
} }
void _triggerDoubleVibration() { void _triggerDoubleVibration() {

View File

@@ -110,6 +110,38 @@ extension _RegistrationSubmitPart on _RegistrationPageState {
} }
Future<void> _submitOne(String baseUrl, String zongpaiNo) async { 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( final result = await _apiService.registerLocation(
baseUrl: baseUrl, baseUrl: baseUrl,
zongpaiNo: zongpaiNo, zongpaiNo: zongpaiNo,

View File

@@ -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/boxing/widgets/boxing_shared_widgets.dart';
import 'package:pad_scanner/pages/registration/registration_calculations.dart' import 'package:pad_scanner/pages/registration/registration_calculations.dart'
as registration_calculations; 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_scan_part.dart';
part 'registration/parts/registration_status_part.dart'; part 'registration/parts/registration_status_part.dart';
part 'registration/parts/registration_overview_part.dart'; part 'registration/parts/registration_overview_part.dart';
part 'registration/parts/registration_submit_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/parts/registration_mode_part.dart';
part 'registration/dialogs/registration_dialogs_part.dart'; part 'registration/dialogs/registration_dialogs_part.dart';
part 'registration/widgets/registration_widgets_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. /// Whether a cross-paicha warning dialog is currently showing.
bool _crossPaichaPending = false; 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 @override
void initState() { void initState() {
super.initState(); super.initState();

View File

@@ -195,6 +195,7 @@ class BoxInfoResult {
final List<BoxDetailData> existingBoxes; final List<BoxDetailData> existingBoxes;
final int maxBoxNo; final int maxBoxNo;
final int suggestedBoxNo; final int suggestedBoxNo;
final Map<String, dynamic>? attachmentSummary;
BoxInfoResult({ BoxInfoResult({
required this.success, required this.success,
@@ -207,6 +208,7 @@ class BoxInfoResult {
this.existingBoxes = const [], this.existingBoxes = const [],
this.maxBoxNo = 0, this.maxBoxNo = 0,
this.suggestedBoxNo = 1, this.suggestedBoxNo = 1,
this.attachmentSummary,
}); });
factory BoxInfoResult.ok(Map<String, dynamic> json) { factory BoxInfoResult.ok(Map<String, dynamic> json) {
@@ -232,6 +234,7 @@ class BoxInfoResult {
existingBoxes: boxes, existingBoxes: boxes,
maxBoxNo: json['max_box_no'] as int? ?? 0, maxBoxNo: json['max_box_no'] as int? ?? 0,
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, 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}); 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 { class ApiService {
final http.Client _client; final http.Client _client;
final Duration timeout; final Duration timeout;
@@ -534,4 +700,341 @@ class ApiService {
return false; 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: '网络异常,请检查网络连接',
);
}
}
} }

View File

@@ -3,6 +3,9 @@ class CodeParser {
static final _zongpaiRegex = RegExp(r'^\d{2}(B|C|T)\d+$|^\d{2}(BW|CW)\d{4}$'); 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 _locationRegex = RegExp(r'^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$');
static final _transitRegex = RegExp(r'^TRANS-'); 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) { static ParseResult parse(String code) {
final trimmed = code.trim(); final trimmed = code.trim();
@@ -10,6 +13,10 @@ class CodeParser {
return ParseResult(type: CodeType.invalid, value: code); return ParseResult(type: CodeType.invalid, value: code);
} }
final normalized = trimmed.toUpperCase(); 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)) { if (_transitRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.locationTransit, value: normalized); return ParseResult(type: CodeType.locationTransit, value: normalized);
} }
@@ -36,6 +43,7 @@ class CodeParser {
enum CodeType { enum CodeType {
zongpaiNo, zongpaiNo,
attachmentCode,
locationNormal, locationNormal,
locationTransit, locationTransit,
locationTempStorage, locationTempStorage,

View File

@@ -372,6 +372,248 @@ void main() {
expect(missing.errorMessage, '指定装箱明细不存在'); 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 { class _MockClient extends http.BaseClient {

View File

@@ -62,4 +62,50 @@ void main() {
expect(CodeParser.isLocation(CodeType.invalid), false); 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');
});
});
} }