feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验

后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-23 14:05:41 +08:00
parent c77e1c1490
commit c1ed81dfab
60 changed files with 4664 additions and 1572 deletions
@@ -1,3 +1,4 @@
import '../../core/utils/dialog_util.dart';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
@@ -10,6 +11,7 @@ import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
import '../../providers/product_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
class ProductDetailScreen extends ConsumerStatefulWidget {
final int productId;
@@ -151,7 +153,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
}
void _showQRCode() {
showDialog(
showAppDialog(
context: context,
builder: (_) => _QRCodeDialog(productId: widget.productId, product: _product!),
);
@@ -528,12 +530,16 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
setState(() { _printing = true; _printStatus = '正在打印...'; });
try {
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await printProductLabel(
qrBytes: _bytes!,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
} catch (e) {
if (mounted) {
@@ -1,3 +1,4 @@
import '../../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
@@ -72,7 +73,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
searchCtrl: _nameSearchCtrl,
hint: '搜索名称/编号',
onSearchChanged: () => setState(() => _namePage = 1),
onAdd: () => _showCreateDialog(
onAdd: () => _showOptionDialog(
title: '新建商品名称',
hasQuantity: false,
onSave: (data) => ref.read(productNameListProvider.notifier).create(data),
@@ -101,10 +102,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除名称「${it.name}」?',
() => ref.read(productNameListProvider.notifier).delete(it.id),
))),
DataCell(_actionButtons(
onEdit: () => _showOptionDialog(
title: '编辑商品名称',
hasQuantity: false,
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
onSave: (data) => ref.read(productNameListProvider.notifier).updateItem(it.id, data),
),
onDelete: () => _confirmDelete(
'删除名称「${it.name}」?',
() => ref.read(productNameListProvider.notifier).delete(it.id),
),
)),
])).toList(),
);
},
@@ -136,7 +145,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
searchCtrl: _seriesSearchCtrl,
hint: '搜索系列/编号',
onSearchChanged: () => setState(() => _seriesPage = 1),
onAdd: () => _showCreateDialog(
onAdd: () => _showOptionDialog(
title: '新建系列',
hasQuantity: false,
onSave: (data) => ref.read(productSeriesListProvider.notifier).create(data),
@@ -165,10 +174,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除系列「${it.name}」?',
() => ref.read(productSeriesListProvider.notifier).delete(it.id),
))),
DataCell(_actionButtons(
onEdit: () => _showOptionDialog(
title: '编辑系列',
hasQuantity: false,
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
onSave: (data) => ref.read(productSeriesListProvider.notifier).updateItem(it.id, data),
),
onDelete: () => _confirmDelete(
'删除系列「${it.name}」?',
() => ref.read(productSeriesListProvider.notifier).delete(it.id),
),
)),
])).toList(),
);
},
@@ -200,7 +217,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
searchCtrl: _specSearchCtrl,
hint: '搜索规格/编号',
onSearchChanged: () => setState(() => _specPage = 1),
onAdd: () => _showCreateDialog(
onAdd: () => _showOptionDialog(
title: '新建规格',
hasQuantity: true,
onSave: (data) => ref.read(productSpecListProvider.notifier).create(data),
@@ -232,10 +249,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(it.quantity > 0 ? '${it.quantity}' : '-')),
DataCell(Text(it.remark ?? '-')),
DataCell(_deleteButton(() => _confirmDelete(
'删除规格「${it.name}」?',
() => ref.read(productSpecListProvider.notifier).delete(it.id),
))),
DataCell(_actionButtons(
onEdit: () => _showOptionDialog(
title: '编辑规格',
hasQuantity: true,
initial: {'code': it.code ?? '', 'name': it.name, 'quantity': it.quantity, 'remark': it.remark ?? ''},
onSave: (data) => ref.read(productSpecListProvider.notifier).updateItem(it.id, data),
),
onDelete: () => _confirmDelete(
'删除规格「${it.name}」?',
() => ref.read(productSpecListProvider.notifier).delete(it.id),
),
)),
])).toList(),
);
},
@@ -296,10 +321,19 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
);
}
Widget _deleteButton(VoidCallback onTap) {
return TextButton(
onPressed: onTap,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onEdit,
child: const Text('编辑', style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: onDelete,
child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)),
),
],
);
}
@@ -332,18 +366,22 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
}
}
Future<void> _showCreateDialog({
Future<void> _showOptionDialog({
required String title,
required bool hasQuantity,
required Future<void> Function(Map<String, dynamic>) onSave,
Map<String, dynamic>? initial,
}) async {
final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final quantityCtrl = TextEditingController();
final remarkCtrl = TextEditingController();
final codeCtrl = TextEditingController(text: initial?['code'] as String? ?? '');
final nameCtrl = TextEditingController(text: initial?['name'] as String? ?? '');
final quantityCtrl = TextEditingController(
text: initial != null && (initial['quantity'] as int? ?? 0) > 0
? '${initial['quantity']}'
: '');
final remarkCtrl = TextEditingController(text: initial?['remark'] as String? ?? '');
final formKey = GlobalKey<FormState>();
await showDialog(
await showAppDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(title),