feat(client): 出入库单打印按新版式重做 + 单价口径修正

- 版式参照单据样板:公司抬头 + 两行三列表头 + 统一明细列
  (商品编号/名称/系列/规格/批次号/生产日期/数量/单价/金额/备注)
- 单据总计独立成行(仅数量+金额,右对齐高亮)
- 出库单保留温馨提示,入库单去掉
- 签字行:制单人=当前登录用户(OrderPrintMeta),采购员/销售员=经办人;
  公司名取门店(shopInfoProvider)
- 出库单单价=售价(sale_price),金额=售价×数量;历史单 sale_price=0
  时回退 unit_price/total_price。入库单单价=进价(unit_price)
- 桌面 PDF 与 Web HTML 两端同构

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-26 17:03:23 +08:00
parent 5187702fc5
commit a355a3a2a2
9 changed files with 418 additions and 285 deletions
@@ -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 = '',
});
}
+11 -8
View File
@@ -5,6 +5,8 @@ import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart'; import 'label_data.dart';
export 'label_data.dart'; export 'label_data.dart';
import 'order_print_meta.dart';
export 'order_print_meta.dart';
import 'print_util_stub.dart' import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart'; if (dart.library.js_interop) 'print_util_web.dart';
@@ -47,19 +49,20 @@ Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。 /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl(); Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
Future<void> printStockInOrder(StockInOrder order) => Future<void> printStockInOrder(StockInOrder order, OrderPrintMeta meta) =>
printStockInOrderImpl(order); printStockInOrderImpl(order, meta);
Future<void> printStockOutOrder(StockOutOrder order) => Future<void> printStockOutOrder(StockOutOrder order, OrderPrintMeta meta) =>
printStockOutOrderImpl(order); printStockOutOrderImpl(order, meta);
/// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 /// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockInOrderPdf(StockInOrder order) => Future<Uint8List> buildStockInOrderPdf(StockInOrder order, OrderPrintMeta meta) =>
buildStockInOrderPdfImpl(order); buildStockInOrderPdfImpl(order, meta);
/// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。 /// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockOutOrderPdf(StockOutOrder order) => Future<Uint8List> buildStockOutOrderPdf(
buildStockOutOrderPdfImpl(order); StockOutOrder order, OrderPrintMeta meta) =>
buildStockOutOrderPdfImpl(order, meta);
/// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。 /// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。
/// 用法:await safePrint(context, () => printStockInOrder(order)); /// 用法:await safePrint(context, () => printStockInOrder(order));
+229 -131
View File
@@ -25,6 +25,7 @@ import 'package:printing/printing.dart';
import '../../models/stock_in.dart'; import '../../models/stock_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import 'label_data.dart'; import 'label_data.dart';
import 'order_print_meta.dart';
Future<pw.Font> _loadFont() async { Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
@@ -644,184 +645,263 @@ Future<List<String>> listLabelPrintersImpl() async {
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。 /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter(); Future<String?> 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 = <double>[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({ pw.Widget _buildOrderDoc({
required pw.Font font, required pw.Font font,
required pw.Font bold, required pw.Font bold,
required String title, required String shopName, // 公司名称(顶部抬头)
required String title, // 入 库 单 / 出 库 单
required String orderNo, required String orderNo,
required String? orderDate, required String noPrefix, // 'NO' / 'NO.'
required String? partnerLabel, required String partnerLabel, // 单位名称 / 客户名称
required String? partnerName, required String? partnerName,
required String dateLabel, // 入库日期 / 出库日期
required String? orderDate,
required String addressLabel, // 单位地址 / 送货地址
required String? warehouseName, required String? warehouseName,
required String signerLabel, // 出货人(签字)/ 收货人(签字)
required String operatorRoleLabel, // 采购员 / 销售员(= 经办人)
required String? operatorName, required String? operatorName,
required String? reviewerName, required String? makerName, // 制单人 = 打印人
required String? remark, required String? remark,
required List<String> headers, required bool showTips,
required List<List<String>> rows, required List<List<String>> rows,
required double totalQty, required double totalQty,
required double totalAmt, 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);
final cellStyle = pw.TextStyle(font: font, fontSize: 9.5); final headStyle =
pw.TextStyle(font: bold, fontSize: 9.5, fontWeight: pw.FontWeight.bold);
final metaStyle = pw.TextStyle(font: font, fontSize: 10); 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) { pw.Widget metaText(String label, String value, pw.TextAlign align) =>
if (h == '商品名称') return const pw.FlexColumnWidth(2.2); pw.Text('$label$value', style: metaStyle, textAlign: align);
if (h == '系列' || h == '规格') return const pw.FlexColumnWidth(1.2);
if (h == '商品编号') return const pw.FlexColumnWidth(1.2);
return const pw.FlexColumnWidth(0.9);
}).toList();
return pw.Column( return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
// ── 公司名称 ──────────────────────────────────────────────────────────
pw.Center(
child: pw.Text(shopName,
style: pw.TextStyle(
font: bold, fontSize: 18, fontWeight: pw.FontWeight.bold)),
),
pw.SizedBox(height: 4),
// ── 单据类型 ──────────────────────────────────────────────────────────
pw.Center( pw.Center(
child: pw.Text(title, 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.SizedBox(height: 8),
// ── 抬头信息(两行三列)──────────────────────────────────────────────
pw.Container( pw.Container(
decoration: const pw.BoxDecoration( 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), padding: const pw.EdgeInsets.only(bottom: 5),
child: pw.Row( child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('$partnerLabel${partnerName ?? ''}', style: metaStyle), pw.Row(
pw.Text('日期:${orderDate ?? ''}', style: metaStyle), children: [
pw.Text('NO: $orderNo', style: boldStyle), 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.SizedBox(height: 8),
// ── 明细表 + 单据总计 ────────────────────────────────────────────────
pw.Table( pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey500), border: pw.TableBorder.all(color: PdfColors.grey600, width: 0.5),
columnWidths: { for (var i = 0; i < colWidths.length; i++) i: colWidths[i] }, columnWidths: {
for (var i = 0; i < _orderColFlex.length; i++)
i: pw.FlexColumnWidth(_orderColFlex[i])
},
children: [ children: [
pw.TableRow( pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey200), decoration: const pw.BoxDecoration(color: PdfColors.grey200),
children: headers.map((h) => pw.Padding( children: [
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 3), for (final h in _orderHeaders)
child: pw.Text(h, textAlign: pw.TextAlign.center, style: headerStyle), pw.Padding(
)).toList(), padding:
const pw.EdgeInsets.symmetric(horizontal: 2, vertical: 3),
child: pw.Text(h,
textAlign: pw.TextAlign.center, style: headStyle),
),
],
), ),
...rows.map((row) => pw.TableRow( for (final row in rows)
children: row.map((cell) => pw.Padding( pw.TableRow(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), children: [
child: pw.Text(cell, style: cellStyle), for (var i = 0; i < row.length; i++)
)).toList(), pw.Padding(
)), padding: const pw.EdgeInsets.symmetric(
pw.TableRow( horizontal: 2, vertical: 2.5),
decoration: const pw.BoxDecoration(color: PdfColors.grey100), child: pw.Text(row[i],
children: List.generate(headers.length, (i) { textAlign: _colAlign(i), style: cellStyle),
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,
), ),
); ],
} ),
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.SizedBox(height: 6),
pw.Text('备注:$remark', style: metaStyle), pw.Container(
], width: double.infinity,
if (tipsText != null) ...[ decoration: pw.BoxDecoration(
pw.SizedBox(height: 6), 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( pw.Container(
width: double.infinity,
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400), border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
color: PdfColors.grey50, color: PdfColors.grey50,
), ),
padding: const pw.EdgeInsets.all(5), padding: const pw.EdgeInsets.all(5),
child: pw.Text(tipsText, child: pw.Text(_kTipsText,
style: pw.TextStyle(font: font, fontSize: 9, color: PdfColors.grey700)), style: pw.TextStyle(
font: font, fontSize: 8.5, color: PdfColors.grey800)),
), ),
], ],
pw.SizedBox(height: 36), pw.SizedBox(height: 26),
// ── 签字行:签收人 / 经办人 / 制单人 ────────────────────────────────
pw.Row( pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
_sig(font, bold, '收/出货人(签字)'), pw.Text('$signerLabel', style: metaStyle),
_sig(font, bold, '经办人${operatorName ?? ''}'), pw.Text('$operatorRoleLabel${operatorName ?? ''}', style: metaStyle),
_sig(font, bold, '制单人:${reviewerName ?? operatorName ?? ''}'), pw.Text('制单人:${makerName ?? ''}', style: metaStyle),
], ],
), ),
], if ((remark ?? '').isNotEmpty) ...[
); pw.SizedBox(height: 10),
} pw.Text('备注:$remark', 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)),
),
], ],
); );
} }
/// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 /// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。
Future<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async { Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async {
final font = await _loadFont(); final font = await _loadFont();
final bold = await _loadBoldFont(); final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0; double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[]; final rows = <List<String>>[];
for (int i = 0; i < order.items.length; i++) { for (final it in order.items) {
final it = order.items[i];
totalQty += it.quantity; totalQty += it.quantity;
totalAmt += it.totalPrice; totalAmt += it.totalPrice;
rows.add([ rows.add([
'${i + 1}',
it.productCode ?? '', it.productCode ?? '',
it.productName ?? '', it.productName ?? '',
it.productSeries ?? '', it.productSeries ?? '',
it.productSpec ?? '', it.productSpec ?? '',
it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), it.batchNo ?? '',
it.productUnit ?? '', _d10(it.productionDate),
_qtyStr(it.quantity),
it.unitPrice.toStringAsFixed(2), it.unitPrice.toStringAsFixed(2),
it.totalPrice.toStringAsFixed(2), it.totalPrice.toStringAsFixed(2),
it.productionDate ?? '', '',
it.batchNo ?? '',
]); ]);
} }
@@ -832,16 +912,22 @@ Future<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async {
build: (_) => [ build: (_) => [
_buildOrderDoc( _buildOrderDoc(
font: font, bold: bold, font: font, bold: bold,
shopName: meta.shopName,
title: '入 库 单', title: '入 库 单',
orderNo: order.orderNo, orderNo: order.orderNo,
orderDate: order.orderDate, noPrefix: 'NO',
partnerLabel: '供应商', partnerLabel: '单位名称',
partnerName: order.partnerName, partnerName: order.partnerName,
dateLabel: '入库日期',
orderDate: order.orderDate,
addressLabel: '单位地址',
warehouseName: order.warehouseName, warehouseName: order.warehouseName,
signerLabel: '出货人(签字)',
operatorRoleLabel: '采购员',
operatorName: order.operatorName, operatorName: order.operatorName,
reviewerName: order.reviewerName, makerName: meta.makerName,
remark: order.remark, remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '数量', '单位', '单价', '金额', '生产日期', '批次'], showTips: false,
rows: rows, rows: rows,
totalQty: totalQty, totalQty: totalQty,
totalAmt: totalAmt, totalAmt: totalAmt,
@@ -851,9 +937,10 @@ Future<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async {
return doc.save(); return doc.save();
} }
Future<void> printStockInOrderImpl(StockInOrder order) async { Future<void> printStockInOrderImpl(
StockInOrder order, OrderPrintMeta meta) async {
try { try {
final bytes = await buildStockInOrderPdfImpl(order); final bytes = await buildStockInOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) { if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
} }
@@ -868,26 +955,31 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
} }
/// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。 /// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。
Future<Uint8List> buildStockOutOrderPdfImpl(StockOutOrder order) async { Future<Uint8List> buildStockOutOrderPdfImpl(
StockOutOrder order, OrderPrintMeta meta) async {
final font = await _loadFont(); final font = await _loadFont();
final bold = await _loadBoldFont(); final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0; double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[]; final rows = <List<String>>[];
for (int i = 0; i < order.items.length; i++) { for (final it in order.items) {
final it = order.items[i]; // 出库单「单价」= 售价: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; totalQty += it.quantity;
totalAmt += it.totalPrice; totalAmt += amt;
rows.add([ rows.add([
'${i + 1}',
it.productCode ?? '', it.productCode ?? '',
it.productName ?? '', it.productName ?? '',
it.productSeries ?? '', it.productSeries ?? '',
it.productSpec ?? '', it.productSpec ?? '',
it.productUnit ?? '', it.batchNo ?? '',
it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), _d10(it.productionDate),
it.unitPrice.toStringAsFixed(2), _qtyStr(it.quantity),
it.totalPrice.toStringAsFixed(2), unit.toStringAsFixed(2),
amt.toStringAsFixed(2),
'',
]); ]);
} }
@@ -898,29 +990,35 @@ Future<Uint8List> buildStockOutOrderPdfImpl(StockOutOrder order) async {
build: (_) => [ build: (_) => [
_buildOrderDoc( _buildOrderDoc(
font: font, bold: bold, font: font, bold: bold,
shopName: meta.shopName,
title: '出 库 单', title: '出 库 单',
orderNo: order.orderNo, orderNo: order.orderNo,
orderDate: order.orderDate, noPrefix: 'NO.',
partnerLabel: '客户', partnerLabel: '客户名称',
partnerName: order.partnerName, partnerName: order.partnerName,
dateLabel: '出库日期',
orderDate: order.orderDate,
addressLabel: '送货地址',
warehouseName: order.warehouseName, warehouseName: order.warehouseName,
signerLabel: '收货人(签字)',
operatorRoleLabel: '销售员',
operatorName: order.operatorName, operatorName: order.operatorName,
reviewerName: order.reviewerName, makerName: meta.makerName,
remark: order.remark, remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '单位', '数量', '单价', '金额'], showTips: true,
rows: rows, rows: rows,
totalQty: totalQty, totalQty: totalQty,
totalAmt: totalAmt, totalAmt: totalAmt,
tipsText: '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。',
), ),
], ],
)); ));
return doc.save(); return doc.save();
} }
Future<void> printStockOutOrderImpl(StockOutOrder order) async { Future<void> printStockOutOrderImpl(
StockOutOrder order, OrderPrintMeta meta) async {
try { try {
final bytes = await buildStockOutOrderPdfImpl(order); final bytes = await buildStockOutOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) { if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
} }
+122 -134
View File
@@ -6,6 +6,7 @@ import '../../models/stock_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart'; import 'label_data.dart';
import 'order_print_meta.dart';
void _openPrintWindow(String html) { void _openPrintWindow(String html) {
final win = web.window.open('', '_blank'); final win = web.window.open('', '_blank');
@@ -172,34 +173,81 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
// ── 单据 PDF 字节(Web 走 HTML 打印路径,不生成 PDF;预览弹窗仅桌面端调用)────── // ── 单据 PDF 字节(Web 走 HTML 打印路径,不生成 PDF;预览弹窗仅桌面端调用)──────
Future<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async => Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async =>
throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF'); throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF');
Future<Uint8List> buildStockOutOrderPdfImpl(StockOutOrder order) async => Future<Uint8List> buildStockOutOrderPdfImpl(
StockOutOrder order, OrderPrintMeta meta) async =>
throw UnsupportedError('Web 端出库单走 HTML 打印,不生成 PDF'); 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 =
'<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,'
'如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div>';
/// 截取日期前 10 位(yyyy-MM-dd)。
String _d10(String? s) =>
(s == null || s.isEmpty) ? '' : (s.length > 10 ? s.substring(0, 10) : s);
/// 单据明细表头(入库/出库一致)。
const String _orderTableHead = '''<thead><tr>
<th style="width:12%">商品编号</th>
<th style="width:16%">商品名称</th>
<th style="width:9%">系列</th>
<th style="width:10%">规格</th>
<th style="width:14%">批次号</th>
<th style="width:11%">生产日期</th>
<th style="width:5%">数量</th>
<th style="width:8%">单价</th>
<th style="width:9%">金额</th>
<th style="width:6%">备注</th>
</tr></thead>''';
// ── 入库单 ────────────────────────────────────────────────────────────────── // ── 入库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockInOrderImpl(StockInOrder order) async { Future<void> printStockInOrderImpl(
StockInOrder order, OrderPrintMeta meta) async {
final rows = StringBuffer(); final rows = StringBuffer();
double totalQty = 0; double totalQty = 0;
double totalAmt = 0; double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) { for (final item in order.items) {
final item = order.items[i];
totalQty += item.quantity; totalQty += item.quantity;
totalAmt += item.totalPrice; totalAmt += item.totalPrice;
rows.write('''<tr> rows.write('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td> <td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td> <td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</td> <td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</td> <td class="c">${item.productSpec ?? ''}</td>
<td class="c">${item.batchNo ?? ''}</td>
<td class="c">${_d10(item.productionDate)}</td>
<td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td> <td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td>
<td class="c">${item.productUnit ?? ''}</td>
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td> <td class="r">${item.unitPrice.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td> <td class="r">${item.totalPrice.toStringAsFixed(2)}</td>
<td class="c">${item.productionDate ?? ''}</td> <td></td>
<td class="c">${item.batchNo ?? ''}</td>
</tr>'''); </tr>''');
} }
@@ -211,72 +259,38 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>$_orderPrintCss</style>
@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; }
.hdr { text-align: center; margin-bottom: 10px; }
.hdr .title { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
.meta { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 6px; border-bottom: 1px solid #666; padding-bottom: 5px; }
.meta .left, .meta .center, .meta .right { flex: 1; }
.meta .center { text-align: center; }
.meta .right { text-align: right; font-weight: bold; font-size: 12px; }
.meta2 { display: flex; gap: 24px; margin-bottom: 8px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
th, td { border: 1px solid #999; padding: 4px 5px; }
th { background: #f0f0f0; font-weight: 600; text-align: center; font-size: 11px; }
td { font-size: 10.5px; }
.c { text-align: center; }
.r { text-align: right; }
.subtotal { font-weight: bold; background: #f7f7f7; }
.remark { margin: 6px 0; font-size: 11px; color: #333; }
.sigs { display: flex; justify-content: space-between; margin-top: 28px; }
.sig { text-align: center; min-width: 120px; }
.sig-line { border-top: 1px solid #555; padding-top: 4px; margin-top: 28px; font-size: 11px; }
.footer-note { text-align: right; font-size: 9px; color: #aaa; margin-top: 6px; }
</style>
</head> </head>
<body> <body>
<div class="hdr"><div class="title">入 库 单</div></div> <div class="shop">${meta.shopName}</div>
<div class="meta"> <div class="title">入 库 单</div>
<div class="left">供应商:${order.partnerName ?? ''}</div> <div class="head-block">
<div class="center">入库日期:${order.orderDate ?? ''}</div> <div class="meta-row">
<div class="right">NO: ${order.orderNo}</div> <div class="c1">单位名称:${order.partnerName ?? ''}</div>
</div> <div class="c2">入库日期:${_d10(order.orderDate)}</div>
<div class="meta2"> <div class="c3 no">NO${order.orderNo}</div>
<span>仓库:${order.warehouseName ?? ''}</span> </div>
<span>采购人:${order.operatorName ?? ''}</span> <div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">单位地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div> </div>
<table> <table>
<thead><tr> $_orderTableHead
<th style="width:3%">序号</th>
<th style="width:9%">商品编号</th>
<th style="width:18%">商品名称</th>
<th style="width:10%">系列</th>
<th style="width:9%">规格</th>
<th style="width:5%">数量</th>
<th style="width:5%">单位</th>
<th style="width:8%">单价</th>
<th style="width:8%">金额</th>
<th style="width:10%">生产日期</th>
<th style="width:10%">批次号</th>
</tr></thead>
<tbody>$rows</tbody> <tbody>$rows</tbody>
<tfoot><tr class="subtotal">
<td colspan="5" class="r">单据总计</td>
<td class="r">${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</td>
<td></td><td></td>
<td class="r">${totalAmt.toStringAsFixed(2)}</td>
<td colspan="2"></td>
</tr></tfoot>
</table> </table>
$remarkLine <div class="subtotal-bar">
<div class="sigs"> <span class="lbl-total">单据总计</span>
<div class="sig"><div class="sig-line">出货人(签字)</div></div> <span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<div class="sig"><div class="sig-line">采购员:${order.operatorName ?? ''}</div></div> <span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div> </div>
<div class="footer-note">酒库管理系统</div> <div class="sigs">
<span>出货人(签字):</span>
<span>采购员:${order.operatorName ?? ''}</span>
<span>制单人:${meta.makerName}</span>
</div>
$remarkLine
<script>window.onload = function(){ window.print(); };</script> <script>window.onload = function(){ window.print(); };</script>
</body> </body>
</html>'''; </html>''';
@@ -299,24 +313,30 @@ Future<String?> detectDefaultPrinterImpl() async => null;
// ── 出库单 ────────────────────────────────────────────────────────────────── // ── 出库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockOutOrderImpl(StockOutOrder order) async { Future<void> printStockOutOrderImpl(
StockOutOrder order, OrderPrintMeta meta) async {
final rows = StringBuffer(); final rows = StringBuffer();
double totalQty = 0; double totalQty = 0;
double totalAmt = 0; double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) { for (final item in order.items) {
final item = order.items[i]; // 出库单「单价」= 售价: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; totalQty += item.quantity;
totalAmt += item.totalPrice; totalAmt += amt;
rows.write('''<tr> rows.write('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td> <td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td> <td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</td> <td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</td> <td class="c">${item.productSpec ?? ''}</td>
<td class="c">${item.productUnit ?? ''}</td> <td class="c">${item.batchNo ?? ''}</td>
<td class="c">${_d10(item.productionDate)}</td>
<td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td> <td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td>
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td> <td class="r">${unit.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td> <td class="r">${amt.toStringAsFixed(2)}</td>
<td></td>
</tr>'''); </tr>''');
} }
@@ -328,71 +348,39 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>$_orderPrintCss</style>
@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; }
.hdr { text-align: center; margin-bottom: 10px; }
.hdr .title { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
.meta { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 6px; border-bottom: 1px solid #666; padding-bottom: 5px; }
.meta .left, .meta .center, .meta .right { flex: 1; }
.meta .center { text-align: center; }
.meta .right { text-align: right; font-weight: bold; font-size: 12px; }
.meta2 { display: flex; gap: 24px; margin-bottom: 8px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
th, td { border: 1px solid #999; padding: 4px 5px; }
th { background: #f0f0f0; font-weight: 600; text-align: center; font-size: 11px; }
td { font-size: 10.5px; }
.c { text-align: center; }
.r { text-align: right; }
.subtotal { font-weight: bold; background: #f7f7f7; }
.remark { margin: 6px 0; font-size: 11px; color: #333; }
.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: 28px; }
.sig { text-align: center; min-width: 120px; }
.sig-line { border-top: 1px solid #555; padding-top: 4px; margin-top: 28px; font-size: 11px; }
.footer-note { text-align: right; font-size: 9px; color: #aaa; margin-top: 6px; }
</style>
</head> </head>
<body> <body>
<div class="hdr"><div class="title">出 库 单</div></div> <div class="shop">${meta.shopName}</div>
<div class="meta"> <div class="title">出 库 单</div>
<div class="left">客户:${order.partnerName ?? ''}</div> <div class="head-block">
<div class="center">出库日期:${order.orderDate ?? ''}</div> <div class="meta-row">
<div class="right">NO.${order.orderNo}</div> <div class="c1">客户名称:${order.partnerName ?? ''}</div>
</div> <div class="c2">出库日期:${_d10(order.orderDate)}</div>
<div class="meta2"> <div class="c3 no">NO.${order.orderNo}</div>
<span>仓库:${order.warehouseName ?? ''}</span> </div>
<span>经办人:${order.operatorName ?? ''}</span> <div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">送货地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div> </div>
<table> <table>
<thead><tr> $_orderTableHead
<th style="width:3%">序号</th>
<th style="width:9%">商品编号</th>
<th style="width:20%">商品名称</th>
<th style="width:11%">系列</th>
<th style="width:10%">规格</th>
<th style="width:5%">单位</th>
<th style="width:5%">数量</th>
<th style="width:9%">单价</th>
<th style="width:9%">金额</th>
</tr></thead>
<tbody>$rows</tbody> <tbody>$rows</tbody>
<tfoot><tr class="subtotal">
<td colspan="6" class="r">单据总计</td>
<td class="r">${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</td>
<td></td>
<td class="r">${totalAmt.toStringAsFixed(2)}</td>
</tr></tfoot>
</table> </table>
$remarkLine <div class="subtotal-bar">
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div> <span class="lbl-total">单据总计</span>
<div class="sigs"> <span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<div class="sig"><div class="sig-line">收货人(签字)</div></div> <span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
<div class="sig"><div class="sig-line">销售员:${order.operatorName ?? ''}</div></div>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div> </div>
<div class="footer-note">酒库管理系统</div> $_orderTipsHtml
<div class="sigs">
<span>收货人(签字):</span>
<span>销售员:${order.operatorName ?? ''}</span>
<span>制单人:${meta.makerName}</span>
</div>
$remarkLine
<script>window.onload = function(){ window.print(); };</script> <script>window.onload = function(){ window.print(); };</script>
</body> </body>
</html>'''; </html>''';
@@ -227,7 +227,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Future<void> _printOrder() async { Future<void> _printOrder() async {
if (_loadedOrder == null) return; if (_loadedOrder == null) return;
await showStockInOrderPrint(context, _loadedOrder!); await showStockInOrderPrint(context, ref, _loadedOrder!);
} }
Future<void> _submit(bool asDraft) async { Future<void> _submit(bool asDraft) async {
@@ -530,7 +530,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onPrint: () async { onPrint: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id); final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (context.mounted) { if (context.mounted) {
await showStockInOrderPrint(context, order); await showStockInOrderPrint(context, ref, order);
} }
}, },
afterPrint: [ afterPrint: [
@@ -1075,7 +1075,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
IconButton( IconButton(
icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark),
tooltip: '打印', tooltip: '打印',
onPressed: () => showStockInOrderPrint(context, _loadedOrder!), onPressed: () => showStockInOrderPrint(context, ref, _loadedOrder!),
), ),
], ],
), ),
@@ -258,7 +258,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _printOrder() async { Future<void> _printOrder() async {
if (_loadedOrder == null) return; if (_loadedOrder == null) return;
await showStockOutOrderPrint(context, _loadedOrder!); await showStockOutOrderPrint(context, ref, _loadedOrder!);
} }
Future<void> _submit(bool asDraft) async { Future<void> _submit(bool asDraft) async {
@@ -558,7 +558,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onPrint: () async { onPrint: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id); final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (context.mounted) { if (context.mounted) {
await showStockOutOrderPrint(context, order); await showStockOutOrderPrint(context, ref, order);
} }
}, },
onSettle: () => _confirmSettle(context, o.id, 'stock_out'), onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
@@ -986,7 +986,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
IconButton( IconButton(
icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark), icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark),
tooltip: '打印', tooltip: '打印',
onPressed: () => showStockOutOrderPrint(context, _loadedOrder!), onPressed: () => showStockOutOrderPrint(context, ref, _loadedOrder!),
), ),
], ],
), ),
@@ -1,13 +1,17 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import '../core/auth/auth_state.dart';
import '../core/errors/error_reporter.dart'; import '../core/errors/error_reporter.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../core/responsive/responsive.dart'; import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart'; import '../core/utils/print_util.dart';
import '../models/shop.dart';
import '../models/stock_in.dart'; import '../models/stock_in.dart';
import '../models/stock_out.dart'; import '../models/stock_out.dart';
import '../providers/shop_provider.dart';
/// 单据打印预览弹窗:把待打印的 A4 PDF 光栅化成图片(所见即所得)逐页展示, /// 单据打印预览弹窗:把待打印的 A4 PDF 光栅化成图片(所见即所得)逐页展示,
/// 页内点「打印」再调系统打印。解决部分 Windows 系统打印对话框无预览的问题。 /// 页内点「打印」再调系统打印。解决部分 Windows 系统打印对话框无预览的问题。
@@ -221,11 +225,29 @@ class _OrderPrintPreviewDialogState extends State<OrderPrintPreviewDialog> {
} }
} }
/// 组装单据打印抬头信息:公司名称(门店)+ 制单人(当前登录、点击打印的人)。
Future<OrderPrintMeta> _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 端走浏览器自带打印预览。 /// 打印入库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。
Future<void> showStockInOrderPrint( Future<void> showStockInOrderPrint(
BuildContext context, StockInOrder order) async { BuildContext context, WidgetRef ref, StockInOrder order) async {
final meta = await _buildPrintMeta(ref);
if (!context.mounted) return;
if (kIsWeb) { if (kIsWeb) {
await safePrint(context, () => printStockInOrder(order)); await safePrint(context, () => printStockInOrder(order, meta));
return; return;
} }
await showDialog<void>( await showDialog<void>(
@@ -234,16 +256,18 @@ Future<void> showStockInOrderPrint(
builder: (_) => OrderPrintPreviewDialog( builder: (_) => OrderPrintPreviewDialog(
title: '入库单打印预览', title: '入库单打印预览',
printName: '入库单', printName: '入库单',
pdfBuilder: () => buildStockInOrderPdf(order), pdfBuilder: () => buildStockInOrderPdf(order, meta),
), ),
); );
} }
/// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。 /// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。
Future<void> showStockOutOrderPrint( Future<void> showStockOutOrderPrint(
BuildContext context, StockOutOrder order) async { BuildContext context, WidgetRef ref, StockOutOrder order) async {
final meta = await _buildPrintMeta(ref);
if (!context.mounted) return;
if (kIsWeb) { if (kIsWeb) {
await safePrint(context, () => printStockOutOrder(order)); await safePrint(context, () => printStockOutOrder(order, meta));
return; return;
} }
await showDialog<void>( await showDialog<void>(
@@ -252,7 +276,7 @@ Future<void> showStockOutOrderPrint(
builder: (_) => OrderPrintPreviewDialog( builder: (_) => OrderPrintPreviewDialog(
title: '出库单打印预览', title: '出库单打印预览',
printName: '出库单', printName: '出库单',
pdfBuilder: () => buildStockOutOrderPdf(order), pdfBuilder: () => buildStockOutOrderPdf(order, meta),
), ),
); );
} }