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:
@@ -8,6 +8,7 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../repositories/finance_repository.dart';
|
||||
|
||||
class FinanceScreen extends ConsumerWidget {
|
||||
const FinanceScreen({super.key});
|
||||
@@ -30,7 +31,6 @@ class FinanceScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Each tab has its own independent state — avoids shared-provider conflicts
|
||||
class _FinanceTab extends ConsumerStatefulWidget {
|
||||
final String typeFilter;
|
||||
const _FinanceTab({required this.typeFilter});
|
||||
@@ -44,7 +44,6 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
int _page = 1;
|
||||
int _pageSize = 20;
|
||||
|
||||
// We drive fetches by maintaining a Future locally, bypassing the global provider
|
||||
late Future<List<FinanceRecord>> _future;
|
||||
List<FinanceRecord> _allRecords = [];
|
||||
int _total = 0;
|
||||
@@ -60,7 +59,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
ColDef('ref', '关联单据', minWidth: 900),
|
||||
ColDef('amount', '金额'),
|
||||
ColDef('balance', '余额'),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('remark', '备注', minWidth: 1000),
|
||||
ColDef('actions', '操作'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -104,9 +105,32 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _closeRecord(FinanceRecord r) async {
|
||||
try {
|
||||
await ref.read(financeRepositoryProvider).close(r.id);
|
||||
_refetch();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => _AddPaymentDialog(
|
||||
typeFilter: widget.typeFilter,
|
||||
onSaved: _refetch,
|
||||
repo: ref.read(financeRepositoryProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 网络恢复时自动刷新
|
||||
ref.listen(networkRecoveryCountProvider, (_, __) => _refetch());
|
||||
|
||||
return FutureBuilder<List<FinanceRecord>>(
|
||||
@@ -144,16 +168,16 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
}
|
||||
|
||||
Widget _buildContent(List<FinanceRecord> records) {
|
||||
// Summary: only for payable/receivable tabs
|
||||
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
|
||||
final totalBalance = records.fold(0.0, (s, r) => s + r.balance);
|
||||
final totalPaid = totalAmount - totalBalance;
|
||||
final openAmount = records
|
||||
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open')
|
||||
.fold(0.0, (s, r) => s + r.amount);
|
||||
final closedAmount = records
|
||||
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed')
|
||||
.fold(0.0, (s, r) => s + r.amount);
|
||||
|
||||
// Derive filter options from all loaded records
|
||||
final typeOptions = _allRecords
|
||||
.map((r) => r.typeLabel)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort();
|
||||
final partnerOptions = _allRecords
|
||||
.map((r) => r.partnerName ?? '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
@@ -161,7 +185,6 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
// Build visible columns (manual hide + responsive auto-hide by screen width)
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
@@ -202,8 +225,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
case 'partner':
|
||||
return DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.partnerName ?? '-',
|
||||
overflow: TextOverflow.ellipsis),
|
||||
child: Text(r.partnerName ?? '-', overflow: TextOverflow.ellipsis),
|
||||
));
|
||||
case 'ref':
|
||||
return DataCell(Text(
|
||||
@@ -211,9 +233,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
|
||||
: '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: AppTheme.primary),
|
||||
fontSize: 11, fontFamily: 'monospace', color: AppTheme.primary),
|
||||
));
|
||||
case 'amount':
|
||||
return DataCell(Text(
|
||||
@@ -224,19 +244,31 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
return DataCell(Text(
|
||||
'¥${r.balance.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color:
|
||||
r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
|
||||
color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
));
|
||||
case 'status':
|
||||
if (r.type != 'payable' && r.type != 'receivable') {
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
return DataCell(_StatusBadge(r.status));
|
||||
case 'remark':
|
||||
return DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(r.remark ?? '-',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
||||
));
|
||||
case 'actions':
|
||||
if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') {
|
||||
return DataCell(TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
));
|
||||
}
|
||||
return const DataCell(SizedBox());
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
@@ -256,15 +288,19 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
]
|
||||
: records
|
||||
.map((r) => DataRow(
|
||||
cells: visibleCols
|
||||
.map((c) => buildFinanceCell(c.key, r))
|
||||
.toList(),
|
||||
cells: visibleCols.map((c) => buildFinanceCell(c.key, r)).toList(),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// Determine add button label
|
||||
final addLabel = widget.typeFilter == 'payable'
|
||||
? '添加付款'
|
||||
: widget.typeFilter == 'receivable'
|
||||
? '添加收款'
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary bar (only for type-filtered tabs)
|
||||
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
|
||||
Container(
|
||||
color: AppTheme.background,
|
||||
@@ -272,24 +308,24 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryCard(
|
||||
title: '本期总额',
|
||||
title: '合计金额',
|
||||
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.account_balance_wallet,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '已结清',
|
||||
value: '¥${(totalPaid / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.success,
|
||||
title: '未结清',
|
||||
value: '¥${(openAmount / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.pending_actions,
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '未结清',
|
||||
value: '¥${(totalBalance / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.pending_actions,
|
||||
color: AppTheme.danger,
|
||||
title: '已结清',
|
||||
value: '¥${(closedAmount / 10000).toStringAsFixed(2)}万',
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -316,6 +352,13 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
},
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (addLabel != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
),
|
||||
if (addLabel != null) const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
final tabName = widget.typeFilter.isEmpty
|
||||
@@ -325,7 +368,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
: '应收账款';
|
||||
exportExcel(
|
||||
filename: tabName,
|
||||
headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '备注'],
|
||||
headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '状态', '备注'],
|
||||
rows: records.map((r) => [
|
||||
r.recordDate?.substring(0, 10) ?? '',
|
||||
r.typeLabel,
|
||||
@@ -335,6 +378,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
: '',
|
||||
r.amount,
|
||||
r.balance,
|
||||
r.status == 'open' ? '未结清' : '已结清',
|
||||
r.remark ?? '',
|
||||
]).toList(),
|
||||
);
|
||||
@@ -368,6 +412,162 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 添加付款/收款弹窗 ─────────────────────────────────────────
|
||||
|
||||
class _AddPaymentDialog extends StatefulWidget {
|
||||
final String typeFilter; // 'payable' | 'receivable'
|
||||
final VoidCallback onSaved;
|
||||
final FinanceRepository repo;
|
||||
|
||||
const _AddPaymentDialog({
|
||||
required this.typeFilter,
|
||||
required this.onSaved,
|
||||
required this.repo,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AddPaymentDialog> createState() => _AddPaymentDialogState();
|
||||
}
|
||||
|
||||
class _AddPaymentDialogState extends State<_AddPaymentDialog> {
|
||||
final _amountCtrl = TextEditingController();
|
||||
final _remarkCtrl = TextEditingController();
|
||||
final _partnerCtrl = TextEditingController();
|
||||
DateTime _date = DateTime.now();
|
||||
bool _saving = false;
|
||||
|
||||
String get _type => widget.typeFilter == 'payable' ? 'payment' : 'receipt';
|
||||
String get _title => widget.typeFilter == 'payable' ? '添加付款记录' : '添加收款记录';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
_partnerCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final amount = double.tryParse(_amountCtrl.text.trim());
|
||||
if (amount == null || amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final body = <String, dynamic>{
|
||||
'type': _type,
|
||||
'amount': amount,
|
||||
'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
|
||||
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
|
||||
};
|
||||
await widget.repo.create(body);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSaved();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('添加成功'), backgroundColor: AppTheme.success),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('添加失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(_title),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _amountCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
);
|
||||
if (picked != null) setState(() => _date = picked);
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(labelText: '日期'),
|
||||
child: Text(
|
||||
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _remarkCtrl,
|
||||
decoration: const InputDecoration(labelText: '备注(选填)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16, height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widgets ──────────────────────────────────────────────────
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusBadge(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isOpen = status == 'open';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
isOpen ? '未结清' : '已结清',
|
||||
style: TextStyle(
|
||||
color: isOpen ? AppTheme.accent : AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
final String label;
|
||||
const _TypeBadge(this.label);
|
||||
@@ -401,8 +601,7 @@ class _TypeBadge extends StatelessWidget {
|
||||
decoration:
|
||||
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -497,8 +696,7 @@ class _MonthSelector extends StatelessWidget {
|
||||
child: Text(m, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => onChanged(v!),
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user