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
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
class BatchTrackingScreen extends ConsumerStatefulWidget {
const BatchTrackingScreen({super.key});
@override
ConsumerState<BatchTrackingScreen> createState() =>
_BatchTrackingScreenState();
}
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
int _page = 1;
late Future<List<BatchRecord>> _future;
@override
void initState() {
super.initState();
_fetch();
}
void _fetch() {
_future = ref
.read(inventoryRepositoryProvider)
.listBatches(page: _page, pageSize: 20)
.then((r) => r.data);
}
void _refetch() {
setState(() => _fetch());
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<BatchRecord>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败:${snap.error}',
style: const TextStyle(color: AppTheme.danger)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _refetch, child: const Text('重试')),
],
),
);
}
return _buildTable(snap.data ?? []);
},
);
}
Widget _buildTable(List<BatchRecord> records) {
return DataTableCard(
totalCount: records.length,
page: _page,
onPageChanged: (p) {
setState(() {
_page = p;
_fetch();
});
},
toolbar: Row(
children: [
const Text('已审核入库批次',
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: () {
setState(() {
_page = 1;
_fetch();
});
},
tooltip: '刷新',
),
],
),
columns: const [
DataColumn(label: Text('商品')),
DataColumn(label: Text('规格')),
DataColumn(label: Text('批次号')),
DataColumn(label: Text('入库单号')),
DataColumn(label: Text('供应商')),
DataColumn(label: Text('仓库')),
DataColumn(label: Text('入库日期')),
DataColumn(label: Text('数量'), numeric: true),
DataColumn(label: Text('单价'), numeric: true),
],
rows: records.isEmpty
? [
const DataRow(cells: [
DataCell(SizedBox()),
DataCell(Text('暂无批次记录',
style: TextStyle(color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: records.map((r) {
final hasBatch =
r.batchNo != null && r.batchNo!.isNotEmpty;
return DataRow(cells: [
DataCell(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(r.productName ?? '-',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500)),
if (r.productCode != null)
Text(r.productCode!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
fontFamily: 'monospace')),
],
),
),
DataCell(Text(r.productSpec ?? '-',
style: const TextStyle(fontSize: 12))),
DataCell(hasBatch
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(3),
),
child: Text(r.batchNo!,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontFamily: 'monospace')),
)
: const Text('-',
style: TextStyle(
color: AppTheme.textSecondary))),
DataCell(Text(r.orderNo ?? '-',
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontFamily: 'monospace'))),
DataCell(Text(r.supplierName ?? '-',
style: const TextStyle(fontSize: 12))),
DataCell(Text(r.warehouseName ?? '-',
style: const TextStyle(fontSize: 12))),
DataCell(Text(
r.orderDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12))),
DataCell(Text(
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Text(
'¥${r.unitPrice.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13))),
]);
}).toList(),
);
}
}
@@ -3,6 +3,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../models/warehouse.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/warehouse_provider.dart';
class InventoryCheckScreen extends ConsumerStatefulWidget {
const InventoryCheckScreen({super.key});
@@ -12,100 +16,124 @@ class InventoryCheckScreen extends ConsumerStatefulWidget {
_InventoryCheckScreenState();
}
class _InventoryCheckScreenState
extends ConsumerState<InventoryCheckScreen> {
final _checkNoCtrl =
TextEditingController(text: 'PD20260404001');
String _warehouse = '主仓库';
class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
Warehouse? _selectedWarehouse;
String _checkType = '全盘';
bool _submitting = false;
bool _loadingItems = false;
// Mock inventory items for checking
final List<Map<String, dynamic>> _checkItems = [
{
'code': 'SP001',
'name': '茅台酒(飞天)53度500ml',
'unit': '',
'systemQty': 286,
'actualQtyCtrl': TextEditingController(text: '286'),
'remark': TextEditingController(),
},
{
'code': 'SP002',
'name': '五粮液(普五)52度500ml',
'unit': '',
'systemQty': 152,
'actualQtyCtrl': TextEditingController(text: '150'),
'remark': TextEditingController(text: '破损2瓶'),
},
{
'code': 'SP003',
'name': '洋河梦之蓝M6+ 45度500ml',
'unit': '',
'systemQty': 88,
'actualQtyCtrl': TextEditingController(text: '88'),
'remark': TextEditingController(),
},
{
'code': 'SP005',
'name': '泸州老窖(国窖157352度500ml',
'unit': '',
'systemQty': 68,
'actualQtyCtrl': TextEditingController(text: '70'),
'remark': TextEditingController(text: '盘盈2瓶'),
},
{
'code': 'SP006',
'name': '汾酒(青花3053度500ml',
'unit': '',
'systemQty': 45,
'actualQtyCtrl': TextEditingController(text: '45'),
'remark': TextEditingController(),
},
{
'code': 'SP010',
'name': '青岛啤酒(经典)500ml',
'unit': '',
'systemQty': 35,
'actualQtyCtrl': TextEditingController(text: '35'),
'remark': TextEditingController(),
},
];
// Editable check items built from real inventory
final List<_CheckItem> _checkItems = [];
String get _checkNo {
final now = DateTime.now();
final date =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}';
}
@override
void dispose() {
_checkNoCtrl.dispose();
for (final item in _checkItems) {
(item['actualQtyCtrl'] as TextEditingController).dispose();
(item['remark'] as TextEditingController).dispose();
item.dispose();
}
super.dispose();
}
int _getDiff(Map<String, dynamic> item) {
final actual = int.tryParse(
(item['actualQtyCtrl'] as TextEditingController).text) ??
0;
return actual - (item['systemQty'] as int);
Future<void> _loadInventory(Warehouse wh) async {
setState(() {
_loadingItems = true;
for (final item in _checkItems) {
item.dispose();
}
_checkItems.clear();
});
try {
final result = await ref
.read(inventoryRepositoryProvider)
.listInventory(warehouseId: wh.id, pageSize: 200);
final items = result.data
.map((inv) => _CheckItem(inventory: inv))
.toList();
if (mounted) {
setState(() {
_checkItems.addAll(items);
_loadingItems = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _loadingItems = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('加载库存失败:$e'),
backgroundColor: AppTheme.danger),
);
}
}
}
Future<void> _submit() async {
setState(() => _submitting = true);
await Future.delayed(const Duration(milliseconds: 800));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('盘点单已提交,待审核'),
backgroundColor: AppTheme.success,
),
);
context.go('/inventory');
if (_selectedWarehouse == null) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('请先选择仓库')));
return;
}
if (mounted) setState(() => _submitting = false);
setState(() => _submitting = true);
try {
final now = DateTime.now();
final dateStr =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final items = _checkItems.map((item) {
final actual =
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
return {
'product_id': item.inventory.productId,
'actual_qty': actual,
'remark': item.remarkCtrl.text.trim(),
};
}).toList();
await ref.read(inventoryRepositoryProvider).createCheck({
'check_no': _checkNo,
'warehouse_id': _selectedWarehouse!.id,
'check_date': dateStr,
'items': items,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('盘点单已提交'),
backgroundColor: AppTheme.success,
),
);
context.go('/inventory');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('提交失败:$e'),
backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
int _getDiff(_CheckItem item) {
final actual =
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
return (actual - item.inventory.quantity).round();
}
@override
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
return Scaffold(
backgroundColor: AppTheme.background,
body: Column(
@@ -127,12 +155,14 @@ class _InventoryCheckScreenState
fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
OutlinedButton(
onPressed: () {},
child: const Text('保存草稿'),
onPressed: () => context.go('/inventory'),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _submitting ? null : _submit,
onPressed: (_submitting || _checkItems.isEmpty)
? null
: _submit,
icon: _submitting
? const SizedBox(
width: 14,
@@ -142,11 +172,6 @@ class _InventoryCheckScreenState
: const Icon(Icons.check_circle_outline, size: 16),
label: const Text('提交盘点'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => context.go('/inventory'),
child: const Text('取消'),
),
],
),
),
@@ -175,28 +200,43 @@ class _InventoryCheckScreenState
children: [
_InfoField(
label: '盘点单号',
child: TextFormField(
controller: _checkNoCtrl,
readOnly: true,
style: const TextStyle(
fontFamily: 'monospace'),
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
_checkNo,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13),
),
),
),
_InfoField(
label: '盘点仓库',
child: DropdownButtonFormField<String>(
value: _warehouse,
items: ['主仓库', '副仓库', '全部仓库']
.map((s) => DropdownMenuItem(
value: s,
child: Text(s,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) =>
setState(() => _warehouse = v!),
decoration: const InputDecoration(),
child: asyncWarehouses.when(
loading: () =>
const LinearProgressIndicator(),
error: (e, _) => Text('$e',
style: const TextStyle(
color: AppTheme.danger,
fontSize: 12)),
data: (warehouses) =>
DropdownButtonFormField<Warehouse>(
value: _selectedWarehouse,
hint: const Text('请选择仓库'),
items: warehouses
.map((w) => DropdownMenuItem(
value: w,
child: Text(w.name,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (w) {
setState(
() => _selectedWarehouse = w);
if (w != null) _loadInventory(w);
},
decoration: const InputDecoration(),
),
),
),
_InfoField(
@@ -219,10 +259,14 @@ class _InventoryCheckScreenState
label: '盘点日期',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
'2026-04-04',
style: const TextStyle(fontSize: 13),
),
child: Builder(builder: (ctx) {
final now = DateTime.now();
return Text(
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}',
style:
const TextStyle(fontSize: 13),
);
}),
),
),
],
@@ -247,103 +291,133 @@ class _InventoryCheckScreenState
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.accent.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
if (_checkItems.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color:
AppTheme.accent.withOpacity(0.1),
borderRadius:
BorderRadius.circular(3),
),
child: Text(
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.accent),
),
),
child: Text(
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.accent),
),
),
],
),
const SizedBox(height: 12),
// Table
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FixedColumnWidth(80),
2: FlexColumnWidth(3),
3: FixedColumnWidth(50),
4: FixedColumnWidth(80),
5: FixedColumnWidth(120),
6: FixedColumnWidth(80),
7: FlexColumnWidth(2),
},
children: [
TableRow(
decoration: const BoxDecoration(
color: Color(0xFFF0F4FF)),
children: [
'序号',
'商品编码',
'商品名称',
'单位',
'账面数量',
'实际数量',
'差异',
'备注',
]
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight:
FontWeight.w600,
color: AppTheme
.primaryDark)),
))
.toList(),
if (_selectedWarehouse == null)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('请先选择盘点仓库',
style: TextStyle(
color: AppTheme.textSecondary)),
),
...List.generate(
_checkItems.length,
(i) => _buildCheckRow(i)),
],
),
const Divider(height: 1),
// Summary
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
)
else if (_loadingItems)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else if (_checkItems.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('该仓库暂无库存记录',
style: TextStyle(
color: AppTheme.textSecondary)),
),
)
else
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FixedColumnWidth(80),
2: FlexColumnWidth(3),
3: FixedColumnWidth(50),
4: FixedColumnWidth(80),
5: FixedColumnWidth(120),
6: FixedColumnWidth(80),
7: FlexColumnWidth(2),
},
children: [
_SummaryItem(
label: '盘点商品',
value: '${_checkItems.length}',
color: AppTheme.primary,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘盈',
value:
'${_checkItems.where((i) => _getDiff(i) > 0).length}',
color: AppTheme.success,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘亏',
value:
'${_checkItems.where((i) => _getDiff(i) < 0).length}',
color: AppTheme.danger,
),
const SizedBox(width: 24),
_SummaryItem(
label: '相符',
value:
'${_checkItems.where((i) => _getDiff(i) == 0).length}',
color: AppTheme.textSecondary,
TableRow(
decoration: const BoxDecoration(
color: Color(0xFFF0F4FF)),
children: [
'序号',
'商品编码',
'商品名称',
'单位',
'账面数量',
'实际数量',
'差异',
'备注',
]
.map((h) => Padding(
padding: const EdgeInsets
.symmetric(
horizontal: 8,
vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight:
FontWeight.w600,
color: AppTheme
.primaryDark)),
))
.toList(),
),
...List.generate(
_checkItems.length,
(i) => _buildCheckRow(i)),
],
),
),
if (_checkItems.isNotEmpty) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
_SummaryItem(
label: '盘点商品',
value: '${_checkItems.length}',
color: AppTheme.primary,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘盈',
value:
'${_checkItems.where((i) => _getDiff(i) > 0).length}',
color: AppTheme.success,
),
const SizedBox(width: 24),
_SummaryItem(
label: '盘亏',
value:
'${_checkItems.where((i) => _getDiff(i) < 0).length}',
color: AppTheme.danger,
),
const SizedBox(width: 24),
_SummaryItem(
label: '相符',
value:
'${_checkItems.where((i) => _getDiff(i) == 0).length}',
color: AppTheme.textSecondary,
),
],
),
),
],
],
),
),
@@ -360,6 +434,7 @@ class _InventoryCheckScreenState
TableRow _buildCheckRow(int index) {
final item = _checkItems[index];
final diff = _getDiff(item);
final inv = item.inventory;
return TableRow(
decoration: BoxDecoration(
@@ -371,39 +446,44 @@ class _InventoryCheckScreenState
),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text('${index + 1}',
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(item['code'] as String,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productCode ?? '-',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(item['name'] as String,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productName ?? '-',
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(item['unit'] as String,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productUnit ?? '-',
style: const TextStyle(fontSize: 13)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text('${item['systemQty']}',
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.quantity.toStringAsFixed(0),
style: const TextStyle(fontSize: 13)),
),
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item['actualQtyCtrl'] as TextEditingController,
controller: item.actualQtyCtrl,
decoration: const InputDecoration(),
style: const TextStyle(fontSize: 13),
keyboardType: TextInputType.number,
@@ -412,7 +492,8 @@ class _InventoryCheckScreenState
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
style: TextStyle(
@@ -427,7 +508,7 @@ class _InventoryCheckScreenState
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item['remark'] as TextEditingController,
controller: item.remarkCtrl,
decoration: const InputDecoration(hintText: '备注'),
style: const TextStyle(fontSize: 13),
),
@@ -437,6 +518,22 @@ class _InventoryCheckScreenState
}
}
class _CheckItem {
final Inventory inventory;
final TextEditingController actualQtyCtrl;
final TextEditingController remarkCtrl;
_CheckItem({required this.inventory})
: actualQtyCtrl = TextEditingController(
text: inventory.quantity.toStringAsFixed(0)),
remarkCtrl = TextEditingController();
void dispose() {
actualQtyCtrl.dispose();
remarkCtrl.dispose();
}
}
class _InfoField extends StatelessWidget {
final String label;
final Widget child;
@@ -467,9 +564,7 @@ class _SummaryItem extends StatelessWidget {
final Color color;
const _SummaryItem(
{required this.label,
required this.value,
required this.color});
{required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) {