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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user