feat(client): 新增入库单/出库单打印,增强商品标签打印布局

- 商品标签改为 4×2 英寸横向,二维码右置,左侧展示系列/规格/批次/生产日期
- 新增 printStockInOrder:A4 入库单,含单号/供应商/仓库/明细表/签字栏
- 新增 printStockOutOrder:A4 出库通知单(温馨提示),含客户/明细表/签字栏
- 入库单/出库单编辑页加载完成后显示「打印」按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-19 00:50:54 +08:00
parent 7ac06a409d
commit 142e92f151
6 changed files with 291 additions and 35 deletions
+21 -4
View File
@@ -1,13 +1,30 @@
import 'dart:typed_data';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart';
/// 打印商品标签(二维码 + 名称 + 编号)。
/// Web:生成 HTML 弹出新窗口调用浏览器打印。
/// 非 Web:生成 PDF 用 printing 包调起原生打印。
Future<void> printProductLabel({
required Uint8List qrBytes,
required String name,
required String code,
String? spec,
}) => printProductLabelImpl(qrBytes: qrBytes, name: name, code: code, spec: spec);
String? series,
String? batchNo,
String? productionDate,
}) =>
printProductLabelImpl(
qrBytes: qrBytes,
name: name,
code: code,
spec: spec,
series: series,
batchNo: batchNo,
productionDate: productionDate,
);
Future<void> printStockInOrder(StockInOrder order) =>
printStockInOrderImpl(order);
Future<void> printStockOutOrder(StockOutOrder order) =>
printStockOutOrderImpl(order);
+50 -18
View File
@@ -2,37 +2,69 @@ import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
required String name,
required String code,
String? spec,
String? series,
String? batchNo,
String? productionDate,
}) async {
final fontData = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
final font = pw.Font.ttf(fontData);
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
doc.addPage(pw.Page(
pageFormat: PdfPageFormat.a4,
build: (_) => pw.Center(
child: pw.Column(
mainAxisSize: pw.MainAxisSize.min,
children: [
pw.Image(qrImage, width: 200, height: 200),
pw.SizedBox(height: 12),
pw.Text(name,
style: pw.TextStyle(
font: font, fontSize: 18, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 4),
pw.Text('编号:$code',
style: pw.TextStyle(font: font, fontSize: 13)),
if ((spec ?? '').isNotEmpty)
pw.Text('规格:$spec',
style: pw.TextStyle(font: font, fontSize: 13)),
],
),
pageFormat: const PdfPageFormat(4 * PdfPageFormat.inch, 2 * PdfPageFormat.inch),
margin: const pw.EdgeInsets.all(10),
build: (_) => pw.Row(
children: [
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(name,
style: pw.TextStyle(
font: font, fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 3),
pw.Text('编号:$code',
style: pw.TextStyle(font: font, fontSize: 10)),
if ((series ?? '').isNotEmpty)
pw.Text('系列:$series',
style: pw.TextStyle(font: font, fontSize: 10)),
if ((spec ?? '').isNotEmpty)
pw.Text('规格:$spec',
style: pw.TextStyle(font: font, fontSize: 10)),
if ((batchNo ?? '').isNotEmpty)
pw.Text('批次:$batchNo',
style: pw.TextStyle(font: font, fontSize: 10)),
if ((productionDate ?? '').isNotEmpty)
pw.Text('生产日期:$productionDate',
style: pw.TextStyle(font: font, fontSize: 10)),
],
),
),
pw.SizedBox(width: 8),
pw.SizedBox(
width: 1.2 * PdfPageFormat.inch,
height: 1.2 * PdfPageFormat.inch,
child: pw.Image(qrImage),
),
],
),
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
}
Future<void> printStockInOrderImpl(StockInOrder order) async {
// Native PDF printing not implemented; no-op on non-web platforms.
}
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
// Native PDF printing not implemented; no-op on non-web platforms.
}
+185 -13
View File
@@ -2,37 +2,209 @@ import 'dart:convert';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
required String name,
required String code,
String? spec,
String? series,
String? batchNo,
String? productionDate,
}) async {
final base64Img = base64Encode(qrBytes);
final specHtml = (spec != null && spec.isNotEmpty)
? '<p style="margin:4px 0;font-size:13px;color:#555;">规格:$spec</p>'
: '';
final leftRows = StringBuffer();
leftRows.write('<div class="name">$name</div>');
leftRows.write('<div class="row">编号:$code</div>');
if (series != null && series.isNotEmpty) leftRows.write('<div class="row">系列:$series</div>');
if (spec != null && spec.isNotEmpty) leftRows.write('<div class="row">规格:$spec</div>');
if (batchNo != null && batchNo.isNotEmpty) leftRows.write('<div class="row">批次:$batchNo</div>');
if (productionDate != null && productionDate.isNotEmpty) {
leftRows.write('<div class="row">生产日期:$productionDate</div>');
}
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page{size:4in 2in;margin:0;}
*{box-sizing:border-box;margin:0;padding:0;}
body{display:flex;justify-content:center;align-items:center;min-height:100vh;
font-family:"PingFang SC","Microsoft YaHei",sans-serif;}
.label{text-align:center;padding:24px;}
img{width:200px;height:200px;display:block;margin:0 auto;}
h2{margin:12px 0 4px;font-size:18px;font-weight:bold;}
p{margin:4px 0;font-size:13px;color:#444;}
body{display:flex;justify-content:center;align-items:center;width:4in;height:2in;
font-family:"PingFang SC","Microsoft YaHei",sans-serif;overflow:hidden;}
.label{display:flex;width:100%;height:100%;padding:10px;align-items:center;gap:8px;}
.left{flex:1;display:flex;flex-direction:column;justify-content:center;gap:3px;overflow:hidden;}
.name{font-size:15px;font-weight:bold;line-height:1.2;word-break:break-all;}
.row{font-size:11px;color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.qr{width:1.2in;height:1.2in;flex-shrink:0;}
img{width:100%;height:100%;display:block;}
</style>
</head>
<body>
<div class="label">
<img src="data:image/png;base64,$base64Img"/>
<h2>$name</h2>
<p>编号:$code</p>
$specHtml
<div class="left">$leftRows</div>
<div class="qr"><img src="data:image/png;base64,$base64Img"/></div>
</div>
<script>window.onload=function(){window.print();};</script>
</body>
</html>''';
final win = web.window.open('', '_blank');
if (win != null) {
win.document.write(html.toJS);
win.document.close();
}
}
Future<void> printStockInOrderImpl(StockInOrder order) async {
final rows = StringBuffer();
double total = 0;
for (int i = 0; i < order.items.length; i++) {
final item = order.items[i];
total += item.totalPrice;
rows.write('''<tr>
<td style="text-align:center">${i + 1}</td>
<td>${item.productName ?? ''}</td>
<td>${item.productSeries ?? ''}</td>
<td>${item.productSpec ?? ''}</td>
<td>${item.batchNo ?? ''}</td>
<td style="text-align:right">${item.quantity.toStringAsFixed(0)}</td>
<td style="text-align:center">${item.productUnit ?? ''}</td>
<td style="text-align:right">${item.unitPrice.toStringAsFixed(2)}</td>
<td style="text-align:right">${item.totalPrice.toStringAsFixed(2)}</td>
</tr>''');
}
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page{size:A4;margin:15mm;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#000;}
h1{text-align:center;font-size:20px;margin-bottom:12px;}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;margin-bottom:12px;}
.row{display:flex;gap:4px;}
.lbl{color:#555;white-space:nowrap;}
table{width:100%;border-collapse:collapse;margin-bottom:8px;}
th,td{border:1px solid #ccc;padding:5px 6px;}
th{background:#f5f5f5;font-weight:600;text-align:center;}
.foot td{font-weight:bold;background:#f9f9f9;}
.sigs{display:flex;justify-content:space-between;margin-top:32px;}
.sig{text-align:center;width:30%;}
.sig-line{border-top:1px solid #333;margin-bottom:4px;height:32px;}
</style>
</head>
<body>
<h1>入库单</h1>
<div class="grid">
<div class="row"><span class="lbl">单号:</span><span>${order.orderNo}</span></div>
<div class="row"><span class="lbl">日期:</span><span>${order.orderDate ?? ''}</span></div>
<div class="row"><span class="lbl">供应商:</span><span>${order.partnerName ?? ''}</span></div>
<div class="row"><span class="lbl">仓库:</span><span>${order.warehouseName ?? ''}</span></div>
<div class="row"><span class="lbl">采购人:</span><span>${order.operatorName ?? ''}</span></div>
<div class="row"><span class="lbl">备注:</span><span>${order.remark ?? ''}</span></div>
</div>
<table>
<thead><tr>
<th>序号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次</th>
<th>数量</th><th>单位</th><th>单价</th><th>金额</th>
</tr></thead>
<tbody>$rows</tbody>
<tfoot><tr class="foot">
<td colspan="8" style="text-align:right;">合计</td>
<td style="text-align:right">${total.toStringAsFixed(2)}</td>
</tr></tfoot>
</table>
<div class="sigs">
<div class="sig"><div class="sig-line"></div>制单人</div>
<div class="sig"><div class="sig-line"></div>审核人</div>
<div class="sig"><div class="sig-line"></div>供应商签字</div>
</div>
<script>window.onload=function(){window.print();};</script>
</body>
</html>''';
final win = web.window.open('', '_blank');
if (win != null) {
win.document.write(html.toJS);
win.document.close();
}
}
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
final rows = StringBuffer();
double total = 0;
for (int i = 0; i < order.items.length; i++) {
final item = order.items[i];
total += item.totalPrice;
rows.write('''<tr>
<td style="text-align:center">${i + 1}</td>
<td>${item.productName ?? ''}</td>
<td>${item.productSeries ?? ''}</td>
<td>${item.productSpec ?? ''}</td>
<td style="text-align:right">${item.quantity.toStringAsFixed(0)}</td>
<td style="text-align:center">${item.productUnit ?? ''}</td>
<td style="text-align:right">${item.unitPrice.toStringAsFixed(2)}</td>
<td style="text-align:right">${item.totalPrice.toStringAsFixed(2)}</td>
</tr>''');
}
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page{size:A4;margin:15mm;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#000;}
.header{text-align:center;margin-bottom:16px;}
h1{font-size:20px;margin-bottom:2px;}
.subtitle{font-size:13px;color:#555;}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 24px;margin-bottom:12px;}
.row{display:flex;gap:4px;}
.lbl{color:#555;white-space:nowrap;}
table{width:100%;border-collapse:collapse;margin-bottom:8px;}
th,td{border:1px solid #ccc;padding:5px 6px;}
th{background:#f5f5f5;font-weight:600;text-align:center;}
.foot td{font-weight:bold;background:#f9f9f9;}
.sigs{display:flex;justify-content:space-between;margin-top:32px;}
.sig{text-align:center;width:30%;}
.sig-line{border-top:1px solid #333;margin-bottom:4px;height:32px;}
</style>
</head>
<body>
<div class="header">
<h1>温馨提示</h1>
<div class="subtitle">出库通知单</div>
</div>
<div class="grid">
<div class="row"><span class="lbl">单号:</span><span>${order.orderNo}</span></div>
<div class="row"><span class="lbl">日期:</span><span>${order.orderDate ?? ''}</span></div>
<div class="row"><span class="lbl">客户:</span><span>${order.partnerName ?? ''}</span></div>
<div class="row"><span class="lbl">仓库:</span><span>${order.warehouseName ?? ''}</span></div>
<div class="row"><span class="lbl">经办人:</span><span>${order.operatorName ?? ''}</span></div>
<div class="row"><span class="lbl">备注:</span><span>${order.remark ?? ''}</span></div>
</div>
<table>
<thead><tr>
<th>序号</th><th>商品名称</th><th>系列</th><th>规格</th>
<th>数量</th><th>单位</th><th>单价</th><th>金额</th>
</tr></thead>
<tbody>$rows</tbody>
<tfoot><tr class="foot">
<td colspan="7" style="text-align:right;">合计</td>
<td style="text-align:right">${total.toStringAsFixed(2)}</td>
</tr></tfoot>
</table>
<div class="sigs">
<div class="sig"><div class="sig-line"></div>制单人</div>
<div class="sig"><div class="sig-line"></div>审核人</div>
<div class="sig"><div class="sig-line"></div>客户签字</div>
</div>
<script>window.onload=function(){window.print();};</script>
</body>
@@ -533,6 +533,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
);
} catch (e) {
if (mounted) {
@@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../models/stock_in.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/product_provider.dart';
@@ -29,6 +31,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
StockInOrder? _loadedOrder;
final List<_ItemRow> _items = [];
@@ -53,6 +56,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final specOpts = await ref.read(productSpecListProvider.future);
setState(() {
_loadedOrder = order;
_warehouseId = order.warehouseId;
_partnerId = order.partnerId;
if (order.orderDate != null) {
@@ -124,6 +128,11 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
});
}
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await printStockInOrder(_loadedOrder!);
}
Future<void> _submit(bool asDraft) async {
if (!asDraft && !_formKey.currentState!.validate()) return;
if (_warehouseId == null) {
@@ -248,6 +257,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Text(_isEdit ? '修改入库单' : '新建入库单',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
if (_isEdit && _loadedOrder != null) ...[
OutlinedButton.icon(
onPressed: _printOrder,
icon: const Icon(Icons.print_outlined, size: 16),
label: const Text('打印'),
),
const SizedBox(width: 8),
],
OutlinedButton(
onPressed: _submitting ? null : () => _submit(true),
child: const Text('保存草稿'),
@@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../models/stock_out.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_option_provider.dart';
@@ -29,6 +31,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
StockOutOrder? _loadedOrder;
final List<_ItemRow> _items = [];
@@ -53,6 +56,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final specOpts = await ref.read(productSpecListProvider.future);
setState(() {
_loadedOrder = order;
_warehouseId = order.warehouseId;
_partnerId = order.partnerId;
if (order.orderDate != null) {
@@ -125,6 +129,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
});
}
Future<void> _printOrder() async {
if (_loadedOrder == null) return;
await printStockOutOrder(_loadedOrder!);
}
Future<void> _submit(bool asDraft) async {
if (!asDraft && !_formKey.currentState!.validate()) return;
if (_warehouseId == null) {
@@ -248,6 +257,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Text(_isEdit ? '修改出库单' : '新建出库单',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
if (_isEdit && _loadedOrder != null) ...[
OutlinedButton.icon(
onPressed: _printOrder,
icon: const Icon(Icons.print_outlined, size: 16),
label: const Text('打印'),
),
const SizedBox(width: 8),
],
OutlinedButton(
onPressed: _submitting ? null : () => _submit(true),
child: const Text('保存草稿'),