diff --git a/lib/pages/boxing/boxing_api_actions.dart b/lib/pages/boxing/boxing_api_actions.dart index 7e397ed..ae290c2 100644 --- a/lib/pages/boxing/boxing_api_actions.dart +++ b/lib/pages/boxing/boxing_api_actions.dart @@ -65,6 +65,8 @@ class BoxingApiActions { required String zongpaiNo, required int boxNo, required int quantity, + String itemType = 'product', + int? accessoryId, }) async { final baseUrl = await _requireBaseUrl(); if (baseUrl == null) return _missingApiUrl(); @@ -74,6 +76,8 @@ class BoxingApiActions { zongpaiNo: zongpaiNo, boxNo: boxNo, quantity: quantity, + itemType: itemType, + accessoryId: accessoryId, ); return _saveResult(result, fallbackMessage: '提交失败'); } diff --git a/lib/pages/boxing/parts/boxing_scan_part.dart b/lib/pages/boxing/parts/boxing_scan_part.dart index 602e22a..e33062b 100644 --- a/lib/pages/boxing/parts/boxing_scan_part.dart +++ b/lib/pages/boxing/parts/boxing_scan_part.dart @@ -74,6 +74,7 @@ extension _BoxingScanPart on _BoxingPageState { _currentZongpaiBoxes = result.currentZongpaiBoxes; _existingBoxes = result.existingBoxes; _maxBoxNo = result.maxBoxNo; + _pendingAccessories = result.pendingAccessories; _phase = BoxingPhase.scanned; _isDuplicateBoxNo = false; _editingBox = null; diff --git a/lib/pages/boxing/parts/boxing_submit_part.dart b/lib/pages/boxing/parts/boxing_submit_part.dart index 0e541af..101396e 100644 --- a/lib/pages/boxing/parts/boxing_submit_part.dart +++ b/lib/pages/boxing/parts/boxing_submit_part.dart @@ -158,4 +158,47 @@ extension _BoxingSubmitPart on _BoxingPageState { const Duration(milliseconds: 1500), ); } + + Future _submitAccessory(PendingAccessory accessory) async { + if (_zongpaiNo == null || _isSubmitting) return; + + final boxNo = int.tryParse(_boxNoController.text); + if (boxNo == null || boxNo <= 0) { + this._showStatusOverride( + '请先输入有效箱号', + StatusDotColor.red, + const Duration(seconds: 2), + ); + return; + } + + setState(() => _isSubmitting = true); + + final action = await _apiActions.saveBoxRecord( + zongpaiNo: _zongpaiNo!, + boxNo: boxNo, + quantity: accessory.quantity, + itemType: 'accessory', + accessoryId: accessory.accessoryId, + ); + + if (!mounted) return; + + setState(() => _isSubmitting = false); + + if (action.success) { + _feedbackService.trigger(FeedbackEvent.submitSuccess); + // Refresh box info to update pending accessories + await this._queryBoxInfo(_zongpaiNo!); + if (mounted) { + this._showStatusOverride( + '${accessory.accessoryType} 已加入箱号 $boxNo', + StatusDotColor.green, + const Duration(milliseconds: 1500), + ); + } + } else { + this._showActionError(action, fallback: '附件装箱失败'); + } + } } diff --git a/lib/pages/boxing/widgets/multi_code_body.dart b/lib/pages/boxing/widgets/multi_code_body.dart index a116b4d..a6584c6 100644 --- a/lib/pages/boxing/widgets/multi_code_body.dart +++ b/lib/pages/boxing/widgets/multi_code_body.dart @@ -36,6 +36,8 @@ class MultiCodeBody extends StatelessWidget { final ValueChanged onStartDeletePackedItem; final ValueChanged onDeletePackedItem; final VoidCallback onCancelDeletePackedItem; + final List pendingAccessories; + final ValueChanged onSubmitAccessory; const MultiCodeBody({ super.key, @@ -69,6 +71,8 @@ class MultiCodeBody extends StatelessWidget { required this.onStartDeletePackedItem, required this.onDeletePackedItem, required this.onCancelDeletePackedItem, + required this.pendingAccessories, + required this.onSubmitAccessory, }); @override @@ -87,7 +91,7 @@ class MultiCodeBody extends StatelessWidget { ), const _MultiListHeader(), Expanded( - child: visibleItems.isEmpty + child: visibleItems.isEmpty && pendingAccessories.isEmpty ? Center( child: Text( '尚未装入任何物料', @@ -99,22 +103,32 @@ class MultiCodeBody extends StatelessWidget { ), ) : ListView.builder( - itemCount: visibleItems.length, + itemCount: visibleItems.length + pendingAccessories.length, itemBuilder: (context, index) { - final item = visibleItems[index]; - return MultiPackedRow( - item: item, - editing: editingPackedItemId == item.boxItemId, - deleting: deletingPackedItemId == item.boxItemId, + if (index < visibleItems.length) { + final item = visibleItems[index]; + return MultiPackedRow( + item: item, + editing: editingPackedItemId == item.boxItemId, + deleting: deletingPackedItemId == item.boxItemId, + isSubmitting: isSubmitting, + editingQuantity: editingPackedQuantity, + onEditingQuantityChanged: + onEditingPackedQuantityChanged, + onSave: () => onSavePackedItem(item), + onCancelEdit: onCancelEditPackedItem, + onStartEdit: () => onStartEditPackedItem(item), + onStartDelete: () => onStartDeletePackedItem(item), + onDelete: () => onDeletePackedItem(item), + onCancelDelete: onCancelDeletePackedItem, + ); + } + final accIndex = index - visibleItems.length; + final acc = pendingAccessories[accIndex]; + return _MultiAccessoryRow( + accessory: acc, isSubmitting: isSubmitting, - editingQuantity: editingPackedQuantity, - onEditingQuantityChanged: onEditingPackedQuantityChanged, - onSave: () => onSavePackedItem(item), - onCancelEdit: onCancelEditPackedItem, - onStartEdit: () => onStartEditPackedItem(item), - onStartDelete: () => onStartDeletePackedItem(item), - onDelete: () => onDeletePackedItem(item), - onCancelDelete: onCancelDeletePackedItem, + onSubmit: () => onSubmitAccessory(acc), ); }, ), @@ -604,3 +618,72 @@ class _MultiSubmitRow extends StatelessWidget { ); } } + +class _MultiAccessoryRow extends StatelessWidget { + final PendingAccessory accessory; + final bool isSubmitting; + final VoidCallback onSubmit; + + const _MultiAccessoryRow({ + required this.accessory, + required this.isSubmitting, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + children: [ + Icon(Icons.build, size: 14, color: Colors.orange.shade600), + const SizedBox(width: 6), + Expanded( + flex: 40, + child: Text( + accessory.accessoryType, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 20, + child: Text( + '\u00d7${accessory.quantity}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + ), + SizedBox( + height: 28, + child: ElevatedButton( + onPressed: isSubmitting ? null : onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: isSubmitting + ? Colors.grey.shade300 + : Colors.orange.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: const Text( + '加入本箱', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/boxing/widgets/single_code_body.dart b/lib/pages/boxing/widgets/single_code_body.dart index 490d96b..a9c9def 100644 --- a/lib/pages/boxing/widgets/single_code_body.dart +++ b/lib/pages/boxing/widgets/single_code_body.dart @@ -38,6 +38,8 @@ class SingleCodeBody extends StatelessWidget { final ValueChanged onStartDeleteAssigned; final ValueChanged onDeleteAssignedItem; final VoidCallback onCancelDeleteAssigned; + final List pendingAccessories; + final ValueChanged onSubmitAccessory; const SingleCodeBody({ super.key, @@ -74,6 +76,8 @@ class SingleCodeBody extends StatelessWidget { required this.onStartDeleteAssigned, required this.onDeleteAssignedItem, required this.onCancelDeleteAssigned, + required this.pendingAccessories, + required this.onSubmitAccessory, }); @override @@ -114,6 +118,12 @@ class SingleCodeBody extends StatelessWidget { onDeleteAssignedItem: onDeleteAssignedItem, onCancelDeleteAssigned: onCancelDeleteAssigned, ), + if (pendingAccessories.isNotEmpty && !isWaiting) + _PendingAccessoriesSection( + pendingAccessories: pendingAccessories, + isSubmitting: isSubmitting, + onSubmitAccessory: onSubmitAccessory, + ), _SingleBottomArea( disabled: isWaiting || isFinished, boxNoController: boxNoController, @@ -239,9 +249,7 @@ class _SingleInfoBar extends StatelessWidget { style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, - color: isWaiting - ? Colors.grey.shade400 - : Colors.black87, + color: isWaiting ? Colors.grey.shade400 : Colors.black87, ), overflow: TextOverflow.ellipsis, ), @@ -252,9 +260,7 @@ class _SingleInfoBar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - isWaiting - ? '已有 -- 箱' - : '已有 ${existingBoxes.length} 箱', + isWaiting ? '已有 -- 箱' : '已有 ${existingBoxes.length} 箱', style: TextStyle( fontSize: 12, color: isWaiting @@ -263,9 +269,7 @@ class _SingleInfoBar extends StatelessWidget { ), ), Text( - isWaiting - ? '最大箱号 --' - : '最大箱号 $maxBoxNo', + isWaiting ? '最大箱号 --' : '最大箱号 $maxBoxNo', style: TextStyle( fontSize: 12, color: isWaiting @@ -576,3 +580,120 @@ class _SingleBottomArea extends StatelessWidget { ); } } + +class _PendingAccessoriesSection extends StatelessWidget { + final List pendingAccessories; + final bool isSubmitting; + final ValueChanged onSubmitAccessory; + + const _PendingAccessoriesSection({ + required this.pendingAccessories, + required this.isSubmitting, + required this.onSubmitAccessory, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + border: Border(bottom: BorderSide(color: Colors.orange.shade200)), + ), + child: Row( + children: [ + Icon(Icons.build, size: 14, color: Colors.orange.shade700), + const SizedBox(width: 6), + Text( + '待装箱附件(${pendingAccessories.length} 项)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.orange.shade800, + ), + ), + ], + ), + ), + ...pendingAccessories.map( + (acc) => _AccessoryRow( + accessory: acc, + isSubmitting: isSubmitting, + onSubmit: () => onSubmitAccessory(acc), + ), + ), + ], + ); + } +} + +class _AccessoryRow extends StatelessWidget { + final PendingAccessory accessory; + final bool isSubmitting; + final VoidCallback onSubmit; + + const _AccessoryRow({ + required this.accessory, + required this.isSubmitting, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.orange.shade50, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + children: [ + Expanded( + flex: 40, + child: Text( + accessory.accessoryType, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 20, + child: Text( + '\u00d7${accessory.quantity}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + ), + SizedBox( + height: 28, + child: ElevatedButton( + onPressed: isSubmitting ? null : onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: isSubmitting + ? Colors.grey.shade300 + : Colors.orange.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: const Text( + '加入本箱', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/boxing_detail_page.dart b/lib/pages/boxing_detail_page.dart index 2289f85..f33a58c 100644 --- a/lib/pages/boxing_detail_page.dart +++ b/lib/pages/boxing_detail_page.dart @@ -103,15 +103,40 @@ class BoxingDetailPage extends StatelessWidget { ), child: Column( children: box.items.map((item) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + final isAccessory = item.itemType == 'accessory'; + return Container( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4, + ), + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: isAccessory + ? BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(4), + ) + : null, child: Row( children: [ + if (isAccessory) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + Icons.build, + size: 14, + color: Colors.orange.shade700, + ), + ), Expanded( flex: 3, child: Text( item.zongpaiNo, - style: const TextStyle(fontSize: 14), + style: TextStyle( + fontSize: 14, + fontWeight: isAccessory + ? FontWeight.w600 + : FontWeight.normal, + ), overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/pages/boxing_page.dart b/lib/pages/boxing_page.dart index 173260b..41611fb 100644 --- a/lib/pages/boxing_page.dart +++ b/lib/pages/boxing_page.dart @@ -107,6 +107,9 @@ class _BoxingPageState extends State with WidgetsBindingObserver { // 多码凑箱:已完成装箱时可跳转的箱号 int? _completedJumpBoxNo; + // 待装箱附件 + List _pendingAccessories = []; + @override void initState() { super.initState(); @@ -244,6 +247,7 @@ class _BoxingPageState extends State with WidgetsBindingObserver { _isDuplicateBoxNo = false; _statusOverrideText = null; _statusOverrideDot = null; + _pendingAccessories = []; } // === 重复箱号检测 === @@ -415,6 +419,9 @@ class _BoxingPageState extends State with WidgetsBindingObserver { this._deletePackedItem(item), onCancelDeletePackedItem: () => setState(() => _deletingPackedItemId = null), + pendingAccessories: _pendingAccessories, + onSubmitAccessory: (accessory) => + this._submitAccessory(accessory), ) : SingleCodeBody( isWaiting: @@ -465,6 +472,9 @@ class _BoxingPageState extends State with WidgetsBindingObserver { this._deleteAssignedItem(item), onCancelDeleteAssigned: () => setState(() => _deletingAssignedItemId = null), + pendingAccessories: _pendingAccessories, + onSubmitAccessory: (accessory) => + this._submitAccessory(accessory), ), ), diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 3d4f8d5..ba82cbc 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -118,6 +118,30 @@ class PaichaOverviewResult { // === 装箱模块数据类 === +/// 待装箱附件 +class PendingAccessory { + final int accessoryId; + final String accessoryType; + final int quantity; + final String? locationCode; + + PendingAccessory({ + required this.accessoryId, + required this.accessoryType, + required this.quantity, + this.locationCode, + }); + + factory PendingAccessory.fromJson(Map json) { + return PendingAccessory( + accessoryId: json['accessory_id'] as int, + accessoryType: json['accessory_type'] as String, + quantity: json['quantity'] as int, + locationCode: json['location_code']?.toString(), + ); + } +} + /// 箱号内单个总排号明细 class BoxItemData { final int? boxItemId; @@ -125,6 +149,7 @@ class BoxItemData { final String? workOrderNo; final int quantity; final int? totalQuantity; + final String itemType; BoxItemData({ this.boxItemId, @@ -132,6 +157,7 @@ class BoxItemData { this.workOrderNo, required this.quantity, this.totalQuantity, + this.itemType = 'product', }); factory BoxItemData.fromJson(Map json) { @@ -141,6 +167,7 @@ class BoxItemData { workOrderNo: json['work_order_no']?.toString(), quantity: json['quantity'] as int, totalQuantity: json['total_quantity'] as int?, + itemType: json['item_type'] as String? ?? 'product', ); } } @@ -167,11 +194,13 @@ class CurrentZongpaiBoxData { final int boxItemId; final int boxNo; final int quantity; + final String itemType; CurrentZongpaiBoxData({ required this.boxItemId, required this.boxNo, required this.quantity, + this.itemType = 'product', }); factory CurrentZongpaiBoxData.fromJson(Map json) { @@ -179,6 +208,7 @@ class CurrentZongpaiBoxData { boxItemId: json['box_item_id'] as int, boxNo: json['box_no'] as int, quantity: json['quantity'] as int, + itemType: json['item_type'] as String? ?? 'product', ); } } @@ -195,7 +225,7 @@ class BoxInfoResult { final List existingBoxes; final int maxBoxNo; final int suggestedBoxNo; - final int pendingAccessories; + final List pendingAccessories; BoxInfoResult({ required this.success, @@ -208,7 +238,7 @@ class BoxInfoResult { this.existingBoxes = const [], this.maxBoxNo = 0, this.suggestedBoxNo = 1, - this.pendingAccessories = 0, + this.pendingAccessories = const [], }); factory BoxInfoResult.ok(Map json) { @@ -224,6 +254,11 @@ class BoxInfoResult { ) .toList() ?? []; + final pendingAccessories = + (json['pending_accessories'] as List?) + ?.map((a) => PendingAccessory.fromJson(a as Map)) + .toList() ?? + []; return BoxInfoResult( success: true, zongpaiNo: json['zongpai_no'] as String?, @@ -234,7 +269,7 @@ class BoxInfoResult { existingBoxes: boxes, maxBoxNo: json['max_box_no'] as int? ?? 0, suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, - pendingAccessories: json['pending_accessories'] as int? ?? 0, + pendingAccessories: pendingAccessories, ); } @@ -583,18 +618,25 @@ class ApiService { required String zongpaiNo, required int boxNo, required int quantity, + String itemType = 'product', + int? accessoryId, }) async { final uri = Uri.parse('$baseUrl/CargoTrace/box'); try { + final bodyMap = { + 'zongpai_no': zongpaiNo, + 'box_no': boxNo, + 'quantity': quantity, + 'item_type': itemType, + }; + if (accessoryId != null) { + bodyMap['accessory_id'] = accessoryId; + } final response = await _client .post( uri, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'zongpai_no': zongpaiNo, - 'box_no': boxNo, - 'quantity': quantity, - }), + body: jsonEncode(bodyMap), ) .timeout(timeout);