feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端 - 新增 shop handler:GET/PUT /shop/info(管理员权限) - 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款 - 新增 inventory UpdateRemark:PUT /inventory/:id/remark - 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制) - 种子数据 S001-S003 补充真实门店信息 前端 - 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人 - 入库单列表新增结清按钮(含确认弹窗),出库单同步 - 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填 - 入库/出库列表新增入库时间、出库时间、创建时间列 - 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」 - 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define) - 库存备注支持点击编辑,超4字截断显示+Hover展示全文 - ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS) 文档 - 新增 docs/user-manual.md 完整用户操作手册(12章) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
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;
|
||||
if (item.batchNo != null) row.batchNoCtrl.text = item.batchNo!;
|
||||
if (item.productionDate != null) {
|
||||
row.productionDateCtrl.text = item.productionDate!.length >= 10
|
||||
? item.productionDate!.substring(0, 10)
|
||||
: item.productionDate!;
|
||||
row.productionDate = DateTime.tryParse(item.productionDate!);
|
||||
}
|
||||
_items.add(row);
|
||||
}
|
||||
if (_items.isEmpty) _items.add(_ItemRow());
|
||||
@@ -112,7 +119,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
.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.where((inv) => inv.productId != null)) inv.productId!: inv.quantity};
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -147,6 +154,53 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
final invalidQtyIndex = _items.indexWhere(
|
||||
(item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0);
|
||||
if (invalidQtyIndex >= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('第 ${invalidQtyIndex + 1} 行数量必须大于 0'),
|
||||
backgroundColor: AppTheme.danger,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!asDraft) {
|
||||
if (_partnerId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择供应商'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
final item = _items[i];
|
||||
if (item.selectedNameId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('第 ${i + 1} 行请选择商品名称'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (item.selectedSeriesId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('第 ${i + 1} 行请选择系列'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (item.selectedSpecId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('第 ${i + 1} 行请选择规格'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (item.productionDateCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('第 ${i + 1} 行请填写生产日期'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
|
||||
@@ -187,11 +241,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
final batchNo = item.batchNoCtrl.text.trim();
|
||||
final productionDate = item.productionDateCtrl.text.trim();
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'total_price': qty * price,
|
||||
if (batchNo.isNotEmpty) 'batch_no': batchNo,
|
||||
if (productionDate.isNotEmpty) 'production_date': productionDate,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
@@ -344,6 +402,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
),
|
||||
_FormField(
|
||||
label: '供应商',
|
||||
required: true,
|
||||
child: asyncSuppliers.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
@@ -358,6 +417,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _partnerId = v),
|
||||
validator: (v) => v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
@@ -439,22 +499,24 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
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),
|
||||
0: FixedColumnWidth(36), // 序号
|
||||
1: FlexColumnWidth(1.2), // 商品编码
|
||||
2: FlexColumnWidth(2.0), // 名称
|
||||
3: FlexColumnWidth(1.3), // 系列
|
||||
4: FlexColumnWidth(1.3), // 规格
|
||||
5: FlexColumnWidth(0.9), // 单品数量
|
||||
6: FlexColumnWidth(1.0), // 数量
|
||||
7: FlexColumnWidth(1.0), // 单价
|
||||
8: FlexColumnWidth(1.0), // 金额
|
||||
9: FlexColumnWidth(1.2), // 批次号
|
||||
10: FlexColumnWidth(1.2), // 生产日期
|
||||
11: FixedColumnWidth(60), // 操作
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: [
|
||||
'序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作',
|
||||
'序号', '商品编码', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '批次号', '生产日期', '操作',
|
||||
]
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -515,6 +577,10 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
?.where((o) => o.id == item.selectedSpecId)
|
||||
.firstOrNull
|
||||
?.quantity ?? 0;
|
||||
final productCode = asyncNames.valueOrNull
|
||||
?.where((o) => o.id == item.selectedNameId)
|
||||
.firstOrNull
|
||||
?.code ?? '';
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
@@ -527,6 +593,12 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
// 商品编码
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
child: Text(productCode,
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
||||
),
|
||||
// 名称
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
@@ -561,6 +633,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
selectedId: item.selectedSeriesId,
|
||||
hint: '选择系列',
|
||||
dialogTitle: '选择系列',
|
||||
isRequired: true,
|
||||
onChanged: (v) => setState(() {
|
||||
item.selectedSeriesId = v;
|
||||
item.productId = null;
|
||||
@@ -647,8 +720,51 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
// 当前库存
|
||||
_buildInventoryCell(item.productId),
|
||||
// 批次号
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item.batchNoCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '选填',
|
||||
labelText: '批次号',
|
||||
isDense: true,
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
// 生产日期
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item.productionDateCtrl,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '请选择',
|
||||
labelText: '* 生产日期',
|
||||
isDense: true,
|
||||
suffixIcon: Icon(Icons.calendar_today, size: 14),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: item.productionDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
locale: const Locale('zh', 'CN'),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
item.productionDate = date;
|
||||
item.productionDateCtrl.text =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// 操作
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
@@ -693,6 +809,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
initialDate: _orderDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
locale: const Locale('zh', 'CN'),
|
||||
);
|
||||
if (date != null) setState(() => _orderDate = date);
|
||||
}
|
||||
@@ -703,12 +820,17 @@ class _ItemRow {
|
||||
int? selectedNameId;
|
||||
int? selectedSeriesId;
|
||||
int? selectedSpecId;
|
||||
final TextEditingController qtyCtrl = TextEditingController();
|
||||
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
||||
final TextEditingController priceCtrl = TextEditingController();
|
||||
final TextEditingController batchNoCtrl = TextEditingController();
|
||||
final TextEditingController productionDateCtrl = TextEditingController();
|
||||
DateTime? productionDate;
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
priceCtrl.dispose();
|
||||
batchNoCtrl.dispose();
|
||||
productionDateCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -10,7 +11,13 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../core/utils/print_util.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../providers/tab_state_provider.dart';
|
||||
import '../../providers/product_provider.dart' show productRepositoryProvider;
|
||||
import '../../repositories/product_repository.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
@@ -33,6 +40,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ColDef('amount', '金额', minWidth: 800),
|
||||
ColDef('status', '状态'),
|
||||
ColDef('date', '日期', minWidth: 900),
|
||||
ColDef('reviewed_at', '入库时间', minWidth: 900),
|
||||
ColDef('operator', '入库员', minWidth: 1100),
|
||||
ColDef('reviewer', '审核员', minWidth: 1100),
|
||||
ColDef('actions', '操作', required: true),
|
||||
@@ -63,13 +71,15 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '入库管理',
|
||||
initialTab: ref.read(stockInTabProvider),
|
||||
onTabChanged: (i) => ref.read(stockInTabProvider.notifier).state = i,
|
||||
tabs: const [
|
||||
Tab(text: '入库审核'),
|
||||
Tab(text: '入库单'),
|
||||
Tab(text: '入库审核'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildListTab(filterStatus: 'pending', showNewButton: true),
|
||||
_buildListTab(filterStatus: 'exclude_pending', showNewButton: false),
|
||||
_buildListTab(filterStatus: 'pending', showNewButton: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -211,6 +221,12 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
return DataCell(StatusBadge(_apiStatusToEnum(o.status)));
|
||||
case 'date':
|
||||
return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-'));
|
||||
case 'reviewed_at':
|
||||
return DataCell(Text(o.reviewedAt != null
|
||||
? o.reviewedAt!.length >= 16
|
||||
? o.reviewedAt!.substring(0, 16)
|
||||
: o.reviewedAt!.substring(0, 10)
|
||||
: '-'));
|
||||
case 'operator':
|
||||
return DataCell(Text(o.operatorName ?? '-',
|
||||
style: const TextStyle(fontSize: 13)));
|
||||
@@ -218,7 +234,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
return DataCell(Text(o.reviewerName ?? '-',
|
||||
style: const TextStyle(fontSize: 13)));
|
||||
case 'actions':
|
||||
return DataCell(Row(
|
||||
return DataCell(SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
@@ -227,6 +245,40 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
await printStockInOrder(order);
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (!context.mounted) return;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => _LabelPrintDialog(
|
||||
order: order,
|
||||
productRepo: ref.read(productRepositoryProvider),
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'approved') ...[
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
],
|
||||
if (o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-in/edit/${o.id}'),
|
||||
@@ -264,7 +316,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
),
|
||||
],
|
||||
],
|
||||
));
|
||||
)));
|
||||
default:
|
||||
return const DataCell(SizedBox());
|
||||
}
|
||||
@@ -362,7 +414,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _showDetail(BuildContext context, int orderId) async {
|
||||
showDialog(
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _StockInDetailDialog(
|
||||
orderId: orderId,
|
||||
@@ -386,6 +438,38 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmSettle(BuildContext context, int orderId, String refType) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('结清确认'),
|
||||
content: const Text('确认将该单据的账款标记为已结清?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('确认结清', style: TextStyle(color: AppTheme.accent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
try {
|
||||
await ref.read(financeRepositoryProvider).closeByRef(refType, orderId);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已结清'), backgroundColor: AppTheme.success),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString()), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -553,11 +637,13 @@ class _StockInDetailDialog extends ConsumerStatefulWidget {
|
||||
class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
late Future<StockInOrder> _future;
|
||||
Map<int, double> _inventoryMap = {};
|
||||
StockInOrder? _loadedOrder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.get(widget.orderId).then((order) async {
|
||||
if (mounted) setState(() => _loadedOrder = order);
|
||||
try {
|
||||
final result = await ref
|
||||
.read(inventoryRepositoryProvider)
|
||||
@@ -565,7 +651,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inventoryMap = {
|
||||
for (final inv in result.data) inv.productId: inv.quantity
|
||||
for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -600,6 +686,12 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white)),
|
||||
const Spacer(),
|
||||
if (_loadedOrder != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.print_outlined, color: Colors.white),
|
||||
tooltip: '打印',
|
||||
onPressed: () => printStockInOrder(_loadedOrder!),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
@@ -674,71 +766,38 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
||||
border: TableBorder.all(color: AppTheme.border, width: 0.5),
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(2.5),
|
||||
2: FlexColumnWidth(1.5),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(2.2),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
6: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.2),
|
||||
6: FlexColumnWidth(1.2),
|
||||
7: FlexColumnWidth(1.2),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存']
|
||||
.asMap().entries.map((e) {
|
||||
final i = e.key; final h = e.value;
|
||||
if (i == 0) {
|
||||
return Padding(
|
||||
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)),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
)).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.productCode ?? '-'),
|
||||
_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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -837,3 +896,149 @@ class _StatusFilterDropdown extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabelPrintDialog extends StatefulWidget {
|
||||
final StockInOrder order;
|
||||
final ProductRepository productRepo;
|
||||
final String shopName;
|
||||
final String shopAddress;
|
||||
final String shopPhone;
|
||||
const _LabelPrintDialog({
|
||||
required this.order,
|
||||
required this.productRepo,
|
||||
this.shopName = '',
|
||||
this.shopAddress = '',
|
||||
this.shopPhone = '',
|
||||
});
|
||||
|
||||
@override
|
||||
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
|
||||
}
|
||||
|
||||
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
|
||||
late final List<bool> _selected;
|
||||
bool _printing = false;
|
||||
String _status = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = List.filled(widget.order.items.length, true);
|
||||
}
|
||||
|
||||
Future<void> _print() async {
|
||||
setState(() { _printing = true; _status = '正在打印...'; });
|
||||
int done = 0;
|
||||
for (int i = 0; i < widget.order.items.length; i++) {
|
||||
if (!_selected[i]) continue;
|
||||
final item = widget.order.items[i];
|
||||
try {
|
||||
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
|
||||
await printProductLabel(
|
||||
qrBytes: qrBytes,
|
||||
name: item.productName ?? '',
|
||||
code: item.productCode ?? '',
|
||||
series: item.productSeries,
|
||||
spec: item.productSpec,
|
||||
batchNo: item.batchNo,
|
||||
productionDate: item.productionDate,
|
||||
shopName: widget.shopName,
|
||||
shopAddress: widget.shopAddress,
|
||||
shopPhone: widget.shopPhone,
|
||||
);
|
||||
done++;
|
||||
if (mounted) setState(() => _status = '已打印 $done 张...');
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e');
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done 张'; });
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = widget.order.items;
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 520,
|
||||
constraints: const BoxConstraints(maxHeight: 520),
|
||||
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: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
final item = items[i];
|
||||
return CheckboxListTile(
|
||||
value: _selected[i],
|
||||
onChanged: _printing
|
||||
? null
|
||||
: (v) => setState(() => _selected[i] = v ?? false),
|
||||
title: Text(
|
||||
'${item.productCode ?? ''} ${item.productName ?? ''}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_status.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(_status,
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
|
||||
icon: const Icon(Icons.print_outlined, size: 16),
|
||||
label: Text(_printing ? '打印中...' : '打印选中'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user