feat(client): 新增入库单/出库单打印,增强商品标签打印布局
- 商品标签改为 4×2 英寸横向,二维码右置,左侧展示系列/规格/批次/生产日期 - 新增 printStockInOrder:A4 入库单,含单号/供应商/仓库/明细表/签字栏 - 新增 printStockOutOrder:A4 出库通知单(温馨提示),含客户/明细表/签字栏 - 入库单/出库单编辑页加载完成后显示「打印」按钮 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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('保存草稿'),
|
||||
|
||||
Reference in New Issue
Block a user