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 '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<List<String>> listLabelPrinters() => listLabelPrintersImpl();
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
Future<void> printStockInOrder(StockInOrder order) =>
printStockInOrderImpl(order);
Future<void> printStockInOrder(StockInOrder order, OrderPrintMeta meta) =>
printStockInOrderImpl(order, meta);
Future<void> printStockOutOrder(StockOutOrder order) =>
printStockOutOrderImpl(order);
Future<void> printStockOutOrder(StockOutOrder order, OrderPrintMeta meta) =>
printStockOutOrderImpl(order, meta);
/// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockInOrderPdf(StockInOrder order) =>
buildStockInOrderPdfImpl(order);
Future<Uint8List> buildStockInOrderPdf(StockInOrder order, OrderPrintMeta meta) =>
buildStockInOrderPdfImpl(order, meta);
/// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockOutOrderPdf(StockOutOrder order) =>
buildStockOutOrderPdfImpl(order);
Future<Uint8List> buildStockOutOrderPdf(
StockOutOrder order, OrderPrintMeta meta) =>
buildStockOutOrderPdfImpl(order, meta);
/// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。
/// 用法: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_out.dart';
import 'label_data.dart';
import 'order_print_meta.dart';
Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
@@ -644,184 +645,263 @@ Future<List<String>> listLabelPrintersImpl() async {
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
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({
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<String> headers,
required bool showTips,
required List<List<String>> 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<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async {
Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async {
final font = await _loadFont();
final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[];
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<Uint8List> 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<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async {
return doc.save();
}
Future<void> printStockInOrderImpl(StockInOrder order) async {
Future<void> printStockInOrderImpl(
StockInOrder order, OrderPrintMeta meta) async {
try {
final bytes = await buildStockInOrderPdfImpl(order);
final bytes = await buildStockInOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero);
}
@@ -868,26 +955,31 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
}
/// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。
Future<Uint8List> buildStockOutOrderPdfImpl(StockOutOrder order) async {
Future<Uint8List> buildStockOutOrderPdfImpl(
StockOutOrder order, OrderPrintMeta meta) async {
final font = await _loadFont();
final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[];
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<Uint8List> 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<void> printStockOutOrderImpl(StockOutOrder order) async {
Future<void> printStockOutOrderImpl(
StockOutOrder order, OrderPrintMeta meta) async {
try {
final bytes = await buildStockOutOrderPdfImpl(order);
final bytes = await buildStockOutOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) {
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 '../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<Uint8List> buildStockInOrderPdfImpl(StockInOrder order) async =>
Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async =>
throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF');
Future<Uint8List> buildStockOutOrderPdfImpl(StockOutOrder order) async =>
Future<Uint8List> 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 =
'<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();
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('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</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="c">${item.productUnit ?? ''}</td>
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td>
<td class="c">${item.productionDate ?? ''}</td>
<td class="c">${item.batchNo ?? ''}</td>
<td></td>
</tr>''');
}
@@ -211,72 +259,38 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
<html>
<head>
<meta charset="utf-8">
<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>
<style>$_orderPrintCss</style>
</head>
<body>
<div class="hdr"><div class="title">入 库 单</div></div>
<div class="meta">
<div class="left">供应商:${order.partnerName ?? ''}</div>
<div class="center">入库日期:${order.orderDate ?? ''}</div>
<div class="right">NO: ${order.orderNo}</div>
</div>
<div class="meta2">
<span>仓库:${order.warehouseName ?? ''}</span>
<span>采购人:${order.operatorName ?? ''}</span>
<div class="shop">${meta.shopName}</div>
<div class="title">入 库 单</div>
<div class="head-block">
<div class="meta-row">
<div class="c1">单位名称:${order.partnerName ?? ''}</div>
<div class="c2">入库日期:${_d10(order.orderDate)}</div>
<div class="c3 no">NO${order.orderNo}</div>
</div>
<div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">单位地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div>
<table>
<thead><tr>
<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>
$_orderTableHead
<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>
$remarkLine
<div class="sigs">
<div class="sig"><div class="sig-line">出货人(签字)</div></div>
<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 class="subtotal-bar">
<span class="lbl-total">单据总计</span>
<span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
</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>
</body>
</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();
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('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</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.unitPrice.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td>
<td class="r">${unit.toStringAsFixed(2)}</td>
<td class="r">${amt.toStringAsFixed(2)}</td>
<td></td>
</tr>''');
}
@@ -328,71 +348,39 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
<html>
<head>
<meta charset="utf-8">
<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>
<style>$_orderPrintCss</style>
</head>
<body>
<div class="hdr"><div class="title">出 库 单</div></div>
<div class="meta">
<div class="left">客户:${order.partnerName ?? ''}</div>
<div class="center">出库日期:${order.orderDate ?? ''}</div>
<div class="right">NO.${order.orderNo}</div>
</div>
<div class="meta2">
<span>仓库:${order.warehouseName ?? ''}</span>
<span>经办人:${order.operatorName ?? ''}</span>
<div class="shop">${meta.shopName}</div>
<div class="title">出 库 单</div>
<div class="head-block">
<div class="meta-row">
<div class="c1">客户名称:${order.partnerName ?? ''}</div>
<div class="c2">出库日期:${_d10(order.orderDate)}</div>
<div class="c3 no">NO.${order.orderNo}</div>
</div>
<div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">送货地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div>
<table>
<thead><tr>
<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>
$_orderTableHead
<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>
$remarkLine
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div>
<div class="sigs">
<div class="sig"><div class="sig-line">收货人(签字)</div></div>
<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 class="subtotal-bar">
<span class="lbl-total">单据总计</span>
<span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
</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>
</body>
</html>''';
@@ -227,7 +227,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await showStockInOrderPrint(context, _loadedOrder!);
await showStockInOrderPrint(context, ref, _loadedOrder!);
}
Future<void> _submit(bool asDraft) async {
@@ -530,7 +530,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
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!),
),
],
),
@@ -258,7 +258,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await showStockOutOrderPrint(context, _loadedOrder!);
await showStockOutOrderPrint(context, ref, _loadedOrder!);
}
Future<void> _submit(bool asDraft) async {
@@ -558,7 +558,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
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!),
),
],
),
@@ -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<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 端走浏览器自带打印预览。
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) {
await safePrint(context, () => printStockInOrder(order));
await safePrint(context, () => printStockInOrder(order, meta));
return;
}
await showDialog<void>(
@@ -234,16 +256,18 @@ Future<void> showStockInOrderPrint(
builder: (_) => OrderPrintPreviewDialog(
title: '入库单打印预览',
printName: '入库单',
pdfBuilder: () => buildStockInOrderPdf(order),
pdfBuilder: () => buildStockInOrderPdf(order, meta),
),
);
}
/// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。
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) {
await safePrint(context, () => printStockOutOrder(order));
await safePrint(context, () => printStockOutOrder(order, meta));
return;
}
await showDialog<void>(
@@ -252,7 +276,7 @@ Future<void> showStockOutOrderPrint(
builder: (_) => OrderPrintPreviewDialog(
title: '出库单打印预览',
printName: '出库单',
pdfBuilder: () => buildStockOutOrderPdf(order),
pdfBuilder: () => buildStockOutOrderPdf(order, meta),
),
);
}