feat: 商品详情页、XLS导入修复、分页选择器、导出功能

后端:
- 新增 product_images 表,支持每商品最多5张图(服务端压缩至1200px/JPEG85%)
- products 表新增 public_id(UUID)、description 字段
- 新增商品详情接口、二维码接口、公开商品接口(无鉴权)
- 修复 XLS 导入:OLE2 magic bytes 检测 + 临时文件解析,兼容 extrame/xls
- 修复商品/名称/系列/规格三张表导入数据为0(LastCol()=0 bug)
- 所有导入接口返回 total/imported/skipped 统计
- config 新增 StorageConfig,支持 STORAGE_* 环境变量覆盖
- 种子数据修复:products 补 public_id、新增 product_images TRUNCATE、schema.sql 表名修正

前端:
- 商品详情页:图片上传/删除、描述内联编辑、二维码弹窗、公开链接复制
- 公开商品页:无鉴权路由 /product/:public_id,Flutter Web SPA
- 商品详情列表(批次追踪)商品名超链接跳转详情页
- 导航「商品管理」改名「商品详情」
- 所有列表表格新增每页条数选择(10/20/50/100)
- 表格列头内嵌筛选(FilterableColumnHeader)
- 导出 Excel 功能(入库/出库/库存/财务/批次/往来单位)
- 网络恢复自动刷新 + 离线缓存展示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-27 00:29:51 +08:00
parent 5dd7c07138
commit 393e227de5
70 changed files with 4993 additions and 1169 deletions
@@ -3,9 +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/product.dart';
import '../../core/auth/auth_state.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/stock_out_provider.dart';
import '../../providers/warehouse_provider.dart';
@@ -26,7 +27,6 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
DateTime _orderDate = DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
// productId → available quantity in selected warehouse
Map<int, double> _inventoryMap = {};
final List<_ItemRow> _items = [];
@@ -46,9 +46,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _loadEditOrder() async {
setState(() => _loadingEdit = true);
try {
final order = await ref
.read(stockOutRepositoryProvider)
.get(widget.editOrderId!);
final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!);
final nameOpts = await ref.read(productNameListProvider.future);
final seriesOpts = await ref.read(productSeriesListProvider.future);
final specOpts = await ref.read(productSpecListProvider.future);
setState(() {
_warehouseId = order.warehouseId;
_partnerId = order.partnerId;
@@ -62,6 +64,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
row.productId = item.productId;
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id;
row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id;
row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id;
_items.add(row);
}
if (_items.isEmpty) _items.add(_ItemRow());
@@ -103,9 +108,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
.read(inventoryRepositoryProvider)
.listInventory(warehouseId: warehouseId, pageSize: 500);
setState(() {
_inventoryMap = {
for (final inv in result.data) inv.productId: inv.quantity
};
_inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity};
});
} catch (_) {}
}
@@ -138,6 +141,39 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
setState(() => _submitting = true);
final nameOpts = ref.read(productNameListProvider).valueOrNull ?? [];
final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? [];
final specOpts = ref.read(productSpecListProvider).valueOrNull ?? [];
for (final item in _items) {
if (item.productId == null) {
final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? '';
final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? '';
final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? '';
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
return;
}
try {
final product = await ref
.read(productRepositoryProvider)
.findOrCreate(name: name, series: series, spec: spec);
item.productId = product.id;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger),
);
setState(() => _submitting = false);
}
return;
}
}
}
final itemsData = _items.map((item) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
@@ -161,9 +197,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
try {
if (_isEdit) {
await ref
.read(stockOutRepositoryProvider)
.update(widget.editOrderId!, data);
await ref.read(stockOutRepositoryProvider).update(widget.editOrderId!, data);
ref.read(stockOutListProvider.notifier).reload();
} else {
await ref.read(stockOutListProvider.notifier).createOrder(data);
@@ -180,8 +214,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
@@ -193,6 +226,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
final asyncCustomers = ref.watch(customerListProvider);
final currentUser = ref.watch(authStateProvider).user;
return Scaffold(
backgroundColor: AppTheme.background,
@@ -211,8 +245,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
),
const SizedBox(width: 8),
Text(_isEdit ? '修改出库单' : '新建出库单',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
OutlinedButton(
onPressed: _submitting ? null : () => _submit(true),
@@ -225,8 +258,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.send, size: 16),
label: const Text('提交审核'),
),
@@ -243,228 +275,211 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
if (_loadingEdit)
const Expanded(child: Center(child: CircularProgressIndicator())),
if (!_loadingEdit)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
// Warehouse dropdown
_FormField(
label: '出库仓库',
required: true,
child: asyncWarehouses.when(
loading: () =>
const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (warehouses) =>
DropdownButtonFormField<int>(
value: _warehouseId,
hint: const Text('请选择仓库',
style:
TextStyle(fontSize: 13)),
items: warehouses
.map((w) => DropdownMenuItem(
value: w.id,
child: Text(w.name,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) {
setState(() => _warehouseId = v);
if (v != null) _loadInventory(v);
},
validator: (v) =>
v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
// Customer dropdown
_FormField(
label: '客户',
child: asyncCustomers.when(
loading: () =>
const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) =>
DropdownButtonFormField<int>(
value: _partnerId,
hint: const Text('请选择客户',
style:
TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) => setState(
() => _partnerId = v),
decoration: const InputDecoration(),
),
),
),
// Date picker
_FormField(
label: '出库日期',
required: true,
child: InkWell(
onTap: _pickDate,
child: InputDecorator(
decoration:
const InputDecoration(),
child: Row(
children: [
Expanded(
child: Text(
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 13),
),
),
const Icon(
Icons.calendar_today,
size: 16,
color:
AppTheme.textSecondary),
],
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_FormField(
label: '出库仓库',
required: true,
child: asyncWarehouses.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (warehouses) => DropdownButtonFormField<int>(
value: _warehouseId,
hint: const Text('请选择仓库',
style: TextStyle(fontSize: 13)),
items: warehouses
.map((w) => DropdownMenuItem(
value: w.id,
child: Text(w.name,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) {
setState(() => _warehouseId = v);
if (v != null) _loadInventory(v);
},
validator: (v) => v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
),
],
),
const SizedBox(height: 16),
_FormField(
label: '备注',
width: double.infinity,
child: TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
hintText: '选填,如有特殊说明请在此注明',
),
),
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('商品明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Spacer(),
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 16),
label: const Text('添加商品'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32)),
),
],
),
const SizedBox(height: 12),
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(3),
2: FlexColumnWidth(1.5),
3: FlexColumnWidth(1.5),
4: FlexColumnWidth(1.5),
5: FixedColumnWidth(60),
},
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(),
),
...List.generate(
_items.length,
(i) => _buildItemRow(i)),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('合计金额:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500)),
Text(
'¥${_totalAmount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.danger),
_FormField(
label: '客户',
child: asyncCustomers.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) => DropdownButtonFormField<int>(
value: _partnerId,
hint: const Text('请选择客户',
style: TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) => setState(() => _partnerId = v),
decoration: const InputDecoration(),
),
),
),
_FormField(
label: '出库员',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
currentUser?.realName ?? '-',
style: const TextStyle(fontSize: 13),
),
),
),
_FormField(
label: '出库日期',
required: true,
child: InkWell(
onTap: _pickDate,
child: InputDecorator(
decoration: const InputDecoration(),
child: Row(
children: [
Expanded(
child: Text(
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 13),
),
),
const Icon(Icons.calendar_today,
size: 16, color: AppTheme.textSecondary),
],
),
),
),
),
],
),
),
],
const SizedBox(height: 16),
_FormField(
label: '备注',
width: double.infinity,
child: TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
hintText: '选填,如有特殊说明请在此注明',
),
),
),
],
),
),
),
),
],
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('商品明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Spacer(),
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 16),
label: const Text('添加商品'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32)),
),
],
),
const SizedBox(height: 12),
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(2.2),
2: FlexColumnWidth(1.3),
3: FlexColumnWidth(1.3),
4: FlexColumnWidth(0.9),
5: FlexColumnWidth(1.0),
6: FlexColumnWidth(1.0),
7: FlexColumnWidth(1.0),
8: FlexColumnWidth(1.0),
9: FixedColumnWidth(60),
},
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(),
),
...List.generate(_items.length, (i) => _buildItemRow(i)),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('合计金额:',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
Text(
'¥${_totalAmount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.danger),
),
],
),
),
],
),
),
),
],
),
),
),
),
),
],
),
);
@@ -472,75 +487,129 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
TableRow _buildItemRow(int index) {
final item = _items[index];
final asyncProducts = ref.watch(productListProvider);
final asyncNames = ref.watch(productNameListProvider);
final asyncSeries = ref.watch(productSeriesListProvider);
final asyncSpecs = ref.watch(productSpecListProvider);
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
final amount = qty * price;
final specQty = asyncSpecs.valueOrNull
?.where((o) => o.id == item.selectedSpecId)
.firstOrNull
?.quantity ?? 0;
return TableRow(
decoration: BoxDecoration(
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
),
children: [
// 序号
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text('${index + 1}',
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
),
// 名称
Padding(
padding: const EdgeInsets.all(4),
child: asyncProducts.when(
child: asyncNames.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) => DropdownButtonFormField<int>(
value: item.productId,
hint: const Text('选择商品',
style: TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text('${p.name}${p.spec ?? p.unit}',
style: const TextStyle(fontSize: 13))))
data: (names) => DropdownButtonFormField<int>(
value: item.selectedNameId,
hint: const Text('选择名称', style: TextStyle(fontSize: 12)),
isExpanded: true,
items: names
.map((o) => DropdownMenuItem(
value: o.id,
child: Text(o.name,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) {
setState(() {
item.productId = v;
if (v != null) {
// Auto-fill sale price
final found = result.data.firstWhere(
(p) => p.id == v,
orElse: () => Product(
id: 0, code: '', name: '', unit: ''));
if (found.salePrice != null) {
item.priceCtrl.text =
found.salePrice!.toStringAsFixed(2);
}
// Auto-fill max available quantity from inventory
final available = _inventoryMap[v] ?? 0;
if (available > 0) {
item.qtyCtrl.text =
available.toStringAsFixed(0);
}
}
});
},
validator: (v) => v == null ? '不能为空' : null,
decoration: const InputDecoration(),
onChanged: (v) => setState(() {
item.selectedNameId = v;
item.productId = null;
}),
validator: (v) => v == null ? '请选择' : null,
decoration: const InputDecoration(isDense: true),
style: const TextStyle(fontSize: 12, color: Colors.black87),
),
),
),
// 系列
Padding(
padding: const EdgeInsets.all(4),
child: asyncSeries.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (series) => DropdownButtonFormField<int>(
value: item.selectedSeriesId,
hint: const Text('选择系列', style: TextStyle(fontSize: 12)),
isExpanded: true,
items: series
.map((o) => DropdownMenuItem(
value: o.id,
child: Text(o.name,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() {
item.selectedSeriesId = v;
item.productId = null;
}),
decoration: const InputDecoration(isDense: true),
style: const TextStyle(fontSize: 12, color: Colors.black87),
),
),
),
// 规格
Padding(
padding: const EdgeInsets.all(4),
child: asyncSpecs.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (specs) => DropdownButtonFormField<int>(
value: item.selectedSpecId,
hint: const Text('选择规格', style: TextStyle(fontSize: 12)),
isExpanded: true,
items: specs
.map((o) => DropdownMenuItem(
value: o.id,
child: Text(o.name,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setState(() {
item.selectedSpecId = v;
item.productId = null;
}),
validator: (v) => v == null ? '请选择' : null,
decoration: const InputDecoration(isDense: true),
style: const TextStyle(fontSize: 12, color: Colors.black87),
),
),
),
// 单品数量
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
specQty > 0 ? '$specQty' : '-',
style: TextStyle(
fontSize: 13,
color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary,
),
),
),
// 数量
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item.qtyCtrl,
decoration: const InputDecoration(hintText: '0'),
decoration: const InputDecoration(hintText: '0', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
@@ -550,18 +619,17 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
},
),
),
// 单价
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: item.priceCtrl,
decoration: const InputDecoration(
hintText: '0.00', prefixText: '¥'),
hintText: '0.00', prefixText: '¥', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
@@ -571,31 +639,54 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
},
),
),
// 金额
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
'¥${amount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
),
// 当前库存
_buildInventoryCell(item.productId),
// 操作
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: IconButton(
icon: const Icon(Icons.delete_outline,
size: 18, color: AppTheme.danger),
onPressed:
_items.length > 1 ? () => _removeItem(index) : null,
icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger),
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
tooltip: '删除',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 28, minHeight: 28),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
),
],
);
}
Widget _buildInventoryCell(int? productId) {
if (productId == null) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
);
}
final qty = _inventoryMap[productId];
final text = qty != null
? qty.toStringAsFixed(0)
: (_warehouseId == null ? '选仓库后显示' : '-');
final color = qty == null
? AppTheme.textSecondary
: qty <= 0
? AppTheme.danger
: AppTheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(text,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)),
);
}
Future<void> _pickDate() async {
final date = await showDatePicker(
context: context,
@@ -609,6 +700,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
class _ItemRow {
int? productId;
int? selectedNameId;
int? selectedSeriesId;
int? selectedSpecId;
final TextEditingController qtyCtrl = TextEditingController();
final TextEditingController priceCtrl = TextEditingController();
@@ -641,12 +735,9 @@ class _FormField extends StatelessWidget {
Row(
children: [
if (required)
const Text('*',
style:
TextStyle(color: AppTheme.danger, fontSize: 13)),
const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)),
Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
],
),
const SizedBox(height: 6),
@@ -9,6 +9,8 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart';
import '../../providers/inventory_provider.dart';
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -32,6 +34,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ColDef('amount', '金额', minWidth: 800),
ColDef('status', '状态'),
ColDef('date', '日期', minWidth: 900),
ColDef('operator', '出库员', minWidth: 1100),
ColDef('reviewer', '审核员', minWidth: 1100),
ColDef('actions', '操作', required: true),
];
@@ -136,8 +140,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return _buildOrderTable(
orders: orders,
totalCount: orders.length,
totalCount: result.total,
page: result.page,
pageSize: result.pageSize,
showStatusFilter: filterStatus == 'exclude_pending',
showNewButton: showNewButton,
warehouseOptions: warehouseOptions,
@@ -151,6 +156,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
required List<StockOutOrder> orders,
required int totalCount,
required int page,
required int pageSize,
required bool showStatusFilter,
required bool showNewButton,
required List<String> warehouseOptions,
@@ -206,6 +212,12 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
case 'date':
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
case 'operator':
return DataCell(Text(o.operatorName ?? '-',
style: const TextStyle(fontSize: 13)));
case 'reviewer':
return DataCell(Text(o.reviewerName ?? '-',
style: const TextStyle(fontSize: 13)));
case 'actions':
return DataCell(Row(
mainAxisSize: MainAxisSize.min,
@@ -282,8 +294,11 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
return DataTableCard(
totalCount: totalCount,
page: page,
pageSize: pageSize,
onPageChanged: (p) =>
ref.read(stockOutListProvider.notifier).setPage(p),
onPageSizeChanged: (s) =>
ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: Row(
children: [
if (showNewButton)
@@ -322,6 +337,19 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
],
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '出库单列表',
headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'],
rows: orders.map((o) => [
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
o.status, o.orderDate, o.totalAmount,
]).toList(),
),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
@@ -514,7 +542,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
// Detail dialog — fetches full order with items
class _StockOutDetailDialog extends StatefulWidget {
class _StockOutDetailDialog extends ConsumerStatefulWidget {
final int orderId;
final StockOutRepository repository;
@@ -524,16 +552,31 @@ class _StockOutDetailDialog extends StatefulWidget {
});
@override
State<_StockOutDetailDialog> createState() => _StockOutDetailDialogState();
ConsumerState<_StockOutDetailDialog> createState() => _StockOutDetailDialogState();
}
class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
late Future<StockOutOrder> _future;
Map<int, double> _inventoryMap = {};
@override
void initState() {
super.initState();
_future = widget.repository.get(widget.orderId);
_future = widget.repository.get(widget.orderId).then((order) async {
try {
final result = await ref
.read(inventoryRepositoryProvider)
.listInventory(warehouseId: order.warehouseId, pageSize: 500);
if (mounted) {
setState(() {
_inventoryMap = {
for (final inv in result.data) inv.productId: inv.quantity
};
});
}
} catch (_) {}
return order;
});
}
@override
@@ -635,40 +678,82 @@ class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
border: TableBorder.all(color: AppTheme.border, width: 0.5),
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(3),
1: FlexColumnWidth(2.5),
2: FlexColumnWidth(1.5),
3: FlexColumnWidth(1.5),
4: FlexColumnWidth(1.5),
5: FlexColumnWidth(1.5),
6: FlexColumnWidth(1.5),
7: FlexColumnWidth(1.2),
},
children: [
TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: ['序号', '商品', '规格', '数量', '单价', '金额']
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存']
.asMap()
.entries
.map((e) {
final i = e.key;
final h = e.value;
// 第一个格子用 Padding 提供行高参照
if (i == 0) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
))
);
}
return TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Container(
color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent,
child: Center(
child: Text(h,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)),
),
),
);
})
.toList(),
),
...o.items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
final qty = item.productId != null ? _inventoryMap[item.productId] : null;
final invColor = qty == null
? AppTheme.textSecondary
: qty <= 0
? AppTheme.danger
: AppTheme.primary;
return TableRow(
decoration: BoxDecoration(
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
children: [
_TableCell('${i + 1}'),
_TableCell(item.productName ?? '-'),
_TableCell(item.productSeries ?? '-'),
_TableCell(item.productSpec ?? '-'),
_TableCell(item.quantity.toStringAsFixed(3)),
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Container(
color: const Color(0xFFEBF3FF),
child: Center(
child: Text(
qty != null ? qty.toStringAsFixed(0) : '-',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor),
),
),
),
),
],
);
}),