Files
jiu/client/lib/screens/inventory/batch_tracking_screen.dart
T
wangjia 66e54af8c6 feat: 商品追踪页面 + 出库提交库存校验
后端:
- feat(backend): 重命名 Batches→Products 接口,新增库存状态(在售/已卖出)和买家信息
- feat(backend): 出库单创建/提交时校验仓库库存,不足则返回明确错误信息
- fix(backend): 出库创建 status 判断逻辑修复(空值默认 draft)

前端:
- feat(client): 批次追踪改为商品追踪,新增状态列(在售/已卖出)和买家/时间列
- fix(client): 无批次号时显示"无批次"而非空
- refactor(client): BatchRecord → ProductTrackingRecord,repository 接口更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 22:26:42 +08:00

236 lines
8.4 KiB
Dart

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;
int _total = 0;
late Future<List<ProductTrackingRecord>> _future;
@override
void initState() {
super.initState();
_fetch();
}
void _fetch() {
_future = ref
.read(inventoryRepositoryProvider)
.listProducts(page: _page, pageSize: 20)
.then((r) {
_total = r.total;
return r.data;
});
}
void _refetch() => setState(() => _fetch());
@override
Widget build(BuildContext context) {
return FutureBuilder<List<ProductTrackingRecord>>(
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<ProductTrackingRecord> records) {
return DataTableCard(
totalCount: _total,
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),
DataColumn(label: Text('状态')),
DataColumn(label: Text('买家/时间')),
],
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()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: records.map((r) {
final batchText =
(r.batchNo != null && r.batchNo!.isNotEmpty)
? r.batchNo!
: null;
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(batchText != null
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(3),
),
child: Text(batchText,
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontFamily: 'monospace')),
)
: const Text('无批次',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(Text(r.orderNo ?? '-',
style: const TextStyle(
fontSize: 11,
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))),
DataCell(_StatusBadge(r.isSoldOut)),
DataCell(r.isSoldOut
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
r.buyerName?.isNotEmpty == true
? r.buyerName!
: '未知买家',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500),
),
if (r.soldAt != null)
Text(r.soldAt!.length > 10
? r.soldAt!.substring(0, 10)
: r.soldAt!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary)),
],
)
: const Text('-',
style:
TextStyle(color: AppTheme.textSecondary))),
]);
}).toList(),
);
}
}
class _StatusBadge extends StatelessWidget {
final bool isSoldOut;
const _StatusBadge(this.isSoldOut);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isSoldOut
? AppTheme.textSecondary.withOpacity(0.12)
: AppTheme.success.withOpacity(0.12),
borderRadius: BorderRadius.circular(3),
),
child: Text(
isSoldOut ? '已卖出' : '在售',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSoldOut ? AppTheme.textSecondary : AppTheme.success,
),
),
);
}
}