import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/clock.dart'; import '../../models/inventory.dart'; import '../../models/warehouse.dart'; import '../../core/config/app_constants.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/warehouse_provider.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/mobile_list_card.dart'; class InventoryCheckScreen extends ConsumerStatefulWidget { const InventoryCheckScreen({super.key}); @override ConsumerState createState() => _InventoryCheckScreenState(); } class _InventoryCheckScreenState extends ConsumerState { Warehouse? _selectedWarehouse; String _checkType = '全盘'; bool _submitting = false; bool _loadingItems = false; // Editable check items built from real inventory final List<_CheckItem> _checkItems = []; String get _checkNo { final now = appNow(); final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}'; } /// 盘点日期(appNow 可注入时钟,golden 冻结确定化)。 String get _dateStr { final now = appNow(); return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; } @override void dispose() { for (final item in _checkItems) { item.dispose(); } super.dispose(); } Future _loadInventory(Warehouse wh) async { setState(() { _loadingItems = true; for (final item in _checkItems) { item.dispose(); } _checkItems.clear(); }); try { final result = await ref.read(inventoryRepositoryProvider).listInventory( warehouseId: wh.id, pageSize: AppConstants.inventoryCheckPageSize); final items = result.data .where((inv) => inv.productId != null) .map((inv) => _CheckItem(inventory: inv)) .toList(); if (mounted) { setState(() { _checkItems.addAll(items); _loadingItems = false; }); } } catch (e) { if (mounted) { setState(() => _loadingItems = false); showDsToast(context, '加载库存失败:$e', bg: context.tokens.danger); } } } Future _submit() async { if (_selectedWarehouse == null) { showDsToast(context, '请先选择仓库'); return; } setState(() => _submitting = true); try { final dateStr = _dateStr; final items = _checkItems.map((item) { final actual = double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity; return { 'product_id': item.inventory.productId!, 'actual_qty': actual, 'remark': item.remarkCtrl.text.trim(), }; }).toList(); await ref.read(inventoryRepositoryProvider).createCheck({ 'check_no': _checkNo, 'warehouse_id': _selectedWarehouse!.id, 'check_date': dateStr, 'items': items, }); if (mounted) { showDsToast(context, '盘点单已提交', bg: context.tokens.success); context.go('/inventory'); } } catch (e) { if (mounted) { showDsToast(context, '提交失败:$e', bg: context.tokens.danger); } } finally { if (mounted) setState(() => _submitting = false); } } int _getDiff(_CheckItem item) { final actual = double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity; return (actual - item.inventory.quantity).round(); } @override Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); // 窄屏(原型 m-inventory-check):壳顶栏带标题/返回 → 隐藏页内大标题头, // 盘点单以卡片流呈现(点卡开详情 sheet),底部操作条提交。 if (context.isMobile) return _buildMobile(asyncWarehouses); return Scaffold( backgroundColor: context.tokens.bg, body: Column( children: [ // Header Container( height: 52, color: context.tokens.surface, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ IconButton( icon: const Icon(LucideIcons.arrowLeft, size: 20), onPressed: () => context.go('/inventory'), ), const SizedBox(width: 8), const Text('库存盘点', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( onPressed: () => context.go('/inventory'), child: const Text('取消'), ), const SizedBox(width: 8), ElevatedButton.icon( onPressed: (_submitting || _checkItems.isEmpty) ? null : _submit, icon: _submitting ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) // ds-ignore: 盘点屏待重建批次统一 : const Icon(LucideIcons.circleCheck, size: 16), label: const Text('提交盘点'), ), ], ), ), const Divider(height: 1), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ // Basic info _basicInfoCard(asyncWarehouses), const SizedBox(height: 12), // Check items table Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('盘点明细', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: context.tokens.primaryDark)), const SizedBox(width: 12), if (_checkItems.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( color: context.tokens.accent.withOpacity(0.1), borderRadius: BorderRadius.circular(3), ), child: Text( '差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项', style: TextStyle( fontSize: 12, color: context.tokens.accent), ), ), ], ), const SizedBox(height: 12), if (_selectedWarehouse == null) Center( child: Padding( padding: const EdgeInsets.all(32), child: Text('请先选择盘点仓库', style: TextStyle(color: context.tokens.muted)), ), ) else if (_loadingItems) const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ) else if (_checkItems.isEmpty) Center( child: Padding( padding: const EdgeInsets.all(32), child: Text('该仓库暂无库存记录', style: TextStyle(color: context.tokens.muted)), ), ) else Table( columnWidths: const { 0: FixedColumnWidth(36), 1: FixedColumnWidth(80), 2: FlexColumnWidth(3), 3: FixedColumnWidth(50), 4: FixedColumnWidth(80), 5: FixedColumnWidth(120), 6: FixedColumnWidth(80), 7: FlexColumnWidth(2), }, children: [ TableRow( decoration: BoxDecoration(color: context.tokens.thBg), children: [ '序号', '商品编码', '商品名称', '单位', '账面数量', '实际数量', '差异', '备注', ] .map((h) => Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 10), child: Text(h, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: context .tokens.primaryDark)), )) .toList(), ), ...List.generate(_checkItems.length, (i) => _buildCheckRow(i)), ], ), if (_checkItems.isNotEmpty) ...[ const Divider(height: 1), Padding( padding: const EdgeInsets.only(top: 12), child: Row( children: [ _SummaryItem( label: '盘点商品', value: '${_checkItems.length}种', color: context.tokens.primary, ), const SizedBox(width: 24), _SummaryItem( label: '盘盈', value: '${_checkItems.where((i) => _getDiff(i) > 0).length}种', color: context.tokens.success, ), const SizedBox(width: 24), _SummaryItem( label: '盘亏', value: '${_checkItems.where((i) => _getDiff(i) < 0).length}种', color: context.tokens.danger, ), const SizedBox(width: 24), _SummaryItem( label: '相符', value: '${_checkItems.where((i) => _getDiff(i) == 0).length}种', color: context.tokens.muted, ), ], ), ), ], ], ), ), ), ], ), ), ), ], ), ); } /// 盘点基本信息卡(桌面/移动共用):单号 / 仓库 / 类型 / 日期。 Widget _basicInfoCard(AsyncValue> asyncWarehouses) { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('盘点基本信息', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: context.tokens.primaryDark)), const SizedBox(height: 16), Wrap( spacing: 16, runSpacing: 16, children: [ _InfoField( label: '盘点单号', child: InputDecorator( decoration: const InputDecoration(), child: Text( _checkNo, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: 13), ), ), ), _InfoField( label: '盘点仓库', child: asyncWarehouses.when( loading: () => const LinearProgressIndicator(), error: (e, _) => Text('$e', style: TextStyle( color: context.tokens.danger, fontSize: 12)), data: (warehouses) => DsSelect( value: _selectedWarehouse, hint: '请选择仓库', options: [ for (final w in warehouses) (w, w.name), ], onChanged: (w) { setState(() => _selectedWarehouse = w); _loadInventory(w); }, ), ), ), _InfoField( label: '盘点类型', child: DsSelect( value: _checkType, options: const [ ('全盘', '全盘'), ('抽盘', '抽盘'), ('循环盘点', '循环盘点'), ], onChanged: (v) => setState(() => _checkType = v), ), ), _InfoField( label: '盘点日期', child: InputDecorator( decoration: const InputDecoration(), child: Text(_dateStr, style: const TextStyle(fontSize: 13)), ), ), ], ), ], ), ), ); } // ── 窄屏(原型 m-inventory-check 粒度)────────────────────────────── int get _totalDiff => _checkItems.fold(0, (sum, item) => sum + _getDiff(item)); String get _scopeLabel => '${_selectedWarehouse?.name ?? '未选仓库'} · $_checkType'; String _fmtDiff(int diff) => diff > 0 ? '+$diff' : '$diff'; Widget _buildMobile(AsyncValue> asyncWarehouses) { final t = context.tokens; return Scaffold( backgroundColor: t.bg, body: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(AppDims.sp3), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _basicInfoCard(asyncWarehouses), // .m-section Padding( padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), child: Text('盘点单', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w700, letterSpacing: .4, color: t.muted)), ), if (_loadingItems) const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()), ) else _draftCard(t), ], ), ), ), // 底部操作条(原型 .m-actionbar 形态:横排等分按钮) Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), decoration: BoxDecoration( color: t.surface, border: Border(top: BorderSide(color: t.borderSubtle)), ), child: Row(children: [ Expanded( child: DsButton('取消', onPressed: () => context.go('/inventory')), ), const SizedBox(width: 10), Expanded( child: DsButton('提交盘点', variant: DsBtnVariant.primary, onPressed: (_submitting || _checkItems.isEmpty) ? null : _submit), ), ]), ), ], ), ); } /// 盘点单卡(原型 .m-card:单号 / 范围·项数 / 状态徽章 + 差异 / 日期脚注)。 Widget _draftCard(dynamic t) { final diff = _totalDiff; return MobileListCard( onTap: _openDraftSheet, title: Text(_checkNo, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), subtitle: Text('$_scopeLabel · ${_checkItems.length} 项'), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ DsBadge('进行中', tone: DsBadgeTone.warn, icon: statusIcon('进行中')), const SizedBox(height: 6), // 「差异」为 CJK 走默认字体,数值走 mono(JetBrains Mono 无 CJK 字形) Text.rich( TextSpan(children: [ TextSpan( text: '差异 ', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13, color: diff == 0 ? t.success : t.danger)), TextSpan( text: _fmtDiff(diff), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w700, fontSize: 13, color: diff == 0 ? t.success : t.danger)), ]), ), ], ), fields: [MobileCardField('日期', _dateStr)], ); } /// 盘点单详情 sheet(原型 openSheet:.drow 键值行 + 状态徽章 + 继续录入)。 void _openDraftSheet() { final t = context.tokens; final diff = _totalDiff; showMSheet( context, title: '盘点单详情', builder: (ctx) => Column( mainAxisSize: MainAxisSize.min, children: [ _drow(ctx, '单号', value: _checkNo, mono: true), _drow(ctx, '范围', value: _scopeLabel), _drow(ctx, '商品项数', value: '${_checkItems.length} 项'), _drow(ctx, '盈亏差异', value: _fmtDiff(diff), mono: true, color: diff == 0 ? t.success : t.danger), _drow(ctx, '状态', child: DsBadge('进行中', tone: DsBadgeTone.warn, icon: statusIcon('进行中')), last: true), ], ), actions: [ DsButton('继续录入', variant: DsBtnVariant.primary, onPressed: () => Navigator.of(context).pop()), ], ); } /// 原型 .drow:label(muted) + b(text 600),pad 11 0,下边 border-subtle。 Widget _drow(BuildContext ctx, String label, {String? value, Widget? child, bool mono = false, Color? color, bool last = false}) { final t = ctx.tokens; return Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( border: last ? null : Border(bottom: BorderSide(color: t.borderSubtle))), child: Row(children: [ Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), const Spacer(), child ?? Text(value ?? '', style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, fontFamily: mono ? AppFonts.mono : null, fontFamilyFallback: mono ? AppFonts.monoFallback : null, color: color ?? t.text)), ]), ); } TableRow _buildCheckRow(int index) { final item = _checkItems[index]; final diff = _getDiff(item); final inv = item.inventory; return TableRow( decoration: BoxDecoration( color: diff != 0 ? (diff > 0 ? context.tokens.success.withOpacity(0.04) : context.tokens.danger.withOpacity(0.04)) : (index.isEven ? context.tokens.surface : context.tokens.bg), ), children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text('${index + 1}', style: TextStyle(fontSize: 13, color: context.tokens.muted)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text(inv.productCode.isEmpty ? '-' : inv.productCode, style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: 12, color: context.tokens.muted)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), 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.unit.isEmpty ? '-' : inv.unit, style: const TextStyle(fontSize: 13)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text(inv.quantity.toStringAsFixed(0), style: const TextStyle(fontSize: 13)), ), Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.actualQtyCtrl, decoration: const InputDecoration(), style: const TextStyle(fontSize: 13), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => setState(() {}), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text( diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: diff == 0 ? context.tokens.muted : (diff > 0 ? context.tokens.success : context.tokens.danger), ), ), ), Padding( padding: const EdgeInsets.all(4), child: TextFormField( controller: item.remarkCtrl, decoration: const InputDecoration(hintText: '备注'), style: const TextStyle(fontSize: 13), ), ), ], ); } } class _CheckItem { final Inventory inventory; final TextEditingController actualQtyCtrl; final TextEditingController remarkCtrl; _CheckItem({required this.inventory}) : actualQtyCtrl = TextEditingController(text: inventory.quantity.toStringAsFixed(0)), remarkCtrl = TextEditingController(); void dispose() { actualQtyCtrl.dispose(); remarkCtrl.dispose(); } } class _InfoField extends StatelessWidget { final String label; final Widget child; const _InfoField({required this.label, required this.child}); @override Widget build(BuildContext context) { return SizedBox( width: 220, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: 13, color: context.tokens.muted)), const SizedBox(height: 6), child, ], ), ); } } class _SummaryItem extends StatelessWidget { final String label; final String value; final Color color; const _SummaryItem( {required this.label, required this.value, required this.color}); @override Widget build(BuildContext context) { return Row( children: [ Text(label, style: TextStyle(fontSize: 13, color: context.tokens.muted)), const SizedBox(width: 4), Text(value, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: color)), ], ); } }