feat(client): 商品详情升级富数据版(KPI + 仓库分布 + 流水)

接后端新能力:listInventory 加 productId 透传;详情异步拉本商品库存+流水(失败
静默不阻塞主数据),渲染原型富数据三件套:
- 3 KPI 卡(当前库存 / 库存货值=库存×进价 / 近30天出库=出库流水30天聚合)
- 各仓库库存分布(按仓库分组 数量+货值)
- 近期流水(StatusPill 入/出 + 增减量,最近 8 条)
价格历史待后端第二步。整屏 golden ×三主题入回归闸。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 15:34:57 +08:00
parent 90ab22bf0e
commit 355ac0ebc3
7 changed files with 292 additions and 0 deletions
@@ -11,6 +11,7 @@ class InventoryRepository {
Future<PageResult<Inventory>> listInventory({
int? warehouseId,
int? productId,
String? keyword,
List<String>? series,
List<String>? 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(','),
@@ -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<ProductDetailScreen> {
bool _savingDesc = false;
bool _descChanged = false;
// 富数据(库存聚合 + 流水),随详情异步加载,失败不阻塞主数据。
List<Inventory> _invRows = [];
List<InventoryLog> _logs = [];
late TextEditingController _descCtrl;
@override
@@ -60,10 +69,30 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
_product = p;
_descCtrl.text = p.description ?? '';
_descChanged = false;
_loadStats();
return p;
});
}
/// 拉本商品的库存聚合 + 流水(fire-and-forget,失败不影响主数据展示)。
Future<void> _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<void> _saveDesc() async {
@@ -273,9 +302,19 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
],
),
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<ProductDetailScreen> {
);
}
// ── 富数据: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<num>(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<num>(0, (s, l) => s + l.quantity);
final cards = <Widget>[
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 = <Widget>[];
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 = <String, num>{};
for (final inv in _invRows) {
final wh = inv.warehouseName.isEmpty ? '' : inv.warehouseName;
byWh[wh] = (byWh[wh] ?? 0) + inv.quantity;
}
final total = byWh.values.fold<num>(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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -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<Product> 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<PageResult<Inventory>> listInventory({
int? warehouseId,
int? productId,
String? keyword,
List<String>? series,
List<String>? 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<PageResult<InventoryLog>> 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<Override> _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),
);
}
@@ -94,6 +94,7 @@ class _FakeInventoryRepository extends InventoryRepository {
@override
Future<PageResult<Inventory>> listInventory({
int? warehouseId,
int? productId,
String? keyword,
List<String>? series,
List<String>? spec,