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:
wangjia
2026-05-23 14:05:41 +08:00
parent c77e1c1490
commit c1ed81dfab
60 changed files with 4664 additions and 1572 deletions
@@ -1,411 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
import '../../providers/connectivity_provider.dart';
import '../../core/utils/export_util.dart';
class BatchTrackingScreen extends ConsumerStatefulWidget {
const BatchTrackingScreen({super.key});
@override
ConsumerState<BatchTrackingScreen> createState() =>
_BatchTrackingScreenState();
}
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
int _page = 1;
int _pageSize = 20;
int _total = 0;
late Future<List<ProductTrackingRecord>> _future;
List<ProductTrackingRecord> _records = [];
Set<String> _filterStatus = {};
Set<String> _filterWarehouse = {};
Set<String> _filterSupplier = {};
Set<String> _hiddenCols = {};
static const _colDefs = [
ColDef('product', '商品', required: true),
ColDef('spec', '规格', minWidth: 1100),
ColDef('batch', '批次号', minWidth: 1000),
ColDef('order_no', '入库单号', minWidth: 900),
ColDef('supplier', '供应商', minWidth: 1100),
ColDef('warehouse', '仓库'),
ColDef('date', '入库日期', minWidth: 1000),
ColDef('qty', '数量'),
ColDef('price', '单价', minWidth: 900),
ColDef('status', '状态'),
ColDef('buyer', '买家/时间'),
];
@override
void initState() {
super.initState();
_fetch();
}
void _fetch() {
_future = ref
.read(inventoryRepositoryProvider)
.listProducts(page: _page, pageSize: _pageSize)
.then((r) {
_total = r.total;
_records = r.data;
return r.data;
});
}
void _refetch() => setState(() => _fetch());
List<ProductTrackingRecord> _applyFilters(
List<ProductTrackingRecord> all) {
return all.where((r) {
if (_filterStatus.isNotEmpty) {
final label = r.isSoldOut ? '已卖出' : '在售';
if (!_filterStatus.contains(label)) return false;
}
if (_filterWarehouse.isNotEmpty) {
if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false;
}
if (_filterSupplier.isNotEmpty) {
if (!_filterSupplier.contains(r.supplierName ?? '')) return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
// 网络恢复时自动刷新
ref.listen(networkRecoveryCountProvider, (_, __) => _refetch());
return FutureBuilder<List<ProductTrackingRecord>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
// 有缓存数据时展示缓存,顶部加提示条
if (_records.isNotEmpty) {
return Column(
children: [
_OfflineBanner(onRetry: _refetch),
Expanded(child: _buildTable(_applyFilters(_records))),
],
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
],
),
);
}
final filtered = _applyFilters(_records);
return _buildTable(filtered);
},
);
}
Widget _buildTable(List<ProductTrackingRecord> records) {
// Derive filter options from all loaded records
final warehouseOptions = _records
.map((r) => r.warehouseName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
final supplierOptions = _records
.map((r) => r.supplierName ?? '')
.where((s) => s.isNotEmpty)
.toSet()
.toList()
..sort();
// Build visible columns (respect manual hide + responsive auto-hide)
final screenWidth = MediaQuery.of(context).size.width;
final visibleCols = _colDefs
.where((c) =>
!_hiddenCols.contains(c.key) &&
(c.minWidth == null || screenWidth >= c.minWidth!))
.toList();
final columns = visibleCols.map((c) {
final label = switch (c.key) {
'status' => FilterableColumnHeader(
text: c.label,
options: const ['在售', '已卖出'],
selected: _filterStatus,
onChanged: (v) => setState(() => _filterStatus = v),
),
'warehouse' => FilterableColumnHeader(
text: c.label,
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) => setState(() => _filterWarehouse = v),
),
'supplier' => FilterableColumnHeader(
text: c.label,
options: supplierOptions,
selected: _filterSupplier,
onChanged: (v) => setState(() => _filterSupplier = v),
),
_ => Text(c.label),
};
return DataColumn(
label: label,
numeric: c.key == 'qty' || c.key == 'price');
}).toList();
final rows = records.isEmpty
? [
DataRow(
cells: List.generate(
visibleCols.length,
(i) => i == 1
? const DataCell(Text('暂无记录',
style: TextStyle(color: AppTheme.textSecondary)))
: const DataCell(SizedBox()),
),
),
]
: records
.map((r) => DataRow(
cells: visibleCols
.map((c) => _buildCell(c.key, r))
.toList(),
))
.toList();
return DataTableCard(
totalCount: _total,
page: _page,
pageSize: _pageSize,
onPageChanged: (p) => setState(() {
_page = p;
_fetch();
}),
onPageSizeChanged: (s) => setState(() {
_pageSize = s;
_page = 1;
_fetch();
}),
toolbar: Row(
children: [
const Text('已审核入库商品(含库存与销售状态)',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '商品管理',
headers: ['商品名称', '商品编码', '规格', '批次号', '入库单号', '供应商', '仓库', '入库日期', '数量', '单价', '状态', '买家'],
rows: records.map((r) => [
r.productName ?? '',
r.productCode ?? '',
r.productSpec ?? '',
r.batchNo ?? '',
r.orderNo ?? '',
r.supplierName ?? '',
r.warehouseName ?? '',
r.orderDate?.substring(0, 10) ?? '',
r.quantity.toInt(),
r.unitPrice,
r.isSoldOut ? '已卖出' : '在售',
r.buyerName ?? '',
]).toList(),
),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: () => setState(() {
_page = 1;
_fetch();
}),
tooltip: '刷新',
),
const SizedBox(width: 4),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
],
),
columns: columns,
rows: rows,
);
}
DataCell _buildCell(String key, ProductTrackingRecord r) {
final batchText =
(r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null;
switch (key) {
case 'product':
return DataCell(
GestureDetector(
onTap: () => context.push('/products/${r.productId}'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(r.productName ?? '-',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary)),
if (r.productCode != null)
Text(r.productCode!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
fontFamily: 'monospace')),
],
),
),
);
case 'spec':
return DataCell(Text(r.productSpec ?? '-',
style: const TextStyle(fontSize: 12)));
case 'batch':
return 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)));
case 'order_no':
return DataCell(Text(r.orderNo ?? '-',
style: const TextStyle(
fontSize: 11,
color: AppTheme.primary,
fontFamily: 'monospace')));
case 'supplier':
return DataCell(Text(r.supplierName ?? '-',
style: const TextStyle(fontSize: 12)));
case 'warehouse':
return DataCell(Text(r.warehouseName ?? '-',
style: const TextStyle(fontSize: 12)));
case 'date':
return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-',
style: const TextStyle(fontSize: 12)));
case 'qty':
return DataCell(Text(
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
style: const TextStyle(fontWeight: FontWeight.w500)));
case 'price':
return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13)));
case 'status':
return DataCell(_StatusBadge(r.isSoldOut));
case 'buyer':
return 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)));
default:
return const DataCell(SizedBox());
}
}
}
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,
),
),
);
}
}
class _OfflineBanner extends StatelessWidget {
final VoidCallback onRetry;
const _OfflineBanner({required this.onRetry});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: const Color(0xFFFFF8E1),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
const SizedBox(width: 8),
const Expanded(
child: Text('网络不可用,当前显示离线缓存数据',
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFF57F17),
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
],
),
);
}
}
@@ -53,6 +53,7 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
.read(inventoryRepositoryProvider)
.listInventory(warehouseId: wh.id, pageSize: 200);
final items = result.data
.where((inv) => inv.productId != null)
.map((inv) => _CheckItem(inventory: inv))
.toList();
if (mounted) {
@@ -89,7 +90,7 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
final actual =
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
return {
'product_id': item.inventory.productId,
'product_id': item.inventory.productId!,
'actual_qty': actual,
'remark': item.remarkCtrl.text.trim(),
};
@@ -455,7 +456,7 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productCode ?? '-',
child: Text(inv.productCode.isEmpty ? '-' : inv.productCode,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -464,14 +465,14 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productName ?? '-',
child: Text(inv.productName.isEmpty ? '-' : inv.productName,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis),
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(inv.productUnit ?? '-',
child: Text(inv.unit.isEmpty ? '-' : inv.unit,
style: const TextStyle(fontSize: 13)),
),
Padding(
@@ -13,6 +13,10 @@ import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/product_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
class InventoryListScreen extends ConsumerStatefulWidget {
const InventoryListScreen({super.key});
@@ -41,6 +45,50 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
});
}
Future<void> _editRemark(BuildContext context, Inventory item) async {
final ctrl = TextEditingController(text: item.remark);
final saved = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改备注'),
content: SizedBox(
width: 360,
child: TextField(
controller: ctrl,
autofocus: true,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入备注内容…',
border: OutlineInputBorder(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, ctrl.text),
child: const Text('保存'),
),
],
),
);
ctrl.dispose();
if (saved == null || !context.mounted) return;
try {
await ref.read(inventoryRepositoryProvider).updateRemark(item.id, saved);
ref.read(inventoryListProvider.notifier).reload();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger),
);
}
}
}
Future<void> _importInventory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
@@ -94,6 +142,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Widget build(BuildContext context) {
return PageScaffold(
title: '库存管理',
initialTab: ref.read(inventoryTabProvider),
onTabChanged: (i) => ref.read(inventoryTabProvider.notifier).state = i,
tabs: const [
Tab(text: '库存查询'),
Tab(text: '库存预警'),
@@ -116,10 +166,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
Text('加载失败:$e',
style: const TextStyle(color: AppTheme.textSecondary),
textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
@@ -137,7 +188,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
// 仓库选项从数据中派生,客户端筛选
final warehouseOptions = items
.map((i) => i.warehouseName ?? '')
.map((i) => i.warehouseName)
.where((s) => s.isNotEmpty)
.toSet()
.toList()
@@ -145,7 +196,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final filteredItems = _filterWarehouse.isEmpty
? items
: items
.where((i) => _filterWarehouse.contains(i.warehouseName ?? ''))
.where((i) => _filterWarehouse.contains(i.warehouseName))
.toList();
return Column(
@@ -158,7 +209,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
children: [
_SummaryCard(
title: '商品总数',
value: '${items.length}',
value: '${result.total}',
unit: '',
icon: Icons.inventory_2,
color: AppTheme.primary),
@@ -197,16 +248,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _importInventory(context, ref),
icon: const Icon(Icons.upload_file, size: 16),
label: const Text('导入库存'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
headers: ['商品编码', '商品名称', '品牌', '规格', '仓库', '库存', '安全库存', '状态'],
headers: ['商品编码', '商品名称', '规格', '批次号', '仓库', '库存', '单价', '生产日期', '供应商', '安全库存', '状态'],
rows: filteredItems.map((item) {
final status = item.quantity == 0
? '缺货'
@@ -214,12 +259,15 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? '库存不足'
: '正常';
return [
item.productCode ?? '',
item.productName ?? '',
item.productBrand ?? '',
item.productSpec ?? '',
item.warehouseName ?? '',
item.quantity.toInt(),
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.spec.isEmpty ? '' : item.spec,
item.batchNo.isEmpty ? '' : item.batchNo,
item.warehouseName.isEmpty ? '' : item.warehouseName,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.supplierName.isEmpty ? '' : item.supplierName,
item.minStock ?? '',
status,
];
@@ -246,8 +294,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
columns: [
const DataColumn(label: Text('商品编码')),
const DataColumn(label: Text('商品名称')),
const DataColumn(label: Text('品牌')),
const DataColumn(label: Text('规格')),
const DataColumn(label: Text('系列')),
const DataColumn(label: Text('批次号')),
DataColumn(
label: FilterableColumnHeader(
text: '仓库',
@@ -256,9 +305,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
onChanged: (v) => setState(() => _filterWarehouse = v),
),
),
const DataColumn(label: Text('库存'), numeric: true),
const DataColumn(label: Text('安全库存'), numeric: true),
const DataColumn(label: Text('库存'), numeric: true),
const DataColumn(label: Text('单价'), numeric: true),
const DataColumn(label: Text('生产日期')),
const DataColumn(label: Text('供应商')),
const DataColumn(label: Text('备注')),
const DataColumn(label: Text('状态')),
const DataColumn(label: Text('操作')),
],
rows: items.isEmpty
? [
@@ -273,6 +326,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: filteredItems
@@ -290,25 +349,36 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}),
cells: [
DataCell(Text(
item.productCode ?? '-',
item.productCode.isEmpty ? '-' : item.productCode,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(SizedBox(
width: 180,
child: Text(
item.productName ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(
Text(item.productBrand ?? '-')),
DataCell(
Text(item.productSpec ?? '-')),
DataCell(
Text(item.warehouseName ?? '-')),
DataCell(item.productId != null
? GestureDetector(
onTap: () => context.push('/products/${item.productId}'),
child: SizedBox(
width: 180,
child: Text(
item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary,
),
),
),
)
: Text(item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis)),
DataCell(Text(item.spec.isEmpty ? '-' : item.spec)),
DataCell(Text(item.series.isEmpty ? '-' : item.series)),
DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
DataCell(Text(
item.quantity.toStringAsFixed(0),
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: item.quantity == 0
@@ -319,10 +389,79 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? AppTheme.accent
: AppTheme.textPrimary),
)),
DataCell(Text(item.minStock != null
? '${item.minStock}'
: '-')),
DataCell(Text(
item.unitPrice != null
? '¥${item.unitPrice!.toStringAsFixed(2)}'
: '-',
)),
DataCell(Text(item.productionDate ?? '-')),
DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)),
DataCell(
Tooltip(
message: item.remark.isEmpty ? '' : item.remark,
waitDuration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => _editRemark(context, item),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.remark.isEmpty
? ''
: item.remark.length > 4
? '${item.remark.substring(0, 4)}'
: item.remark,
style: TextStyle(
color: item.remark.isEmpty
? AppTheme.textSecondary
: AppTheme.textPrimary,
),
),
const SizedBox(width: 4),
const Icon(Icons.edit_outlined,
size: 12, color: AppTheme.textSecondary),
],
),
),
),
),
DataCell(_InventoryStatusBadge(item)),
DataCell(
item.productId != null
? TextButton(
onPressed: () async {
try {
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打印失败:$e'),
backgroundColor: AppTheme.danger),
);
}
}
},
child: const Text('打标签',
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
)
: const SizedBox(),
),
],
))
.toList(),
@@ -342,10 +481,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
Text('加载失败:$e',
style: const TextStyle(color: AppTheme.textSecondary),
textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
@@ -379,9 +519,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
filename: '库存预警',
headers: ['商品编码', '商品名称', '仓库', '当前库存', '安全库存', '缺口', '状态'],
rows: warnings.map((item) => [
item.productCode ?? '',
item.productName ?? '',
item.warehouseName ?? '',
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.warehouseName.isEmpty ? '' : item.warehouseName,
item.quantity.toInt(),
item.minStock ?? 0,
item.minStock! - item.quantity.toInt(),
@@ -423,17 +563,29 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? AppTheme.danger.withOpacity(0.05)
: AppTheme.accent.withOpacity(0.04)),
cells: [
DataCell(Text(item.productCode ?? '-',
DataCell(Text(
item.productCode.isEmpty ? '-' : item.productCode,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12))),
DataCell(SizedBox(
width: 180,
child: Text(item.productName ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(
Text(item.warehouseName ?? '-')),
DataCell(item.productId != null
? GestureDetector(
onTap: () => context.push('/products/${item.productId}'),
child: SizedBox(
width: 180,
child: Text(
item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppTheme.primary,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primary,
)),
),
)
: Text(item.productName.isEmpty ? '-' : item.productName,
overflow: TextOverflow.ellipsis)),
DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
DataCell(Text(
item.quantity.toStringAsFixed(0),
style: TextStyle(
@@ -466,10 +618,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
Text('加载失败:$e',
style: const TextStyle(color: AppTheme.textSecondary),
textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>