From 51acee2705ddf0f87a72fcf24e4ca3876388bd1e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 22:17:50 +0800 Subject: [PATCH] chore: release client-v1.0.71 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-client.md | 6 + client/lib/models/stock_in.dart | 12 + client/lib/models/stock_out.dart | 11 + client/lib/providers/stock_in_provider.dart | 6 + client/lib/providers/stock_out_provider.dart | 6 + .../lib/repositories/stock_in_repository.dart | 12 + .../repositories/stock_out_repository.dart | 12 + .../stock_in/stock_in_list_screen.dart | 80 +++- .../stock_out/stock_out_list_screen.dart | 82 +++- client/lib/widgets/order_return_dialog.dart | 420 ++++++++++++++++++ client/lib/widgets/order_row_actions.dart | 7 + 11 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 client/lib/widgets/order_return_dialog.dart diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index 08937a1..cb25592 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.71] - 2026-06-21 + +### 新功能 +- 已审核单据「退单」:入库/出库列表点红色「退单」→ 弹出退单明细窗,可逐行退或一键「全部退单」,确认框显示名称/系列/规格;已退明细以红色「已退单」醒目标注,列表状态旁显示「部分退单/已退单」徽章 +- 数据导入页新增「入库单/出库单」导入(老系统单据打印格式,每个文件一张单) + ## [1.0.70] - 2026-06-21 ### 修复 diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index 5ade070..a6ffa71 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -1,11 +1,16 @@ class StockInItem { + final int? id; final int? orderId; final int productId; final double quantity; final double unitPrice; final double totalPrice; + final double returnedQuantity; // 0=未退;>=quantity 表示整行已退单 final String? batchNo; final String? productionDate; + + /// 该明细是否已退单(整行已退)。 + bool get isReturned => returnedQuantity >= quantity && quantity > 0; // Denormalized for display final String? productName; final String? productCode; @@ -17,11 +22,13 @@ class StockInItem { final String? productStorage; const StockInItem({ + this.id, this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.totalPrice, + this.returnedQuantity = 0, this.batchNo, this.productionDate, this.productName, @@ -45,6 +52,7 @@ class StockInItem { } return StockInItem( + id: json['id'] != null ? (json['id'] as num).toInt() : null, orderId: json['order_id'] != null ? (json['order_id'] as num).toInt() : null, @@ -52,6 +60,7 @@ class StockInItem { quantity: (json['quantity'] as num).toDouble(), unitPrice: (json['unit_price'] as num).toDouble(), totalPrice: (json['total_price'] as num).toDouble(), + returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0, batchNo: json['batch_no'] as String?, productionDate: json['production_date'] as String?, productName: lineOrProduct('product_name', 'name'), @@ -91,6 +100,7 @@ class StockInOrder { final int? reviewerId; final String? reviewerName; final String status; // draft | pending | approved | rejected + final String returnState; // none | partial | full(退单状态) final String? orderDate; final String? reviewedAt; final double? totalAmount; @@ -110,6 +120,7 @@ class StockInOrder { this.reviewerId, this.reviewerName, required this.status, + this.returnState = 'none', this.orderDate, this.reviewedAt, this.totalAmount, @@ -136,6 +147,7 @@ class StockInOrder { : null, reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, + returnState: json['return_state'] as String? ?? 'none', orderDate: json['order_date'] as String?, reviewedAt: json['reviewed_at'] as String?, totalAmount: json['total_amount'] != null diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart index b25fe4c..7a85d08 100644 --- a/client/lib/models/stock_out.dart +++ b/client/lib/models/stock_out.dart @@ -1,21 +1,27 @@ class StockOutItem { + final int? id; final int? orderId; final int productId; final double quantity; final double unitPrice; final double totalPrice; + final double returnedQuantity; final String? productName; final String? productCode; final String? productSeries; final String? productSpec; final String? productUnit; + bool get isReturned => returnedQuantity >= quantity && quantity > 0; + const StockOutItem({ + this.id, this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.totalPrice, + this.returnedQuantity = 0, this.productName, this.productCode, this.productSeries, @@ -34,6 +40,7 @@ class StockOutItem { } return StockOutItem( + id: json['id'] != null ? (json['id'] as num).toInt() : null, orderId: json['order_id'] != null ? (json['order_id'] as num).toInt() : null, @@ -41,6 +48,7 @@ class StockOutItem { quantity: (json['quantity'] as num).toDouble(), unitPrice: (json['unit_price'] as num).toDouble(), totalPrice: (json['total_price'] as num).toDouble(), + returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0, productName: lineOrProduct('product_name', 'name'), productCode: lineOrProduct('product_code', 'code'), productSeries: lineOrProduct('series', 'series'), @@ -63,6 +71,7 @@ class StockOutOrder { final int? reviewerId; final String? reviewerName; final String status; // draft | pending | approved | rejected + final String returnState; // none | partial | full final String? orderDate; final String? reviewedAt; final String? createdAt; @@ -83,6 +92,7 @@ class StockOutOrder { this.reviewerId, this.reviewerName, required this.status, + this.returnState = 'none', this.orderDate, this.reviewedAt, this.createdAt, @@ -110,6 +120,7 @@ class StockOutOrder { : null, reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, + returnState: json['return_state'] as String? ?? 'none', orderDate: json['order_date'] as String?, reviewedAt: json['reviewed_at'] as String?, createdAt: json['created_at'] as String?, diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index c41ff28..27378d5 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -128,4 +128,10 @@ class StockInListNotifier extends AsyncNotifier> { await ref.read(stockInRepositoryProvider).withdraw(id); reload(); } + + Future returnItems(int id, List itemIds) async { + await ref.read(stockInRepositoryProvider).returnItems(id, itemIds); + reload(); + ref.invalidate(inventoryListProvider); + } } diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index defc28c..105a93d 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -128,4 +128,10 @@ class StockOutListNotifier extends AsyncNotifier> { await ref.read(stockOutRepositoryProvider).withdraw(id); reload(); } + + Future returnItems(int id, List itemIds) async { + await ref.read(stockOutRepositoryProvider).returnItems(id, itemIds); + reload(); + ref.invalidate(inventoryListProvider); + } } diff --git a/client/lib/repositories/stock_in_repository.dart b/client/lib/repositories/stock_in_repository.dart index f0a6848..bbac6b4 100644 --- a/client/lib/repositories/stock_in_repository.dart +++ b/client/lib/repositories/stock_in_repository.dart @@ -130,4 +130,16 @@ class StockInRepository { ); } } + + /// 退单:退掉指定明细(item ids)。入库退单对应库存从库存删除。 + Future returnItems(int id, List itemIds) async { + try { + await _client.post('/stock-in/orders/$id/return', data: {'item_ids': itemIds}); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '退单失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart index f6c3c7b..eb73f06 100644 --- a/client/lib/repositories/stock_out_repository.dart +++ b/client/lib/repositories/stock_out_repository.dart @@ -130,4 +130,16 @@ class StockOutRepository { ); } } + + /// 退单:退掉指定明细(item ids)。出库退单数量加回库存。 + Future returnItems(int id, List itemIds) async { + try { + await _client.post('/stock-out/orders/$id/return', data: {'item_ids': itemIds}); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '退单失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 2b4fc6a..c665ba0 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -25,6 +25,7 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../widgets/write_guard.dart'; import '../../widgets/order_row_actions.dart'; +import '../../widgets/order_return_dialog.dart'; import '../../core/auth/auth_state.dart' show isAdminProvider, currentUserIdProvider; @@ -268,7 +269,11 @@ class _StockInListScreenState extends ConsumerState { ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-')); case 'status': - return DataCell(StatusBadge(_apiStatusToEnum(o.status))); + final rb = returnStateBadge(o.returnState); + return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ])); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); case 'operator': @@ -531,6 +536,9 @@ class _StockInListScreenState extends ConsumerState { onApprove: () => _confirmApprove(context, o), onReject: () => _confirmReject(context, o), onWithdraw: () => _confirmWithdraw(context, o), + // 入库退单:仅管理员/超管 + canReturn: ref.watch(isAdminProvider), + onReturn: () => _confirmReturn(context, o), ); } @@ -540,7 +548,13 @@ class _StockInListScreenState extends ConsumerState { onTap: () => _showDetail(context, o.id), title: Text(o.orderNo, style: const TextStyle(fontFamily: 'monospace', fontSize: 14)), - trailing: StatusBadge(_apiStatusToEnum(o.status)), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (returnStateBadge(o.returnState) != null) ...[ + const SizedBox(width: 4), + returnStateBadge(o.returnState)!, + ], + ]), fields: [ MobileCardField('供应商', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), @@ -797,6 +811,68 @@ class _StockInListScreenState extends ConsumerState { } } } + + Future _confirmReturn(BuildContext context, StockInOrder o) async { + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退单'), + content: Text('确认对已审核入库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('确认'), + ), + ], + ), + ); + if (go != true || !mounted) return; + final StockInOrder full; + try { + full = await ref.read(stockInRepositoryProvider).get(o.id); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger)); + } + return; + } + if (!mounted) return; + final lines = full.items + .where((it) => it.id != null) + .map((it) => ReturnLine( + itemId: it.id!, + code: it.productCode ?? '', + name: it.productName ?? '', + series: it.productSeries ?? '', + spec: it.productSpec ?? '', + quantity: it.quantity, + unitPrice: it.unitPrice, + totalPrice: it.totalPrice, + alreadyReturned: it.isReturned, + )) + .toList(); + await showOrderReturnDialog( + context: context, + title: '入库单退单 — ${o.orderNo}', + isOut: false, + meta: [ + ('供应商', o.partnerName ?? '-'), + ('仓库', o.warehouseName ?? '-'), + ('入库日期', o.orderDate != null && o.orderDate!.length >= 10 + ? o.orderDate!.substring(0, 10) + : (o.orderDate ?? '-')), + ], + lines: lines, + onSubmit: (ids) => + ref.read(stockInListProvider.notifier).returnItems(o.id, ids), + ); + } } // Detail dialog — fetches full order with items diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 21887bd..eaa7de6 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -23,6 +23,7 @@ import '../../providers/product_provider.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../widgets/write_guard.dart'; import '../../widgets/order_row_actions.dart'; +import '../../widgets/order_return_dialog.dart'; import '../../core/auth/auth_state.dart' show isAdminProvider, currentUserIdProvider; @@ -269,7 +270,11 @@ class _StockOutListScreenState extends ConsumerState { ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-')); case 'status': - return DataCell(StatusBadge(_apiStatusToEnum(o.status))); + final rb = returnStateBadge(o.returnState); + return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ])); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); case 'created_at': @@ -505,6 +510,11 @@ class _StockOutListScreenState extends ConsumerState { onApprove: () => _confirmApprove(context, o), onReject: () => _confirmReject(context, o), onWithdraw: () => _confirmWithdraw(context, o), + // 出库退单:管理员/超管 或 本人单 + canReturn: ref.watch(isAdminProvider) || + (o.operatorId != null && + o.operatorId == ref.watch(currentUserIdProvider)), + onReturn: () => _confirmReturn(context, o), ); } @@ -514,7 +524,13 @@ class _StockOutListScreenState extends ConsumerState { onTap: () => _showDetail(context, o.id), title: Text(o.orderNo, style: const TextStyle(fontFamily: 'monospace', fontSize: 14)), - trailing: StatusBadge(_apiStatusToEnum(o.status)), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (returnStateBadge(o.returnState) != null) ...[ + const SizedBox(width: 4), + returnStateBadge(o.returnState)!, + ], + ]), fields: [ MobileCardField('客户', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), @@ -776,6 +792,68 @@ class _StockOutListScreenState extends ConsumerState { } } } + + Future _confirmReturn(BuildContext context, StockOutOrder o) async { + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退单'), + content: Text('确认对已审核出库单「${o.orderNo}」发起退单?将打开退单明细窗口选择要退的商品。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('确认'), + ), + ], + ), + ); + if (go != true || !mounted) return; + final StockOutOrder full; + try { + full = await ref.read(stockOutRepositoryProvider).get(o.id); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载明细失败:$e'), backgroundColor: AppTheme.danger)); + } + return; + } + if (!mounted) return; + final lines = full.items + .where((it) => it.id != null) + .map((it) => ReturnLine( + itemId: it.id!, + code: it.productCode ?? '', + name: it.productName ?? '', + series: it.productSeries ?? '', + spec: it.productSpec ?? '', + quantity: it.quantity, + unitPrice: it.unitPrice, + totalPrice: it.totalPrice, + alreadyReturned: it.isReturned, + )) + .toList(); + await showOrderReturnDialog( + context: context, + title: '出库单退单 — ${o.orderNo}', + isOut: true, + meta: [ + ('客户', o.partnerName ?? '-'), + ('仓库', o.warehouseName ?? '-'), + ('出库日期', o.orderDate != null && o.orderDate!.length >= 10 + ? o.orderDate!.substring(0, 10) + : (o.orderDate ?? '-')), + ], + lines: lines, + onSubmit: (ids) => + ref.read(stockOutListProvider.notifier).returnItems(o.id, ids), + ); + } } // Detail dialog — fetches full order with items diff --git a/client/lib/widgets/order_return_dialog.dart b/client/lib/widgets/order_return_dialog.dart new file mode 100644 index 0000000..b1a2be7 --- /dev/null +++ b/client/lib/widgets/order_return_dialog.dart @@ -0,0 +1,420 @@ +import 'package:flutter/material.dart'; + +import '../core/responsive/responsive.dart'; +import '../core/theme/app_theme.dart'; +import '../core/utils/dialog_util.dart'; + +/// 退单状态小徽章(none 返回 null 不显示)。partial=部分退单(amber),full=已退单(红)。 +Widget? returnStateBadge(String state) { + Color? c; + String? t; + if (state == 'partial') { + c = AppTheme.warning; + t = '部分退单'; + } else if (state == 'full') { + c = AppTheme.danger; + t = '已退单'; + } else { + return null; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)), + child: Text(t, + style: TextStyle(fontSize: 10, color: c, fontWeight: FontWeight.w600)), + ); +} + +/// 退单弹窗的一条明细。 +class ReturnLine { + final int itemId; + final String code; + final String name; + final String series; + final String spec; + final double quantity; + final double unitPrice; + final double totalPrice; + final bool alreadyReturned; // 之前已退过(不可再选) + + const ReturnLine({ + required this.itemId, + required this.code, + required this.name, + required this.series, + required this.spec, + required this.quantity, + required this.unitPrice, + required this.totalPrice, + this.alreadyReturned = false, + }); +} + +/// 打开退单弹窗。提交成功返回 true(调用方据此刷新列表),取消返回 null。 +/// - [isOut] true=出库(退回库存)/ false=入库(从库存删除),影响文案。 +/// - [onSubmit] 收到选中的 itemId 列表,向后端提交;抛异常表示失败。 +Future showOrderReturnDialog({ + required BuildContext context, + required String title, + required List<(String, String)> meta, + required bool isOut, + required List lines, + required Future Function(List itemIds) onSubmit, +}) { + return showAppDialog( + context: context, + builder: (_) => _OrderReturnDialog( + title: title, + meta: meta, + isOut: isOut, + lines: lines, + onSubmit: onSubmit, + ), + ); +} + +class _OrderReturnDialog extends StatefulWidget { + final String title; + final List<(String, String)> meta; + final bool isOut; + final List lines; + final Future Function(List itemIds) onSubmit; + + const _OrderReturnDialog({ + required this.title, + required this.meta, + required this.isOut, + required this.lines, + required this.onSubmit, + }); + + @override + State<_OrderReturnDialog> createState() => _OrderReturnDialogState(); +} + +class _OrderReturnDialogState extends State<_OrderReturnDialog> { + final Set _staged = {}; // 本次暂存要退的 itemId + bool _submitting = false; + + bool _isReturned(ReturnLine l) => l.alreadyReturned || _staged.contains(l.itemId); + + List get _selectable => + widget.lines.where((l) => !l.alreadyReturned).toList(); + + Future _confirmLine(ReturnLine l) async { + final ok = await showAppDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('退单确认', style: TextStyle(fontSize: 16)), + content: RichText( + text: TextSpan( + style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary, height: 1.6), + children: [ + const TextSpan(text: '确认退掉以下明细?\n'), + TextSpan( + text: l.name, + style: const TextStyle(fontWeight: FontWeight.w700)), + TextSpan(text: ' · ${l.series} · ${l.spec}\n'), + TextSpan( + text: '编码 ${l.code} · 数量 ${_q(l.quantity)}', + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('退单'), + ), + ], + ), + ); + if (ok == true) setState(() => _staged.add(l.itemId)); + } + + Future _confirmAll() async { + final pending = _selectable.where((l) => !_staged.contains(l.itemId)).toList(); + if (pending.isEmpty) return; + final ok = await showAppDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('全部退单确认', style: TextStyle(fontSize: 16)), + content: Text('确认退掉本单全部 ${pending.length} 条未退明细?', + style: const TextStyle(fontSize: 13)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('全部退单'), + ), + ], + ), + ); + if (ok == true) { + setState(() => _staged.addAll(pending.map((e) => e.itemId))); + } + } + + Future _submit() async { + if (_staged.isEmpty) return; + final ok = await showAppDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('提交退单', style: TextStyle(fontSize: 16)), + content: Text( + '确认提交退单?${widget.isOut ? '退回库存' : '从库存删除'} 将立即生效,且不可撤销。', + style: const TextStyle(fontSize: 13)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, foregroundColor: Colors.white), + child: const Text('提交退单'), + ), + ], + ), + ); + if (ok != true) return; + setState(() => _submitting = true); + try { + await widget.onSubmit(_staged.toList()); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('退单成功'), backgroundColor: AppTheme.success)); + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + setState(() => _submitting = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('退单失败:$e'), backgroundColor: AppTheme.danger)); + } + } + } + + @override + Widget build(BuildContext context) { + final stagedQty = widget.lines + .where((l) => _staged.contains(l.itemId)) + .fold(0, (s, l) => s + l.quantity); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: SizedBox( + width: context.dialogWidth(820), + height: 560, + child: Column( + children: [ + // 头部 + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 10), + child: Row( + children: [ + Expanded( + child: Text(widget.title, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis), + ), + OutlinedButton( + onPressed: _submitting ? null : _confirmAll, + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.danger, + side: const BorderSide(color: AppTheme.danger), + visualDensity: VisualDensity.compact, + ), + child: const Text('全部退单', style: TextStyle(fontSize: 12)), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + // 单据信息 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Wrap( + spacing: 20, + runSpacing: 4, + children: [ + for (final (k, v) in widget.meta) + Text.rich(TextSpan( + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + children: [ + TextSpan(text: '$k:'), + TextSpan(text: v, style: const TextStyle(color: AppTheme.textPrimary)), + ], + )), + ], + ), + ), + // 行为提示 + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 2, 16, 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF0F6FF), + border: const Border(left: BorderSide(color: AppTheme.primary, width: 3)), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + widget.isOut + ? '提交后,已退单明细的数量将「加回库存」。退单的明细不会删除,以红色「已退单」标注。' + : '提交后,已退单明细对应的库存将「从库存删除」。退单的明细不会删除,以红色「已退单」标注。', + style: const TextStyle(fontSize: 12, color: AppTheme.primaryDark), + ), + ), + // 明细列表 + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: widget.lines.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _lineTile(widget.lines[i], i), + ), + ), + const Divider(height: 1), + // 底部 + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + _staged.isEmpty + ? '未选择退单明细' + : '已退单 ${_staged.length} 项 · ${widget.isOut ? '退回库存 +' : '从库存移除 −'}${_q(stagedQty)} 件', + style: TextStyle( + fontSize: 12, + color: _staged.isEmpty + ? AppTheme.textSecondary + : AppTheme.danger, + fontWeight: FontWeight.w600), + ), + ), + OutlinedButton( + onPressed: _submitting ? null : () => Navigator.pop(context), + child: const Text('取消'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: (_staged.isEmpty || _submitting) ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white), + child: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('提交退单'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _lineTile(ReturnLine l, int idx) { + final returned = _isReturned(l); + final nameStyle = TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: returned ? AppTheme.danger : AppTheme.textPrimary, + decoration: returned ? TextDecoration.lineThrough : null, + ); + final subStyle = TextStyle( + fontSize: 11, + color: returned ? AppTheme.danger : AppTheme.textSecondary, + decoration: returned ? TextDecoration.lineThrough : null, + ); + return Container( + color: returned ? const Color(0xFFFFF4F4) : null, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 9), + child: Row( + children: [ + SizedBox( + width: 24, + child: Text('${idx + 1}', + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary))), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Flexible(child: Text(l.name, style: nameStyle, overflow: TextOverflow.ellipsis)), + if (returned) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.danger, + borderRadius: BorderRadius.circular(8)), + child: const Text('已退单', + style: TextStyle(fontSize: 10, color: Colors.white)), + ), + ], + ]), + const SizedBox(height: 2), + Text( + '${l.code} · ${l.series} · ${l.spec} · ${_q(l.quantity)}×¥${l.unitPrice.toStringAsFixed(2)} = ¥${l.totalPrice.toStringAsFixed(2)}', + style: subStyle, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 64, + child: l.alreadyReturned + ? const SizedBox.shrink() + : (_staged.contains(l.itemId) + ? TextButton( + onPressed: _submitting + ? null + : () => setState(() => _staged.remove(l.itemId)), + child: const Text('撤销', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ) + : OutlinedButton( + onPressed: _submitting ? null : () => _confirmLine(l), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.danger, + side: const BorderSide(color: AppTheme.danger), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: const Text('退单', style: TextStyle(fontSize: 12)), + )), + ), + ], + ), + ); + } + + static String _q(double v) => + v == v.roundToDouble() ? v.toInt().toString() : v.toString(); +} diff --git a/client/lib/widgets/order_row_actions.dart b/client/lib/widgets/order_row_actions.dart index e66a44b..af15c80 100644 --- a/client/lib/widgets/order_row_actions.dart +++ b/client/lib/widgets/order_row_actions.dart @@ -24,7 +24,9 @@ List buildOrderRowActions({ required VoidCallback onApprove, required VoidCallback onReject, required VoidCallback onWithdraw, + required VoidCallback onReturn, bool canWithdraw = false, + bool canReturn = false, List afterPrint = const [], }) { TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) => @@ -39,6 +41,11 @@ List buildOrderRowActions({ ...afterPrint, if (!readonly && status == 'approved') WriteGuard(child: btn('结清', AppTheme.accent, onSettle)), + // 退单(已审核单退货):error 样式,按权限显示 + if (!readonly && status == 'approved' && canReturn) + WriteGuard( + child: btn('退单', AppTheme.danger, onReturn, + key: Key('btn_return_$orderId'))), if (!readonly && status == 'draft') ...[ WriteGuard(child: btn('修改', AppTheme.primary, onEdit)), WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),