feat: 完成7项功能任务

后端:
- fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-"
- feat(backend): 新增 /inventory/batches 批次追踪接口
- feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler)
- fix(backend): service/stock_test.go 修复 model.Date 类型错误

前端:
- feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮
- feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细
- feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品
- fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段
- feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口
- feat(client): 财务管理、盘点、编号规则接入真实后端 API
- fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-10 22:03:19 +08:00
parent 5f8e7cfa7e
commit ce1cbf404c
28 changed files with 2507 additions and 1072 deletions
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/stock_in.dart';
import '../../providers/stock_in_provider.dart';
import '../../repositories/stock_in_repository.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
@@ -45,17 +46,17 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
return PageScaffold(
title: '入库管理',
tabs: const [
Tab(text: '入库单'),
Tab(text: '入库审核'),
Tab(text: '入库单'),
],
tabViews: [
_buildListTab(filterStatus: null),
_buildListTab(filterStatus: 'pending'),
_buildListTab(filterStatus: 'pending', showNewButton: true),
_buildListTab(filterStatus: null, showNewButton: false),
],
);
}
Widget _buildListTab({String? filterStatus}) {
Widget _buildListTab({String? filterStatus, required bool showNewButton}) {
final asyncOrders = ref.watch(stockInListProvider);
return asyncOrders.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -75,7 +76,6 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
),
),
data: (result) {
// Client-side filter for review tab
final orders = filterStatus != null
? result.data
.where((o) => o.status == filterStatus)
@@ -86,6 +86,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
totalCount: filterStatus != null ? orders.length : result.total,
page: result.page,
showStatusFilter: filterStatus == null,
showNewButton: showNewButton,
);
},
);
@@ -96,6 +97,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
required int totalCount,
required int page,
required bool showStatusFilter,
required bool showNewButton,
}) {
return DataTableCard(
totalCount: totalCount,
@@ -104,11 +106,12 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setPage(p),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建入库单'),
),
if (showNewButton)
ElevatedButton.icon(
onPressed: () => context.go('/stock-in/new'),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建入库审核单'),
),
const Spacer(),
if (showStatusFilter) ...[
_StatusFilterDropdown(
@@ -187,6 +190,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => _showDetail(context, o.id),
child: const Text('详情',
style: TextStyle(
fontSize: 12,
color: AppTheme.primary)),
),
if (o.status == 'draft')
TextButton(
onPressed: () =>
@@ -224,6 +234,16 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
);
}
Future<void> _showDetail(BuildContext context, int orderId) async {
showDialog(
context: context,
builder: (ctx) => _StockInDetailDialog(
orderId: orderId,
repository: ref.read(stockInRepositoryProvider),
),
);
}
Future<void> _pickDateRange() async {
final range = await showDateRangePicker(
context: context,
@@ -354,6 +374,218 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
// Detail dialog — fetches full order with items
class _StockInDetailDialog extends StatefulWidget {
final int orderId;
final StockInRepository repository;
const _StockInDetailDialog({
required this.orderId,
required this.repository,
});
@override
State<_StockInDetailDialog> createState() => _StockInDetailDialogState();
}
class _StockInDetailDialogState extends State<_StockInDetailDialog> {
late Future<StockInOrder> _future;
@override
void initState() {
super.initState();
_future = widget.repository.get(widget.orderId);
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
width: 720,
constraints: const BoxConstraints(maxHeight: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('入库单详情',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: FutureBuilder<StockInOrder>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Text('加载失败:${snap.error}',
style: const TextStyle(color: AppTheme.danger)));
}
return _buildContent(snap.data!);
},
),
),
],
),
),
);
}
Widget _buildContent(StockInOrder o) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic info
Wrap(
spacing: 32,
runSpacing: 12,
children: [
_InfoField('入库单号', o.orderNo),
_InfoField('状态', _statusLabel(o.status)),
_InfoField('供应商', o.partnerName ?? '-'),
_InfoField('仓库', o.warehouseName ?? '-'),
_InfoField('入库日期', o.orderDate?.substring(0, 10) ?? '-'),
_InfoField('合计金额',
o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'),
if (o.remark != null && o.remark!.isNotEmpty)
_InfoField('备注', o.remark!),
],
),
const SizedBox(height: 20),
const Text('商品明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(height: 8),
if (o.items.isEmpty)
const Text('无商品明细',
style: TextStyle(color: AppTheme.textSecondary))
else
Table(
border: TableBorder.all(color: AppTheme.border, width: 0.5),
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(3),
2: FlexColumnWidth(1.5),
3: FlexColumnWidth(1.5),
4: FlexColumnWidth(1.5),
5: FlexColumnWidth(1.5),
},
children: [
TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: ['序号', '商品', '规格', '数量', '单价', '金额']
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
))
.toList(),
),
...o.items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
return TableRow(
decoration: BoxDecoration(
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
children: [
_TableCell('${i + 1}'),
_TableCell(item.productName ?? '-'),
_TableCell(item.productSpec ?? '-'),
_TableCell(item.quantity.toStringAsFixed(3)),
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
],
);
}),
],
),
],
),
);
}
String _statusLabel(String status) {
switch (status) {
case 'draft':
return '草稿';
case 'pending':
return '待审核';
case 'approved':
return '已审核';
case 'rejected':
return '已拒绝';
default:
return status;
}
}
}
class _InfoField extends StatelessWidget {
final String label;
final String value;
const _InfoField(this.label, this.value);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 2),
Text(value,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
],
);
}
}
class _TableCell extends StatelessWidget {
final String text;
const _TableCell(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(text, style: const TextStyle(fontSize: 13)),
);
}
}
class _StatusFilterDropdown extends StatelessWidget {
final String value;
final ValueChanged<String?> onChanged;