From 44c985e6b5ec931fc4816162bd2d7e6d9d98038f Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 14 Jul 2026 09:54:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=87=BA=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=8D=95=E8=AF=A6=E6=83=85=E6=8A=BD=E5=B1=89=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=9F=E4=BA=A7=E6=97=A5=E6=9C=9F+?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E5=8F=B7=E5=88=97=EF=BC=88=E4=B8=A4=E7=AB=AF?= =?UTF-8?q?=EF=BC=8C=E6=8A=BD=E5=B1=89=E5=8A=A0=E5=AE=BD=20660=EF=BC=8C?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B=E5=90=8C=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- client/lib/models/stock_in.dart | 5 +- .../stock_in/stock_in_list_screen.dart | 7 +- .../stock_out/stock_out_list_screen.dart | 4 + client/lib/widgets/order_detail_drawer.dart | 80 ++++++++++++++++--- .../order_detail_drawer_columns_test.dart | 41 +++++++++- design/prototype/atoms.css | 5 +- design/prototype/index.html | 6 +- design/prototype/screens/m-stock-in-list.html | 20 +++-- .../prototype/screens/m-stock-out-list.html | 18 +++-- design/prototype/screens/stock-in-list.html | 47 +++++------ design/prototype/screens/stock-out-list.html | 48 +++++------ 11 files changed, 201 insertions(+), 80 deletions(-) diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index b9202a1..695b547 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -60,8 +60,9 @@ class StockInItem { costPrice: (json['cost_price'] as num?)?.toDouble() ?? 0, costAmount: (json['cost_amount'] as num?)?.toDouble() ?? 0, returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0, - batchNo: json['batch_no'] as String?, - productionDate: json['production_date'] as String?, + // 批次号/生产日期:优先明细行快照,回退 product 关联(对齐 stock_out.dart 写法) + batchNo: lineOrProduct('batch_no', 'batch_no'), + productionDate: lineOrProduct('production_date', 'production_date'), productName: lineOrProduct('product_name', 'name'), productCode: lineOrProduct('product_code', 'code'), productSeries: lineOrProduct('series', 'series'), diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 90beac1..31a5f4e 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -414,7 +414,8 @@ class _StockInListScreenState extends ConsumerState { // 下拉刷新 = 桌面「刷新」 onRefresh: () async { ref.read(stockInListProvider.notifier).reload(); - await Future.delayed(const Duration(milliseconds: 600)); + await Future.delayed( + const Duration(milliseconds: 600)); }, mobileCards: orders.map((o) => _orderCard(context, o)).toList(), @@ -995,6 +996,10 @@ class _StockInListScreenState extends ConsumerState { price: it.costPrice == 0 ? '待定价' : _num.format(it.costPrice), amount: it.costAmount == 0 ? '待定价' : _num.format(it.costAmount), pending: it.costPrice == 0, + productionDate: it.productionDate != null + ? it.productionDate!.substring(0, 10) + : null, + batchNo: it.batchNo, )) .toList(), totalText: o.costTotal != null ? _money.format(o.costTotal) : '—', diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index c010fc5..76ab962 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1076,6 +1076,10 @@ class _StockOutListScreenState extends ConsumerState { cost: admin ? _num.format(it.costPrice) : null, profit: admin ? (pending ? '待定价' : _num.format(profit)) : null, pending: pending, + productionDate: it.productionDate != null + ? it.productionDate!.substring(0, 10) + : null, + batchNo: it.batchNo, ); }).toList(), totalText: o.saleTotal != null ? _money.format(o.saleTotal) : '—', diff --git a/client/lib/widgets/order_detail_drawer.dart b/client/lib/widgets/order_detail_drawer.dart index b56b65d..889e69f 100644 --- a/client/lib/widgets/order_detail_drawer.dart +++ b/client/lib/widgets/order_detail_drawer.dart @@ -41,7 +41,7 @@ Future showOrderDetailDrawer( barrierColor: _kDrawerMask, transitionDuration: const Duration(milliseconds: 250), pageBuilder: (ctx, _, __) { - final w = math.min(600.0, MediaQuery.of(ctx).size.width * 0.94); + final w = math.min(660.0, MediaQuery.of(ctx).size.width * 0.94); return Align( alignment: Alignment.centerRight, child: Material( @@ -63,7 +63,8 @@ Future showOrderDetailDrawer( ); } -/// 抽屉明细行(对齐原型 .lines .lr,5 列:商品·编码 / 系列·规格 / 数量 / 单价 / 金额)。 +/// 抽屉明细行(对齐原型 .lines .lr,入库 6 列 / 出库管理员 8 列: +/// 商品·编码 / 系列·规格 / 生产日期·批次号 / 数量 / 单价(/成本价/售价/总售价) / 利润)。 class OrderLine { final String name; final String code; @@ -77,6 +78,9 @@ class OrderLine { final String? cost; final String? profit; final bool pending; // 单价<=0:单价/金额显示待定价样式 + // 生产日期 / 批次号(2026-07-14,出入库单详情明细新增双行列) + final String? productionDate; + final String? batchNo; const OrderLine({ required this.name, required this.code, @@ -88,6 +92,8 @@ class OrderLine { this.cost, this.profit, this.pending = false, + this.productionDate, + this.batchNo, }); } @@ -327,8 +333,11 @@ class OrderDetailDrawer extends StatelessWidget { ); } - /// 窄屏明细行(原型:左 商品名 + 系列·×数量 小字,右 金额 mono / 待定价徽章)。 + /// 窄屏明细行(原型:左 商品名 + 系列·×数量 小字,中 生产日期/批次号双行列 + /// (两值都空则不渲染,避免空占位),右 金额 mono / 待定价徽章)。 Widget _mLineRow(dynamic t, OrderLine l, bool last) { + final hasDateBatch = (l.productionDate?.isNotEmpty ?? false) || + (l.batchNo?.isNotEmpty ?? false); return Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: last @@ -351,6 +360,30 @@ class OrderDetailDrawer extends StatelessWidget { ], ), ), + if (hasDateBatch) ...[ + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + (l.productionDate?.isNotEmpty ?? false) + ? l.productionDate! + : '—', + style: TextStyle( + fontSize: AppDims.fsSm, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.text)), + Text((l.batchNo?.isNotEmpty ?? false) ? l.batchNo! : '—', + style: TextStyle( + fontSize: AppDims.fsXs, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.faint)), + ], + ), + ], const SizedBox(width: 10), l.pending ? const DsTextIconBadge('待定价') @@ -450,6 +483,7 @@ class _LinesTable extends StatelessWidget { c1: Text('商品 · 编码', style: _hStyle(t), overflow: TextOverflow.ellipsis), c2: Text('系列 / 规格', style: _hStyle(t)), + cDate: Text('生产日期 / 批次号', style: _hStyle(t)), c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)), c4: _withProfit ? Text('成本价', textAlign: TextAlign.right, style: _hStyle(t)) @@ -506,6 +540,30 @@ class _LinesTable extends StatelessWidget { TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ], ), + cDate: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + (lines[i].productionDate?.isNotEmpty ?? false) + ? lines[i].productionDate! + : '—', + style: TextStyle( + fontSize: AppDims.fsSm, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.text)), + Text( + (lines[i].batchNo?.isNotEmpty ?? false) + ? lines[i].batchNo! + : '—', + style: TextStyle( + fontSize: AppDims.fsXs, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.faint)), + ], + ), c3: Text(lines[i].qty, textAlign: TextAlign.right, style: const TextStyle( @@ -557,11 +615,12 @@ class _LinesTable extends StatelessWidget { color: accent ? t.text : t.text)); } - // 5 列布局(对齐原型 grid 1fr 124 40 64 78);c6/c7 非空时为出库 7 列形态 - // (数量/成本价/售价/总售价/利润,数值列等宽收窄)。 + // 6 列布局(入库,对齐原型 grid 1fr 110 72 40 64 78);c6/c7 非空时为出库 + // 8 列形态(商品/系列规格/生产日期批次号/数量/成本价/售价/总售价/利润,数值列等宽收窄)。 Widget _lineRow(BuildContext context, {required Widget c1, required Widget c2, + required Widget cDate, required Widget c3, required Widget c4, required Widget c5, @@ -573,15 +632,18 @@ class _LinesTable extends StatelessWidget { children: [ Expanded(child: c1), const SizedBox(width: 8), - // 5 列(入库)时中间列加宽左移:商品列不再吃掉全部余宽, + // 6 列(入库)时中间列加宽左移:商品列不再吃掉全部余宽, // 「进价(单瓶)」表头一行放得下(2026-07-03 用户反馈)。 - SizedBox(width: wide ? 100 : 124, child: c2), + // 2026-07-14:新增「生产日期/批次号」列,入库侧挤压 124→110、88→78 腾位。 + SizedBox(width: wide ? 100 : 110, child: c2), + const SizedBox(width: 8), + SizedBox(width: 72, child: cDate), const SizedBox(width: 8), SizedBox(width: wide ? 32 : 40, child: c3), const SizedBox(width: 8), - SizedBox(width: wide ? 62 : 88, child: c4), + SizedBox(width: wide ? 62 : 78, child: c4), const SizedBox(width: 8), - SizedBox(width: wide ? 62 : 88, child: c5), + SizedBox(width: wide ? 62 : 78, child: c5), if (c6 != null) ...[ const SizedBox(width: 8), SizedBox(width: 62, child: c6), diff --git a/client/test/order_detail_drawer_columns_test.dart b/client/test/order_detail_drawer_columns_test.dart index 69c2534..6097e68 100644 --- a/client/test/order_detail_drawer_columns_test.dart +++ b/client/test/order_detail_drawer_columns_test.dart @@ -6,6 +6,9 @@ import 'package:jiu_client/widgets/order_detail_drawer.dart'; /// 2026-07 定价重设计:出库详情按角色两种形态—— /// 管理员(行带 cost/profit)= 成本价/售价/利润 三数值列 + 合计利润; /// operator(不带)= 售价/小计两列,无成本无利润。 +/// +/// 2026-07-14:明细新增「生产日期 / 批次号」双行列(入库 5→6 列 / 出库管理员 +/// 7→8 列),两种形态下都固定渲染,与成本/利润的角色可见性无关。 void main() { Widget host(OrderDetailDrawer drawer) => MaterialApp( theme: buildTheme('a'), @@ -22,6 +25,8 @@ void main() { amount: '¥800.00', cost: '¥355.00', profit: '¥90.00', + productionDate: '2021-01-27', + batchNo: '20260506', ); const line4 = OrderLine( @@ -32,9 +37,11 @@ void main() { qty: '2', price: '¥400.00', amount: '¥800.00', + productionDate: '2021-01-27', + batchNo: '20260506', ); - testWidgets('管理员形态:成本价/售价/利润列 + 合计利润', (tester) async { + testWidgets('管理员形态:成本价/售价/利润列 + 生产日期/批次号列 + 合计利润', (tester) async { await tester.pumpWidget(host(OrderDetailDrawer( title: 'CK001', infoRows: const [], @@ -54,9 +61,12 @@ void main() { expect(find.text('¥355.00'), findsOneWidget); expect(find.text('¥90.00'), findsNWidgets(2)); // 行利润 + 合计利润 expect(find.text('合计利润 '), findsOneWidget); + expect(find.text('生产日期 / 批次号'), findsOneWidget); + expect(find.text('2021-01-27'), findsOneWidget); + expect(find.text('20260506'), findsOneWidget); }); - testWidgets('operator 形态:售价/小计,无成本无利润', (tester) async { + testWidgets('operator 形态:售价/小计 + 生产日期/批次号列,无成本无利润', (tester) async { await tester.pumpWidget(host(OrderDetailDrawer( title: 'CK001', infoRows: const [], @@ -73,5 +83,32 @@ void main() { expect(find.text('利润'), findsNothing); expect(find.text('合计利润 '), findsNothing); expect(find.text('¥800.00'), findsNWidgets(2)); // 行小计 + 合计金额 + expect(find.text('生产日期 / 批次号'), findsOneWidget); + expect(find.text('2021-01-27'), findsOneWidget); + expect(find.text('20260506'), findsOneWidget); + }); + + testWidgets('生产日期/批次号两值都空时,桌面列仍渲染但单元格显示 —', (tester) async { + const lineEmpty = OrderLine( + name: '汾酒青花20年', + code: 'ZXZ027232', + series: '普通/53度', + spec: '500ml*6/件', + qty: '2', + price: '¥400.00', + amount: '¥800.00', + ); + await tester.pumpWidget(host(OrderDetailDrawer( + title: 'RK001', + infoRows: const [], + linesLabel: '入库明细', + lines: const [lineEmpty], + totalText: '¥800.00', + priceLabel: '进价(单瓶)', + amountLabel: '总价', + actionGroups: const [], + ))); + expect(find.text('生产日期 / 批次号'), findsOneWidget); + expect(find.text('—'), findsNWidgets(2)); // 生产日期 + 批次号均兜底 }); } diff --git a/design/prototype/atoms.css b/design/prototype/atoms.css index d503a6f..8214795 100644 --- a/design/prototype/atoms.css +++ b/design/prototype/atoms.css @@ -217,7 +217,7 @@ td.num{font-family:var(--font-mono);} /* ---- 抽屉 ---- */ .drawer{position:fixed; top:0; right:0; height:100vh; width:480px; max-width:94vw; background:var(--surface); border-left:1px solid var(--border); box-shadow:-16px 0 50px var(--shadow); transform:translateX(100%); transition:transform .25s; z-index:var(--z-drawer); display:flex; flex-direction:column;} .drawer.show{transform:translateX(0);} -.drawer.wide{width:600px;} +.drawer.wide{width:660px;} /* 抽屉遮罩:点击空白处关闭抽屉(z-index 在抽屉之下) */ .drawer-mask{position:fixed; inset:0; background:var(--scrim); opacity:0; pointer-events:none; transition:opacity .25s; z-index:var(--z-drawer-scrim);} .drawer-mask.show{opacity:1; pointer-events:auto;} @@ -256,6 +256,9 @@ td.num{font-family:var(--font-mono);} .lcell .lcode{display:block; font-family:var(--font-mono); color:var(--muted); font-size:var(--fs-xs); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} .lcell .lspec{display:block; color:var(--muted); font-size:var(--fs-xs); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} .lcell .lser{display:block; color:var(--text); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +/* 明细行新增列:生产日期 / 批次号(2026-07-14,出入库单详情) */ +.lcell .lpdate{display:block; font-family:var(--font-mono); color:var(--text); font-size:var(--fs-sm); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.lcell .lbatch{display:block; font-family:var(--font-mono); color:var(--faint); font-size:var(--fs-xs); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} /* 抽屉底部操作分组:审核类 / 打印类 */ .dactions{margin-top:18px; display:flex; flex-direction:column; gap:14px;} .dact-group{display:flex; flex-direction:column; gap:8px;} diff --git a/design/prototype/index.html b/design/prototype/index.html index b34af6e..28a49da 100644 --- a/design/prototype/index.html +++ b/design/prototype/index.html @@ -427,9 +427,9 @@
检测到未提交的入库单草稿(06-21 11:20),是否恢复?
恢复放弃
单据被驳回:单价高于近期采购价,请核实后重报。
-

单据明细行(商品 / 系列规格分列)

.lines .lr · .lcell .ln/.lcode/.lser/.lspec
-
商品 · 编码系列 / 规格数量单价金额
-
泸州老窖 特曲LZLJ-TQ-52-500特曲52° 500ml×648¥320¥15,360
+

单据明细行(商品 / 系列规格 / 生产日期批次号分列)

.lines .lr · .lcell .ln/.lcode/.lser/.lspec/.lpdate/.lbatch
+
商品 · 编码系列 / 规格生产日期 / 批次号数量单价金额
+
泸州老窖 特曲LZLJ-TQ-52-500特曲52° 500ml×62022-02-142026041548¥320¥15,360

抽屉操作分组

.dactions · .dact-group/.dact-label/.dact-row(审核类 / 打印类)
diff --git a/design/prototype/screens/m-stock-in-list.html b/design/prototype/screens/m-stock-in-list.html index 5aee57d..47e5f91 100644 --- a/design/prototype/screens/m-stock-in-list.html +++ b/design/prototype/screens/m-stock-in-list.html @@ -43,13 +43,13 @@