diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart index 49818f1..c2de4e0 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -11,6 +11,7 @@ class InventoryRepository { Future> listInventory({ int? warehouseId, + int? productId, String? keyword, List? series, List? spec, @@ -22,6 +23,7 @@ class InventoryRepository { 'page': page, 'page_size': pageSize, if (warehouseId != null) 'warehouse_id': warehouseId, + if (productId != null) 'product_id': productId, if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, if (series != null && series.isNotEmpty) 'series': series.join(','), if (spec != null && spec.isNotEmpty) 'spec': spec.join(','), diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index e2fcb07..b032924 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -12,10 +12,15 @@ import '../../widgets/label_preview_dialog.dart'; import '../../widgets/fullscreen_image_viewer.dart'; import '../../widgets/write_guard.dart'; import '../../core/theme/context_tokens.dart'; +import '../../core/theme/app_dims.g.dart'; +import '../../core/responsive/responsive.dart'; import '../../models/product.dart'; import '../../models/product_image.dart'; +import '../../models/inventory.dart'; import '../../providers/product_provider.dart'; +import '../../providers/inventory_provider.dart'; import '../../providers/shop_provider.dart' show shopInfoProvider; +import '../../widgets/kpi_card.dart'; class ProductDetailScreen extends ConsumerStatefulWidget { final int productId; @@ -33,6 +38,10 @@ class _ProductDetailScreenState extends ConsumerState { bool _savingDesc = false; bool _descChanged = false; + // 富数据(库存聚合 + 流水),随详情异步加载,失败不阻塞主数据。 + List _invRows = []; + List _logs = []; + late TextEditingController _descCtrl; @override @@ -60,10 +69,30 @@ class _ProductDetailScreenState extends ConsumerState { _product = p; _descCtrl.text = p.description ?? ''; _descChanged = false; + _loadStats(); return p; }); } + /// 拉本商品的库存聚合 + 流水(fire-and-forget,失败不影响主数据展示)。 + Future _loadStats() async { + try { + final repo = ref.read(inventoryRepositoryProvider); + final inv = + await repo.listInventory(productId: widget.productId, pageSize: 1000); + final logs = + await repo.listLogs(productId: widget.productId, pageSize: 100); + if (mounted) { + setState(() { + _invRows = inv.data; + _logs = logs.data; + }); + } + } catch (_) { + // 富数据失败静默——详情主数据照常展示 + } + } + void _reload() => setState(() => _load()); Future _saveDesc() async { @@ -273,9 +302,19 @@ class _ProductDetailScreenState extends ConsumerState { ], ), const SizedBox(height: 20), + _buildStatKpis(p), + const SizedBox(height: 20), _buildImageSection(p), const SizedBox(height: 20), _buildInfoSection(p), + if (_invRows.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildWarehouseDist(p), + ], + if (_logs.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildRecentLogs(), + ], if (p.salePrice != null && p.salePrice! > 0) ...[ const SizedBox(height: 20), _buildPriceSection(p), @@ -296,6 +335,164 @@ class _ProductDetailScreenState extends ConsumerState { ); } + // ── 富数据:KPI / 各仓库分布 / 近期流水(还原原型详情页) ────────────── + + Widget _statCard({required String title, Widget? hint, required Widget body}) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDims.rLg), + side: BorderSide(color: context.tokens.border, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Text(title, + style: TextStyle( + fontSize: AppDims.fsTitle, + fontWeight: FontWeight.w600, + color: context.tokens.heading)), + const Spacer(), + if (hint != null) hint, + ], + ), + ), + const Divider(height: 1), + body, + ], + ), + ); + } + + Widget _buildStatKpis(Product p) { + final currentStock = _invRows.fold(0, (s, i) => s + i.quantity); + final cost = p.purchasePrice ?? 0; + final stockValue = currentStock * cost; + final now = DateTime.now(); + final out30 = _logs.where((l) { + if (l.direction != 'out') return false; + final d = DateTime.tryParse(l.createdAt ?? ''); + return d != null && now.difference(d).inDays <= 30; + }).fold(0, (s, l) => s + l.quantity); + + final cards = [ + KpiCard( + title: '当前库存', + value: currentStock.toStringAsFixed(0), + icon: Icons.inventory_2, + tone: KpiTone.info, + delta: '件'), + KpiCard( + title: '库存货值', + value: '¥${(stockValue / 10000).toStringAsFixed(2)}万', + icon: Icons.account_balance_wallet, + tone: KpiTone.ok), + KpiCard( + title: '近30天出库', + value: out30.toStringAsFixed(0), + icon: Icons.upload_outlined, + tone: KpiTone.blue, + delta: '件'), + ]; + final mobile = context.isMobile; + final row = []; + for (var i = 0; i < cards.length; i++) { + if (i > 0) row.add(const SizedBox(width: AppDims.sp3)); + row.add(mobile + ? SizedBox(width: 150, child: cards[i]) + : Expanded(child: cards[i])); + } + return mobile + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicHeight(child: Row(children: row))) + : IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: row)); + } + + Widget _buildWarehouseDist(Product p) { + final cost = p.purchasePrice ?? 0; + final byWh = {}; + for (final inv in _invRows) { + final wh = inv.warehouseName.isEmpty ? '—' : inv.warehouseName; + byWh[wh] = (byWh[wh] ?? 0) + inv.quantity; + } + final total = byWh.values.fold(0, (s, v) => s + v); + return _statCard( + title: '各仓库库存分布', + hint: Text('合计 ${total.toStringAsFixed(0)} 件', + style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)), + body: Column( + children: byWh.entries.map((e) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded(child: Text(e.key)), + SizedBox( + width: 80, + child: Text('${e.value.toStringAsFixed(0)} 件', + textAlign: TextAlign.right)), + SizedBox( + width: 120, + child: Text( + '¥${((e.value * cost) / 10000).toStringAsFixed(2)}万', + textAlign: TextAlign.right, + style: TextStyle(color: context.tokens.muted))), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _buildRecentLogs() { + return _statCard( + title: '近期流水', + hint: Text('最近 ${_logs.length > 8 ? 8 : _logs.length} 条', + style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)), + body: Column( + children: _logs.take(8).map((l) { + final isIn = l.direction == 'in'; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), + child: Row( + children: [ + SizedBox( + width: 96, + child: Text(l.createdAt?.substring(0, 10) ?? '-', + style: TextStyle( + fontFamily: 'monospace', + fontSize: AppDims.fsSm, + color: context.tokens.muted)), + ), + StatusPill( + label: isIn ? '入库' : '出库', + color: isIn ? context.tokens.success : context.tokens.danger, + background: + isIn ? context.tokens.okSoft : context.tokens.dangerBg, + ), + const Spacer(), + Text( + '${isIn ? '+' : '-'}${l.quantity.toStringAsFixed(0)}', + style: TextStyle( + fontWeight: FontWeight.w600, + color: isIn ? context.tokens.success : context.tokens.danger), + ), + ], + ), + ); + }).toList(), + ), + ); + } + Widget _buildImageSection(Product p) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/client/test/golden/goldens/product_detail_a.png b/client/test/golden/goldens/product_detail_a.png new file mode 100644 index 0000000..8131548 Binary files /dev/null and b/client/test/golden/goldens/product_detail_a.png differ diff --git a/client/test/golden/goldens/product_detail_b.png b/client/test/golden/goldens/product_detail_b.png new file mode 100644 index 0000000..0ede3bf Binary files /dev/null and b/client/test/golden/goldens/product_detail_b.png differ diff --git a/client/test/golden/goldens/product_detail_c.png b/client/test/golden/goldens/product_detail_c.png new file mode 100644 index 0000000..2877454 Binary files /dev/null and b/client/test/golden/goldens/product_detail_c.png differ diff --git a/client/test/golden/product_detail_golden_test.dart b/client/test/golden/product_detail_golden_test.dart new file mode 100644 index 0000000..0596ec7 --- /dev/null +++ b/client/test/golden/product_detail_golden_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/models/product.dart'; +import 'package:jiu_client/models/inventory.dart'; +import 'package:jiu_client/providers/product_provider.dart'; +import 'package:jiu_client/providers/inventory_provider.dart'; +import 'package:jiu_client/repositories/product_repository.dart'; +import 'package:jiu_client/repositories/inventory_repository.dart'; +import 'package:jiu_client/screens/products/product_detail_screen.dart'; + +import '../support/golden_harness.dart'; + +/// design-distill 阶段4:商品详情「富数据版」golden × 三主题。 +/// 验证 3 KPI(当前库存/货值/近30天出库)+ 各仓库分布 + 近期流水(接后端 +/// /inventory?product_id + /inventory/logs?product_id 聚合)。 +/// 更新基准:flutter test --update-goldens test/golden/product_detail_golden_test.dart + +class _FakeProductRepo implements ProductRepository { + @override + Future getDetail(int id) async => const Product( + id: 1, code: 'MT-FT-500', name: '茅台 飞天 53°', unit: '件', + series: '飞天', spec: '500ml×6', brand: '茅台', + purchasePrice: 2680, categoryName: '酱香'); + @override + dynamic noSuchMethod(Invocation invocation) => + super.noSuchMethod(invocation); +} + +class _FakeInvRepo implements InventoryRepository { + @override + Future> listInventory({ + int? warehouseId, + int? productId, + String? keyword, + List? series, + List? spec, + int page = 1, + int pageSize = 50, + }) async => + const PageResult(data: [ + Inventory(id: 1, productId: 1, quantity: 92, warehouseName: '主仓'), + Inventory(id: 2, productId: 1, quantity: 28, warehouseName: '二号仓'), + Inventory(id: 3, productId: 1, quantity: 8, warehouseName: '冷藏库'), + ], total: 3, page: 1, pageSize: 1000); + + @override + Future> listLogs({ + int? warehouseId, + int? productId, + int page = 1, + int pageSize = 50, + }) async => + const PageResult(data: [ + InventoryLog( + warehouseId: 1, productId: 1, direction: 'in', quantity: 24, + createdAt: '2026-06-20T09:00:00'), + InventoryLog( + warehouseId: 1, productId: 1, direction: 'out', quantity: 12, + createdAt: '2026-06-18T09:00:00'), + InventoryLog( + warehouseId: 2, productId: 1, direction: 'out', quantity: 18, + createdAt: '2026-06-15T09:00:00'), + InventoryLog( + warehouseId: 1, productId: 1, direction: 'in', quantity: 36, + createdAt: '2026-06-12T09:00:00'), + ], total: 4, page: 1, pageSize: 100); + + @override + dynamic noSuchMethod(Invocation invocation) => + super.noSuchMethod(invocation); +} + +List _overrides() => [ + productRepositoryProvider.overrideWithValue(_FakeProductRepo()), + inventoryRepositoryProvider.overrideWithValue(_FakeInvRepo()), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'product detail 富数据(桌面)', + goldenPrefix: 'product_detail', + child: () => const Scaffold(body: ProductDetailScreen(productId: 1)), + overrides: _overrides, + logical: const Size(1100, 1300), + ); +} diff --git a/client/test/stock_out_insufficient_stock_flow_test.dart b/client/test/stock_out_insufficient_stock_flow_test.dart index 1d8cc1a..1f1c3fe 100644 --- a/client/test/stock_out_insufficient_stock_flow_test.dart +++ b/client/test/stock_out_insufficient_stock_flow_test.dart @@ -94,6 +94,7 @@ class _FakeInventoryRepository extends InventoryRepository { @override Future> listInventory({ int? warehouseId, + int? productId, String? keyword, List? series, List? spec,