Compare commits

...

1 Commits

Author SHA1 Message Date
wangjia 381588826c chore: release client-v1.0.69
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-macos (push) Successful in 2m13s
Deploy Client / build-android (push) Successful in 1m6s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-ios (push) Successful in 2m41s
Deploy Client / release-deploy-client (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-21 19:09:47 +08:00
10 changed files with 147 additions and 11 deletions
+8
View File
@@ -5,6 +5,14 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.69] - 2026-06-21
### 新功能
- 入库/出库审核列表:管理员可「撤回」审核中的单据,单据回到草稿,修改后重新提交审核
### 改进
- 新建入库单:批次号移到「生产日期」右侧、与各列对齐填写;生产日期输入框收窄,不再占用过宽
## [1.0.68] - 2026-06-21 ## [1.0.68] - 2026-06-21
### 改进 ### 改进
+7
View File
@@ -194,3 +194,10 @@ final isReadonlyProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role)); final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'readonly'; return role == 'readonly';
}); });
/// 当前登录用户是否为管理员(role == 'admin')。
/// 用于「撤回审核中单据」等仅管理员可用的操作;后端 middleware.AdminOnly() 兜底。
final isAdminProvider = Provider<bool>((ref) {
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
return role == 'admin';
});
@@ -123,4 +123,9 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
await ref.read(stockInRepositoryProvider).reject(id); await ref.read(stockInRepositoryProvider).reject(id);
reload(); reload();
} }
Future<void> withdrawOrder(int id) async {
await ref.read(stockInRepositoryProvider).withdraw(id);
reload();
}
} }
@@ -123,4 +123,9 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
await ref.read(stockOutRepositoryProvider).reject(id); await ref.read(stockOutRepositoryProvider).reject(id);
reload(); reload();
} }
Future<void> withdrawOrder(int id) async {
await ref.read(stockOutRepositoryProvider).withdraw(id);
reload();
}
} }
@@ -119,4 +119,15 @@ class StockInRepository {
); );
} }
} }
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-in/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
} }
@@ -119,4 +119,15 @@ class StockOutRepository {
); );
} }
} }
Future<void> withdraw(int id) async {
try {
await _client.put('/stock-out/orders/$id/withdraw');
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '撤回失败',
statusCode: e.response?.statusCode,
);
}
}
} }
@@ -873,7 +873,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
controller: item.batchNoCtrl, controller: item.batchNoCtrl,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: '选填', hintText: '选填',
labelText: '批次号',
isDense: true, isDense: true,
), ),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
@@ -993,7 +992,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Expanded(flex: 18, child: th('名称')), Expanded(flex: 18, child: th('名称')),
Expanded(flex: 12, child: th('系列')), Expanded(flex: 12, child: th('系列')),
Expanded(flex: 12, child: th('规格')), Expanded(flex: 12, child: th('规格')),
Expanded(flex: 24, child: th('生产日期')), Expanded(flex: 14, child: th('生产日期')),
Expanded(flex: 12, child: th('批次号')),
Expanded(flex: 10, child: th('数量')), Expanded(flex: 10, child: th('数量')),
Expanded(flex: 10, child: th('单价')), Expanded(flex: 10, child: th('单价')),
Expanded(flex: 10, child: th('金额')), Expanded(flex: 10, child: th('金额')),
@@ -1017,8 +1017,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
style: TextStyle(fontSize: 13, color: color, fontWeight: weight)), style: TextStyle(fontSize: 13, color: color, fontWeight: weight)),
); );
final hasOptional = item.batchNoCtrl.text.isNotEmpty || final hasOptional = item.selectedOriginId != null ||
item.selectedOriginId != null ||
item.selectedShelfLifeId != null || item.selectedShelfLifeId != null ||
item.selectedStorageId != null || item.selectedStorageId != null ||
item.selectedDescriptionDocId != null; item.selectedDescriptionDocId != null;
@@ -1050,10 +1049,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
child: _specField(item))), child: _specField(item))),
Expanded( Expanded(
flex: 24, flex: 14,
child: Padding( child: Padding(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
child: _dateField(item))), child: _dateField(item))),
Expanded(
flex: 12,
child: Padding(
padding: const EdgeInsets.all(4),
child: _batchField(item))),
Expanded( Expanded(
flex: 10, flex: 10,
child: Padding( child: Padding(
@@ -1071,7 +1075,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
SizedBox( SizedBox(
width: 32, width: 32,
child: Tooltip( child: Tooltip(
message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)', message: item.expanded ? '收起选填' : '展开选填(产地/保质期等)',
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
item.expanded ? Icons.expand_less : Icons.expand_more, item.expanded ? Icons.expand_less : Icons.expand_more,
@@ -1131,8 +1135,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
spacing: 16, spacing: 16,
runSpacing: 12, runSpacing: 12,
children: [ children: [
_OptionalField(
label: '批次号', width: 180, child: _batchField(item)),
_OptionalField( _OptionalField(
label: '产地', width: 180, child: _originField(item)), label: '产地', width: 180, child: _originField(item)),
_OptionalField( _OptionalField(
@@ -1170,6 +1172,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
MobileCardField('系列', null, valueWidget: _seriesField(item)), MobileCardField('系列', null, valueWidget: _seriesField(item)),
MobileCardField('规格', null, valueWidget: _specField(item)), MobileCardField('规格', null, valueWidget: _specField(item)),
MobileCardField('生产日期', null, valueWidget: _dateField(item)), MobileCardField('生产日期', null, valueWidget: _dateField(item)),
MobileCardField('批次号', null, valueWidget: _batchField(item)),
MobileCardField('数量', null, valueWidget: _qtyField(item)), MobileCardField('数量', null, valueWidget: _qtyField(item)),
MobileCardField('单价', null, valueWidget: _priceField(item)), MobileCardField('单价', null, valueWidget: _priceField(item)),
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
@@ -1178,15 +1181,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
onPressed: () => setState(() => item.expanded = !item.expanded), onPressed: () => setState(() => item.expanded = !item.expanded),
icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more, icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more,
size: 16), size: 16),
label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'), label: Text(item.expanded ? '收起选填项' : '展开选填项(产地/保质期…)'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 32), minimumSize: const Size(0, 32),
foregroundColor: AppTheme.textSecondary, foregroundColor: AppTheme.textSecondary,
), ),
)), )),
if (item.expanded)
MobileCardField('批次号', null, valueWidget: _batchField(item)),
if (item.expanded) if (item.expanded)
MobileCardField('产地', null, valueWidget: _originField(item)), MobileCardField('产地', null, valueWidget: _originField(item)),
if (item.expanded) if (item.expanded)
@@ -25,6 +25,7 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart'; import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart'; import '../../widgets/order_row_actions.dart';
import '../../core/auth/auth_state.dart' show isAdminProvider;
class StockInListScreen extends ConsumerStatefulWidget { class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key}); const StockInListScreen({super.key});
@@ -478,6 +479,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
List<Widget> _orderActions(BuildContext context, StockInOrder o) { List<Widget> _orderActions(BuildContext context, StockInOrder o) {
return buildOrderRowActions( return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref), readonly: WriteGuard.isReadonly(ref),
isAdmin: ref.watch(isAdminProvider),
status: o.status, status: o.status,
orderId: o.id, orderId: o.id,
onDetail: () => _showDetail(context, o.id), onDetail: () => _showDetail(context, o.id),
@@ -525,6 +527,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onSubmit: () => _confirmSubmit(context, o), onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o), onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o), onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
); );
} }
@@ -754,6 +757,43 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
} }
} }
} }
Future<void> _confirmWithdraw(BuildContext context, StockInOrder o) async {
final confirmed = await showDialog<bool>(
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.accent,
foregroundColor: Colors.white),
child: const Text('撤回'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref.read(stockInListProvider.notifier).withdrawOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('撤回失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
} }
// Detail dialog — fetches full order with items // Detail dialog — fetches full order with items
@@ -23,6 +23,7 @@ import '../../providers/product_provider.dart';
import '../../providers/finance_provider.dart' show financeRepositoryProvider; import '../../providers/finance_provider.dart' show financeRepositoryProvider;
import '../../widgets/write_guard.dart'; import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart'; import '../../widgets/order_row_actions.dart';
import '../../core/auth/auth_state.dart' show isAdminProvider;
class StockOutListScreen extends ConsumerStatefulWidget { class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key}); const StockOutListScreen({super.key});
@@ -484,6 +485,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
List<Widget> _orderActions(BuildContext context, StockOutOrder o) { List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
return buildOrderRowActions( return buildOrderRowActions(
readonly: WriteGuard.isReadonly(ref), readonly: WriteGuard.isReadonly(ref),
isAdmin: ref.watch(isAdminProvider),
status: o.status, status: o.status,
orderId: o.id, orderId: o.id,
onDetail: () => _showDetail(context, o.id), onDetail: () => _showDetail(context, o.id),
@@ -499,6 +501,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onSubmit: () => _confirmSubmit(context, o), onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o), onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o), onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
); );
} }
@@ -732,6 +735,44 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
} }
} }
} }
Future<void> _confirmWithdraw(
BuildContext context, StockOutOrder o) async {
final confirmed = await showDialog<bool>(
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.accent,
foregroundColor: Colors.white),
child: const Text('撤回'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref.read(stockOutListProvider.notifier).withdrawOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已撤回为草稿'), backgroundColor: AppTheme.accent));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('撤回失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
} }
// Detail dialog — fetches full order with items // Detail dialog — fetches full order with items
@@ -23,6 +23,8 @@ List<Widget> buildOrderRowActions({
required VoidCallback onSubmit, required VoidCallback onSubmit,
required VoidCallback onApprove, required VoidCallback onApprove,
required VoidCallback onReject, required VoidCallback onReject,
required VoidCallback onWithdraw,
bool isAdmin = false,
List<Widget> afterPrint = const [], List<Widget> afterPrint = const [],
}) { }) {
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) => TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
@@ -49,6 +51,11 @@ List<Widget> buildOrderRowActions({
WriteGuard( WriteGuard(
child: btn('拒绝', AppTheme.danger, onReject, child: btn('拒绝', AppTheme.danger, onReject,
key: Key('btn_reject_$orderId'))), key: Key('btn_reject_$orderId'))),
// 撤回(审核中→草稿)仅管理员可见
if (isAdmin)
WriteGuard(
child: btn('撤回', AppTheme.accent, onWithdraw,
key: Key('btn_withdraw_$orderId'))),
], ],
]; ];
} }