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:
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user