From a355a3a2a27222b795cce415caf7eda0d2659a79 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 26 Jun 2026 17:03:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=87=BA=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=8D=95=E6=89=93=E5=8D=B0=E6=8C=89=E6=96=B0=E7=89=88=E5=BC=8F?= =?UTF-8?q?=E9=87=8D=E5=81=9A=20+=20=E5=8D=95=E4=BB=B7=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版式参照单据样板:公司抬头 + 两行三列表头 + 统一明细列 (商品编号/名称/系列/规格/批次号/生产日期/数量/单价/金额/备注) - 单据总计独立成行(仅数量+金额,右对齐高亮) - 出库单保留温馨提示,入库单去掉 - 签字行:制单人=当前登录用户(OrderPrintMeta),采购员/销售员=经办人; 公司名取门店(shopInfoProvider) - 出库单单价=售价(sale_price),金额=售价×数量;历史单 sale_price=0 时回退 unit_price/total_price。入库单单价=进价(unit_price) - 桌面 PDF 与 Web HTML 两端同构 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/utils/order_print_meta.dart | 20 + client/lib/core/utils/print_util.dart | 19 +- client/lib/core/utils/print_util_stub.dart | 360 +++++++++++------- client/lib/core/utils/print_util_web.dart | 256 ++++++------- .../stock_in/stock_in_form_screen.dart | 2 +- .../stock_in/stock_in_list_screen.dart | 4 +- .../stock_out/stock_out_form_screen.dart | 2 +- .../stock_out/stock_out_list_screen.dart | 4 +- .../widgets/order_print_preview_dialog.dart | 36 +- 9 files changed, 418 insertions(+), 285 deletions(-) create mode 100644 client/lib/core/utils/order_print_meta.dart diff --git a/client/lib/core/utils/order_print_meta.dart b/client/lib/core/utils/order_print_meta.dart new file mode 100644 index 0000000..b28f08a --- /dev/null +++ b/client/lib/core/utils/order_print_meta.dart @@ -0,0 +1,20 @@ +/// 单据打印的「门店抬头 + 制单人」元信息。 +/// +/// - 门店信息(公司名称/地址/电话)来自 `shopInfoProvider`,作为单据顶部抬头。 +/// - [makerName] = 制单人 = **当前登录、点击打印的人**(非单据经办人/审核人)。 +/// 入库单的「采购员」「出库单的「销售员」才是单据经办人(operator)。 +class OrderPrintMeta { + final String shopName; + final String shopAddress; + final String shopPhone; + + /// 制单人 = 当前登录用户(打印此单的人)。 + final String makerName; + + const OrderPrintMeta({ + this.shopName = '', + this.shopAddress = '', + this.shopPhone = '', + this.makerName = '', + }); +} diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index df5405e..0be4f00 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -5,6 +5,8 @@ import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; export 'label_data.dart'; +import 'order_print_meta.dart'; +export 'order_print_meta.dart'; import 'print_util_stub.dart' if (dart.library.js_interop) 'print_util_web.dart'; @@ -47,19 +49,20 @@ Future> listLabelPrinters() => listLabelPrintersImpl(); /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。 Future detectDefaultPrinter() => detectDefaultPrinterImpl(); -Future printStockInOrder(StockInOrder order) => - printStockInOrderImpl(order); +Future printStockInOrder(StockInOrder order, OrderPrintMeta meta) => + printStockInOrderImpl(order, meta); -Future printStockOutOrder(StockOutOrder order) => - printStockOutOrderImpl(order); +Future printStockOutOrder(StockOutOrder order, OrderPrintMeta meta) => + printStockOutOrderImpl(order, meta); /// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 -Future buildStockInOrderPdf(StockInOrder order) => - buildStockInOrderPdfImpl(order); +Future buildStockInOrderPdf(StockInOrder order, OrderPrintMeta meta) => + buildStockInOrderPdfImpl(order, meta); /// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 -Future buildStockOutOrderPdf(StockOutOrder order) => - buildStockOutOrderPdfImpl(order); +Future buildStockOutOrderPdf( + StockOutOrder order, OrderPrintMeta meta) => + buildStockOutOrderPdfImpl(order, meta); /// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。 /// 用法:await safePrint(context, () => printStockInOrder(order)); diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index e2244d8..019021e 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -25,6 +25,7 @@ import 'package:printing/printing.dart'; import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import 'label_data.dart'; +import 'order_print_meta.dart'; Future _loadFont() async { final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); @@ -644,184 +645,263 @@ Future> listLabelPrintersImpl() async { /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。 Future detectDefaultPrinterImpl() => _findThermalPrinter(); +/// 截取日期前 10 位(yyyy-MM-dd),空值返回空串。 +String _d10(String? s) { + if (s == null || s.isEmpty) return ''; + return s.length > 10 ? s.substring(0, 10) : s; +} + +/// 数量格式化:整数不带小数,否则保留 3 位。 +String _qtyStr(double q) => + q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3); + +/// 单据明细列(入库/出库统一):商品编号→备注。 +const _orderHeaders = [ + '商品编号', '商品名称', '系列', '规格', '批次号', + '生产日期', '数量', '单价', '金额', '备注', +]; +const _orderColFlex = [1.5, 2.0, 1.1, 1.3, 1.6, 1.3, 0.7, 1.1, 1.2, 0.9]; + +/// 温馨提示文案(入库/出库统一)。 +const _kTipsText = + '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,' + '如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。'; + +/// 各明细列对齐:系列/规格/批次号/生产日期居中,数量/单价/金额右对齐,其余左对齐。 +pw.TextAlign _colAlign(int i) { + switch (i) { + case 2: + case 3: + case 4: + case 5: + return pw.TextAlign.center; + case 6: + case 7: + case 8: + return pw.TextAlign.right; + default: + return pw.TextAlign.left; + } +} + +/// 构建入库单/出库单 A4 版式(参照「锐浪报表」单据:公司抬头 + 两行三列表头 +/// + 明细表 + 单据总计 + 温馨提示 + 签字行)。入库/出库仅称谓不同。 pw.Widget _buildOrderDoc({ required pw.Font font, required pw.Font bold, - required String title, + required String shopName, // 公司名称(顶部抬头) + required String title, // 入 库 单 / 出 库 单 required String orderNo, - required String? orderDate, - required String? partnerLabel, + required String noPrefix, // 'NO:' / 'NO.' + required String partnerLabel, // 单位名称 / 客户名称 required String? partnerName, + required String dateLabel, // 入库日期 / 出库日期 + required String? orderDate, + required String addressLabel, // 单位地址 / 送货地址 required String? warehouseName, + required String signerLabel, // 出货人(签字)/ 收货人(签字) + required String operatorRoleLabel, // 采购员 / 销售员(= 经办人) required String? operatorName, - required String? reviewerName, + required String? makerName, // 制单人 = 打印人 required String? remark, - required List headers, + required bool showTips, required List> rows, required double totalQty, required double totalAmt, - String? tipsText, }) { - final headerStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); - final cellStyle = pw.TextStyle(font: font, fontSize: 9.5); + final cellStyle = pw.TextStyle(font: font, fontSize: 9); + final headStyle = + pw.TextStyle(font: bold, fontSize: 9.5, fontWeight: pw.FontWeight.bold); final metaStyle = pw.TextStyle(font: font, fontSize: 10); - final boldStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); + final boldStyle = + pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); - final colWidths = headers.map((h) { - if (h == '商品名称') return const pw.FlexColumnWidth(2.2); - if (h == '系列' || h == '规格') return const pw.FlexColumnWidth(1.2); - if (h == '商品编号') return const pw.FlexColumnWidth(1.2); - return const pw.FlexColumnWidth(0.9); - }).toList(); + pw.Widget metaText(String label, String value, pw.TextAlign align) => + pw.Text('$label:$value', style: metaStyle, textAlign: align); return pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, + crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ + // ── 公司名称 ────────────────────────────────────────────────────────── + pw.Center( + child: pw.Text(shopName, + style: pw.TextStyle( + font: bold, fontSize: 18, fontWeight: pw.FontWeight.bold)), + ), + pw.SizedBox(height: 4), + // ── 单据类型 ────────────────────────────────────────────────────────── pw.Center( child: pw.Text(title, - style: pw.TextStyle(font: bold, fontSize: 20, fontWeight: pw.FontWeight.bold)), + style: pw.TextStyle( + font: bold, + fontSize: 15, + fontWeight: pw.FontWeight.bold, + letterSpacing: 2)), ), pw.SizedBox(height: 8), + // ── 抬头信息(两行三列)────────────────────────────────────────────── pw.Container( decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey600)), + border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey700)), ), padding: const pw.EdgeInsets.only(bottom: 5), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + child: pw.Column( children: [ - pw.Text('$partnerLabel:${partnerName ?? ''}', style: metaStyle), - pw.Text('日期:${orderDate ?? ''}', style: metaStyle), - pw.Text('NO: $orderNo', style: boldStyle), + pw.Row( + children: [ + pw.Expanded( + flex: 5, + child: metaText( + partnerLabel, partnerName ?? '', pw.TextAlign.left)), + pw.Expanded( + flex: 4, + child: metaText( + dateLabel, _d10(orderDate), pw.TextAlign.left)), + pw.Expanded( + flex: 3, + child: pw.Text('$noPrefix$orderNo', + style: boldStyle, textAlign: pw.TextAlign.right)), + ], + ), + pw.SizedBox(height: 3), + pw.Row( + children: [ + pw.Expanded( + flex: 5, + child: metaText('联系电话', '', pw.TextAlign.left)), + pw.Expanded( + flex: 4, + child: metaText(addressLabel, '', pw.TextAlign.left)), + pw.Expanded( + flex: 3, + child: metaText( + '仓库', warehouseName ?? '', pw.TextAlign.right)), + ], + ), ], ), ), - pw.SizedBox(height: 4), - pw.Row( - children: [ - pw.Text('仓库:${warehouseName ?? ''}', style: metaStyle), - pw.SizedBox(width: 24), - pw.Text('经办人:${operatorName ?? ''}', style: metaStyle), - ], - ), pw.SizedBox(height: 8), + // ── 明细表 + 单据总计 ──────────────────────────────────────────────── pw.Table( - border: pw.TableBorder.all(color: PdfColors.grey500), - columnWidths: { for (var i = 0; i < colWidths.length; i++) i: colWidths[i] }, + border: pw.TableBorder.all(color: PdfColors.grey600, width: 0.5), + columnWidths: { + for (var i = 0; i < _orderColFlex.length; i++) + i: pw.FlexColumnWidth(_orderColFlex[i]) + }, children: [ pw.TableRow( decoration: const pw.BoxDecoration(color: PdfColors.grey200), - children: headers.map((h) => pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 3), - child: pw.Text(h, textAlign: pw.TextAlign.center, style: headerStyle), - )).toList(), + children: [ + for (final h in _orderHeaders) + pw.Padding( + padding: + const pw.EdgeInsets.symmetric(horizontal: 2, vertical: 3), + child: pw.Text(h, + textAlign: pw.TextAlign.center, style: headStyle), + ), + ], ), - ...rows.map((row) => pw.TableRow( - children: row.map((cell) => pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), - child: pw.Text(cell, style: cellStyle), - )).toList(), - )), - pw.TableRow( - decoration: const pw.BoxDecoration(color: PdfColors.grey100), - children: List.generate(headers.length, (i) { - if (i == headers.length - 3) { - return pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), - child: pw.Text('单据总计', textAlign: pw.TextAlign.right, style: boldStyle), - ); - } - if (i == headers.length - 2) { - return pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), - child: pw.Text( - totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3), - textAlign: pw.TextAlign.right, style: boldStyle, + for (final row in rows) + pw.TableRow( + children: [ + for (var i = 0; i < row.length; i++) + pw.Padding( + padding: const pw.EdgeInsets.symmetric( + horizontal: 2, vertical: 2.5), + child: pw.Text(row[i], + textAlign: _colAlign(i), style: cellStyle), ), - ); - } - if (i == headers.length - 1) { - return pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), - child: pw.Text(totalAmt.toStringAsFixed(2), - textAlign: pw.TextAlign.right, style: boldStyle), - ); - } - return pw.SizedBox(); - }), - ), + ], + ), ], ), - if ((remark ?? '').isNotEmpty) ...[ - pw.SizedBox(height: 6), - pw.Text('备注:$remark', style: metaStyle), - ], - if (tipsText != null) ...[ - pw.SizedBox(height: 6), + // ── 单据总计:独立一行,仅数量 + 金额,右对齐高亮 ────────────────── + pw.SizedBox(height: 6), + pw.Container( + width: double.infinity, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey600, width: 0.5), + color: PdfColors.grey100, + ), + padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('单据总计', + style: pw.TextStyle( + font: bold, fontSize: 10.5, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(width: 30), + pw.Text('数量', style: metaStyle), + pw.SizedBox(width: 6), + pw.Text(_qtyStr(totalQty), + style: pw.TextStyle( + font: bold, fontSize: 11, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(width: 30), + pw.Text('金额', style: metaStyle), + pw.SizedBox(width: 6), + pw.Text(totalAmt.toStringAsFixed(2), + style: pw.TextStyle( + font: bold, fontSize: 12, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + // ── 温馨提示(仅出库单)──────────────────────────────────────────── + if (showTips) ...[ + pw.SizedBox(height: 8), pw.Container( + width: double.infinity, decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey400), + border: pw.Border.all(color: PdfColors.grey400, width: 0.5), color: PdfColors.grey50, ), padding: const pw.EdgeInsets.all(5), - child: pw.Text(tipsText, - style: pw.TextStyle(font: font, fontSize: 9, color: PdfColors.grey700)), + child: pw.Text(_kTipsText, + style: pw.TextStyle( + font: font, fontSize: 8.5, color: PdfColors.grey800)), ), ], - pw.SizedBox(height: 36), + pw.SizedBox(height: 26), + // ── 签字行:签收人 / 经办人 / 制单人 ──────────────────────────────── pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - _sig(font, bold, '收/出货人(签字)'), - _sig(font, bold, '经办人:${operatorName ?? ''}'), - _sig(font, bold, '制单人:${reviewerName ?? operatorName ?? ''}'), + pw.Text('$signerLabel:', style: metaStyle), + pw.Text('$operatorRoleLabel:${operatorName ?? ''}', style: metaStyle), + pw.Text('制单人:${makerName ?? ''}', style: metaStyle), ], ), - ], - ); -} - -pw.Widget _sig(pw.Font font, pw.Font bold, String label) { - return pw.Column( - children: [ - pw.Container(width: 120, height: 28), - pw.Container( - width: 120, - decoration: const pw.BoxDecoration( - border: pw.Border(top: pw.BorderSide(color: PdfColors.grey700)), - ), - padding: const pw.EdgeInsets.only(top: 3), - child: pw.Text(label, - textAlign: pw.TextAlign.center, - style: pw.TextStyle(font: font, fontSize: 10)), - ), + if ((remark ?? '').isNotEmpty) ...[ + pw.SizedBox(height: 10), + pw.Text('备注:$remark', style: metaStyle), + ], ], ); } /// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 -Future buildStockInOrderPdfImpl(StockInOrder order) async { +Future buildStockInOrderPdfImpl( + StockInOrder order, OrderPrintMeta meta) async { final font = await _loadFont(); final bold = await _loadBoldFont(); double totalQty = 0, totalAmt = 0; final rows = >[]; - for (int i = 0; i < order.items.length; i++) { - final it = order.items[i]; + for (final it in order.items) { totalQty += it.quantity; totalAmt += it.totalPrice; rows.add([ - '${i + 1}', it.productCode ?? '', it.productName ?? '', it.productSeries ?? '', it.productSpec ?? '', - it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), - it.productUnit ?? '', + it.batchNo ?? '', + _d10(it.productionDate), + _qtyStr(it.quantity), it.unitPrice.toStringAsFixed(2), it.totalPrice.toStringAsFixed(2), - it.productionDate ?? '', - it.batchNo ?? '', + '', ]); } @@ -832,16 +912,22 @@ Future buildStockInOrderPdfImpl(StockInOrder order) async { build: (_) => [ _buildOrderDoc( font: font, bold: bold, + shopName: meta.shopName, title: '入 库 单', orderNo: order.orderNo, - orderDate: order.orderDate, - partnerLabel: '供应商', + noPrefix: 'NO:', + partnerLabel: '单位名称', partnerName: order.partnerName, + dateLabel: '入库日期', + orderDate: order.orderDate, + addressLabel: '单位地址', warehouseName: order.warehouseName, + signerLabel: '出货人(签字)', + operatorRoleLabel: '采购员', operatorName: order.operatorName, - reviewerName: order.reviewerName, + makerName: meta.makerName, remark: order.remark, - headers: ['序号', '商品编号', '商品名称', '系列', '规格', '数量', '单位', '单价', '金额', '生产日期', '批次'], + showTips: false, rows: rows, totalQty: totalQty, totalAmt: totalAmt, @@ -851,9 +937,10 @@ Future buildStockInOrderPdfImpl(StockInOrder order) async { return doc.save(); } -Future printStockInOrderImpl(StockInOrder order) async { +Future printStockInOrderImpl( + StockInOrder order, OrderPrintMeta meta) async { try { - final bytes = await buildStockInOrderPdfImpl(order); + final bytes = await buildStockInOrderPdfImpl(order, meta); if (!kIsWeb && Platform.isMacOS) { await Future.delayed(Duration.zero); } @@ -868,26 +955,31 @@ Future printStockInOrderImpl(StockInOrder order) async { } /// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 -Future buildStockOutOrderPdfImpl(StockOutOrder order) async { +Future buildStockOutOrderPdfImpl( + StockOutOrder order, OrderPrintMeta meta) async { final font = await _loadFont(); final bold = await _loadBoldFont(); double totalQty = 0, totalAmt = 0; final rows = >[]; - for (int i = 0; i < order.items.length; i++) { - final it = order.items[i]; + for (final it in order.items) { + // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、 + // 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。 + final unit = it.salePrice > 0 ? it.salePrice : it.unitPrice; + final amt = it.salePrice > 0 ? it.salePrice * it.quantity : it.totalPrice; totalQty += it.quantity; - totalAmt += it.totalPrice; + totalAmt += amt; rows.add([ - '${i + 1}', it.productCode ?? '', it.productName ?? '', it.productSeries ?? '', it.productSpec ?? '', - it.productUnit ?? '', - it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), - it.unitPrice.toStringAsFixed(2), - it.totalPrice.toStringAsFixed(2), + it.batchNo ?? '', + _d10(it.productionDate), + _qtyStr(it.quantity), + unit.toStringAsFixed(2), + amt.toStringAsFixed(2), + '', ]); } @@ -898,29 +990,35 @@ Future buildStockOutOrderPdfImpl(StockOutOrder order) async { build: (_) => [ _buildOrderDoc( font: font, bold: bold, + shopName: meta.shopName, title: '出 库 单', orderNo: order.orderNo, - orderDate: order.orderDate, - partnerLabel: '客户', + noPrefix: 'NO.', + partnerLabel: '客户名称', partnerName: order.partnerName, + dateLabel: '出库日期', + orderDate: order.orderDate, + addressLabel: '送货地址', warehouseName: order.warehouseName, + signerLabel: '收货人(签字)', + operatorRoleLabel: '销售员', operatorName: order.operatorName, - reviewerName: order.reviewerName, + makerName: meta.makerName, remark: order.remark, - headers: ['序号', '商品编号', '商品名称', '系列', '规格', '单位', '数量', '单价', '金额'], + showTips: true, rows: rows, totalQty: totalQty, totalAmt: totalAmt, - tipsText: '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。', ), ], )); return doc.save(); } -Future printStockOutOrderImpl(StockOutOrder order) async { +Future printStockOutOrderImpl( + StockOutOrder order, OrderPrintMeta meta) async { try { - final bytes = await buildStockOutOrderPdfImpl(order); + final bytes = await buildStockOutOrderPdfImpl(order, meta); if (!kIsWeb && Platform.isMacOS) { await Future.delayed(Duration.zero); } diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 8e38812..1bf177e 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -6,6 +6,7 @@ import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; import '../errors/error_reporter.dart'; import 'label_data.dart'; +import 'order_print_meta.dart'; void _openPrintWindow(String html) { final win = web.window.open('', '_blank'); @@ -172,34 +173,81 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; } // ── 单据 PDF 字节(Web 走 HTML 打印路径,不生成 PDF;预览弹窗仅桌面端调用)────── -Future buildStockInOrderPdfImpl(StockInOrder order) async => +Future buildStockInOrderPdfImpl( + StockInOrder order, OrderPrintMeta meta) async => throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF'); -Future buildStockOutOrderPdfImpl(StockOutOrder order) async => +Future buildStockOutOrderPdfImpl( + StockOutOrder order, OrderPrintMeta meta) async => throw UnsupportedError('Web 端出库单走 HTML 打印,不生成 PDF'); +/// 单据打印共用样式(入库/出库一致)。 +const String _orderPrintCss = ''' + @page { size: A4; margin: 12mm 15mm; } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: "PingFang SC", "Microsoft YaHei", "SimSun", sans-serif; font-size: 11px; color: #000; } + .shop { text-align: center; font-size: 20px; font-weight: bold; } + .title { text-align: center; font-size: 16px; font-weight: bold; letter-spacing: 4px; margin: 4px 0 8px; } + .head-block { border-bottom: 1px solid #555; padding-bottom: 5px; margin-bottom: 8px; } + .meta-row { display: flex; font-size: 11px; } + .meta-row + .meta-row { margin-top: 3px; } + .meta-row .c1 { flex: 5; } .meta-row .c2 { flex: 4; } .meta-row .c3 { flex: 3; text-align: right; } + .meta-row .no { font-weight: bold; font-size: 12px; } + table { width: 100%; border-collapse: collapse; } + th, td { border: 1px solid #777; padding: 4px 4px; font-size: 10.5px; } + th { background: #eee; font-weight: 600; text-align: center; } + .c { text-align: center; } .r { text-align: right; } + .subtotal-bar { display: flex; justify-content: flex-end; align-items: baseline; gap: 30px; border: 1px solid #777; background: #f3f3f3; padding: 7px 10px; margin-top: 6px; font-size: 11px; } + .subtotal-bar .lbl-total { font-weight: bold; font-size: 12px; margin-right: auto; } + .subtotal-bar b { font-size: 13px; } + .tips { border: 1px solid #ddd; background: #fafafa; padding: 6px 8px; margin: 8px 0; font-size: 10px; color: #555; line-height: 1.6; } + .sigs { display: flex; justify-content: space-between; margin-top: 26px; font-size: 11px; } + .remark { margin-top: 10px; font-size: 11px; color: #333; } +'''; + +const String _orderTipsHtml = + '
温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,' + '如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。
'; + +/// 截取日期前 10 位(yyyy-MM-dd)。 +String _d10(String? s) => + (s == null || s.isEmpty) ? '' : (s.length > 10 ? s.substring(0, 10) : s); + +/// 单据明细表头(入库/出库一致)。 +const String _orderTableHead = ''' + 商品编号 + 商品名称 + 系列 + 规格 + 批次号 + 生产日期 + 数量 + 单价 + 金额 + 备注 + '''; + // ── 入库单 ────────────────────────────────────────────────────────────────── -Future printStockInOrderImpl(StockInOrder order) async { +Future printStockInOrderImpl( + StockInOrder order, OrderPrintMeta meta) async { final rows = StringBuffer(); double totalQty = 0; double totalAmt = 0; - for (int i = 0; i < order.items.length; i++) { - final item = order.items[i]; + for (final item in order.items) { totalQty += item.quantity; totalAmt += item.totalPrice; rows.write(''' - ${i + 1} ${item.productCode ?? ''} ${item.productName ?? ''} ${item.productSeries ?? ''} ${item.productSpec ?? ''} + ${item.batchNo ?? ''} + ${_d10(item.productionDate)} ${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)} - ${item.productUnit ?? ''} ${item.unitPrice.toStringAsFixed(2)} ${item.totalPrice.toStringAsFixed(2)} - ${item.productionDate ?? ''} - ${item.batchNo ?? ''} + '''); } @@ -211,72 +259,38 @@ Future printStockInOrderImpl(StockInOrder order) async { - + -
入 库 单
-
-
供应商:${order.partnerName ?? ''}
-
入库日期:${order.orderDate ?? ''}
-
NO: ${order.orderNo}
-
-
- 仓库:${order.warehouseName ?? ''} - 采购人:${order.operatorName ?? ''} +
${meta.shopName}
+
入 库 单
+
+
+
单位名称:${order.partnerName ?? ''}
+
入库日期:${_d10(order.orderDate)}
+
NO:${order.orderNo}
+
+
+
联系电话:
+
单位地址:
+
仓库:${order.warehouseName ?? ''}
+
- - - - - - - - - - - - - + $_orderTableHead $rows - - - - - - -
序号商品编号商品名称系列规格数量单位单价金额生产日期批次号
单据总计${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}${totalAmt.toStringAsFixed(2)}
- $remarkLine -
-
出货人(签字)
-
采购员:${order.operatorName ?? ''}
-
制单人:${order.reviewerName ?? order.operatorName ?? ''}
+
+ 单据总计 + 数量 ${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)} + 金额 ${totalAmt.toStringAsFixed(2)}
- +
+ 出货人(签字): + 采购员:${order.operatorName ?? ''} + 制单人:${meta.makerName} +
+ $remarkLine '''; @@ -299,24 +313,30 @@ Future detectDefaultPrinterImpl() async => null; // ── 出库单 ────────────────────────────────────────────────────────────────── -Future printStockOutOrderImpl(StockOutOrder order) async { +Future printStockOutOrderImpl( + StockOutOrder order, OrderPrintMeta meta) async { final rows = StringBuffer(); double totalQty = 0; double totalAmt = 0; - for (int i = 0; i < order.items.length; i++) { - final item = order.items[i]; + for (final item in order.items) { + // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、 + // 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。 + final unit = item.salePrice > 0 ? item.salePrice : item.unitPrice; + final amt = + item.salePrice > 0 ? item.salePrice * item.quantity : item.totalPrice; totalQty += item.quantity; - totalAmt += item.totalPrice; + totalAmt += amt; rows.write(''' - ${i + 1} ${item.productCode ?? ''} ${item.productName ?? ''} ${item.productSeries ?? ''} ${item.productSpec ?? ''} - ${item.productUnit ?? ''} + ${item.batchNo ?? ''} + ${_d10(item.productionDate)} ${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)} - ${item.unitPrice.toStringAsFixed(2)} - ${item.totalPrice.toStringAsFixed(2)} + ${unit.toStringAsFixed(2)} + ${amt.toStringAsFixed(2)} + '''); } @@ -328,71 +348,39 @@ Future printStockOutOrderImpl(StockOutOrder order) async { - + -
出 库 单
-
-
客户:${order.partnerName ?? ''}
-
出库日期:${order.orderDate ?? ''}
-
NO.${order.orderNo}
-
-
- 仓库:${order.warehouseName ?? ''} - 经办人:${order.operatorName ?? ''} +
${meta.shopName}
+
出 库 单
+
+
+
客户名称:${order.partnerName ?? ''}
+
出库日期:${_d10(order.orderDate)}
+
NO.${order.orderNo}
+
+
+
联系电话:
+
送货地址:
+
仓库:${order.warehouseName ?? ''}
+
- - - - - - - - - - - + $_orderTableHead $rows - - - - - -
序号商品编号商品名称系列规格单位数量单价金额
单据总计${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}${totalAmt.toStringAsFixed(2)}
- $remarkLine -
温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。
-
-
收货人(签字)
-
销售员:${order.operatorName ?? ''}
-
制单人:${order.reviewerName ?? order.operatorName ?? ''}
+
+ 单据总计 + 数量 ${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)} + 金额 ${totalAmt.toStringAsFixed(2)}
- + $_orderTipsHtml +
+ 收货人(签字): + 销售员:${order.operatorName ?? ''} + 制单人:${meta.makerName} +
+ $remarkLine '''; diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 50feb6c..8e798c7 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -227,7 +227,7 @@ class _StockInFormScreenState extends ConsumerState { Future _printOrder() async { if (_loadedOrder == null) return; - await showStockInOrderPrint(context, _loadedOrder!); + await showStockInOrderPrint(context, ref, _loadedOrder!); } Future _submit(bool asDraft) async { 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 b69bb67..350612e 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -530,7 +530,7 @@ class _StockInListScreenState extends ConsumerState { onPrint: () async { final order = await ref.read(stockInRepositoryProvider).get(o.id); if (context.mounted) { - await showStockInOrderPrint(context, order); + await showStockInOrderPrint(context, ref, order); } }, afterPrint: [ @@ -1075,7 +1075,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { IconButton( icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), tooltip: '打印', - onPressed: () => showStockInOrderPrint(context, _loadedOrder!), + onPressed: () => showStockInOrderPrint(context, ref, _loadedOrder!), ), ], ), diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index 15aa878..37776b9 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -258,7 +258,7 @@ class _StockOutFormScreenState extends ConsumerState { Future _printOrder() async { if (_loadedOrder == null) return; - await showStockOutOrderPrint(context, _loadedOrder!); + await showStockOutOrderPrint(context, ref, _loadedOrder!); } Future _submit(bool asDraft) async { 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 4cca14e..1bb8c55 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -558,7 +558,7 @@ class _StockOutListScreenState extends ConsumerState { onPrint: () async { final order = await ref.read(stockOutRepositoryProvider).get(o.id); if (context.mounted) { - await showStockOutOrderPrint(context, order); + await showStockOutOrderPrint(context, ref, order); } }, onSettle: () => _confirmSettle(context, o.id, 'stock_out'), @@ -986,7 +986,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { IconButton( icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), tooltip: '打印', - onPressed: () => showStockOutOrderPrint(context, _loadedOrder!), + onPressed: () => showStockOutOrderPrint(context, ref, _loadedOrder!), ), ], ), diff --git a/client/lib/widgets/order_print_preview_dialog.dart b/client/lib/widgets/order_print_preview_dialog.dart index f31f8dd..526c1e0 100644 --- a/client/lib/widgets/order_print_preview_dialog.dart +++ b/client/lib/widgets/order_print_preview_dialog.dart @@ -1,13 +1,17 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:printing/printing.dart'; +import '../core/auth/auth_state.dart'; import '../core/errors/error_reporter.dart'; import '../core/theme/app_theme.dart'; import '../core/responsive/responsive.dart'; import '../core/utils/print_util.dart'; +import '../models/shop.dart'; import '../models/stock_in.dart'; import '../models/stock_out.dart'; +import '../providers/shop_provider.dart'; /// 单据打印预览弹窗:把待打印的 A4 PDF 光栅化成图片(所见即所得)逐页展示, /// 页内点「打印」再调系统打印。解决部分 Windows 系统打印对话框无预览的问题。 @@ -221,11 +225,29 @@ class _OrderPrintPreviewDialogState extends State { } } +/// 组装单据打印抬头信息:公司名称(门店)+ 制单人(当前登录、点击打印的人)。 +Future _buildPrintMeta(WidgetRef ref) async { + ShopInfo? shop; + try { + shop = await ref.read(shopInfoProvider.future); + } catch (_) { + // 门店信息拉取失败(离线等)时抬头留空,不阻塞打印。 + } + return OrderPrintMeta( + shopName: shop?.name ?? '', + shopAddress: shop?.address ?? '', + shopPhone: shop?.phone ?? '', + makerName: ref.read(authStateProvider).user?.realName ?? '', + ); +} + /// 打印入库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。 Future showStockInOrderPrint( - BuildContext context, StockInOrder order) async { + BuildContext context, WidgetRef ref, StockInOrder order) async { + final meta = await _buildPrintMeta(ref); + if (!context.mounted) return; if (kIsWeb) { - await safePrint(context, () => printStockInOrder(order)); + await safePrint(context, () => printStockInOrder(order, meta)); return; } await showDialog( @@ -234,16 +256,18 @@ Future showStockInOrderPrint( builder: (_) => OrderPrintPreviewDialog( title: '入库单打印预览', printName: '入库单', - pdfBuilder: () => buildStockInOrderPdf(order), + pdfBuilder: () => buildStockInOrderPdf(order, meta), ), ); } /// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。 Future showStockOutOrderPrint( - BuildContext context, StockOutOrder order) async { + BuildContext context, WidgetRef ref, StockOutOrder order) async { + final meta = await _buildPrintMeta(ref); + if (!context.mounted) return; if (kIsWeb) { - await safePrint(context, () => printStockOutOrder(order)); + await safePrint(context, () => printStockOutOrder(order, meta)); return; } await showDialog( @@ -252,7 +276,7 @@ Future showStockOutOrderPrint( builder: (_) => OrderPrintPreviewDialog( title: '出库单打印预览', printName: '出库单', - pdfBuilder: () => buildStockOutOrderPdf(order), + pdfBuilder: () => buildStockOutOrderPdf(order, meta), ), ); }