feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验

后端
- 新增 shop handler:GET/PUT /shop/info(管理员权限)
- 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款
- 新增 inventory UpdateRemark:PUT /inventory/:id/remark
- 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制)
- 种子数据 S001-S003 补充真实门店信息

前端
- 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人
- 入库单列表新增结清按钮(含确认弹窗),出库单同步
- 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填
- 入库/出库列表新增入库时间、出库时间、创建时间列
- 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」
- 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define)
- 库存备注支持点击编辑,超4字截断显示+Hover展示全文
- ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS)

文档
- 新增 docs/user-manual.md 完整用户操作手册(12章)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-23 14:05:41 +08:00
parent c77e1c1490
commit c1ed81dfab
60 changed files with 4664 additions and 1572 deletions
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
/// showDialog 的统一封装,自动在内容外层加 SelectionArea
/// 使对话框内的文字可以被选中/复制。
Future<T?> showAppDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color? barrierColor,
bool useRootNavigator = true,
RouteSettings? routeSettings,
}) {
return showDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
useRootNavigator: useRootNavigator,
routeSettings: routeSettings,
builder: (ctx) => SelectionArea(child: builder(ctx)),
);
}
+8
View File
@@ -12,6 +12,10 @@ Future<void> printProductLabel({
String? series,
String? batchNo,
String? productionDate,
String? remark,
String shopName = '',
String shopAddress = '',
String shopPhone = '',
}) =>
printProductLabelImpl(
qrBytes: qrBytes,
@@ -21,6 +25,10 @@ Future<void> printProductLabel({
series: series,
batchNo: batchNo,
productionDate: productionDate,
remark: remark,
shopName: shopName,
shopAddress: shopAddress,
shopPhone: shopPhone,
);
Future<void> printStockInOrder(StockInOrder order) =>
+441 -33
View File
@@ -5,6 +5,41 @@ import 'package:printing/printing.dart';
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
return pw.Font.ttf(data);
}
Future<pw.Font> _loadBoldFont() async {
// Fall back to regular if no bold variant
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
return pw.Font.ttf(data);
}
// ── 设计色彩 token ──────────────────────────────────────────────────────────
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
const _muted = PdfColor(0.533, 0.533, 0.533); // #888 field labels
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
pw.Widget _labelSpecRow(pw.Font font, String label, String value) =>
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(
width: 30,
child: pw.Text(label,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 4),
pw.Expanded(
child: pw.Text(value,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
);
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
required String name,
@@ -13,58 +48,431 @@ Future<void> printProductLabelImpl({
String? series,
String? batchNo,
String? productionDate,
String? remark,
String shopName = '',
String shopAddress = '',
String shopPhone = '',
}) async {
final fontData = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
final font = pw.Font.ttf(fontData);
final font = await _loadFont();
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
// Label: 4 × 2 inch
const labelW = 4.0 * PdfPageFormat.inch;
const labelH = 2.0 * PdfPageFormat.inch;
const headerH = labelH * 0.215;
const footerH = labelH * 0.09;
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
final footerLeft = contact.isNotEmpty ? contact : shopName;
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}'
' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}';
doc.addPage(pw.Page(
pageFormat: const PdfPageFormat(4 * PdfPageFormat.inch, 2 * PdfPageFormat.inch),
margin: const pw.EdgeInsets.all(10),
build: (_) => pw.Row(
pageFormat: const PdfPageFormat(labelW, labelH),
margin: pw.EdgeInsets.zero,
build: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
// ── Header:酒行名称 ─────────────────────────────────────────────────
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(name,
pw.Flexible(
child: pw.Text(shopName,
style: pw.TextStyle(
font: font, fontSize: 13,
fontWeight: pw.FontWeight.bold,
color: _cream, letterSpacing: 1.5,
)),
),
pw.Text('Certificate of Authenticity',
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)),
font: font, fontSize: 5,
color: const PdfColor(0.82, 0.79, 0.72))),
],
),
),
pw.SizedBox(width: 8),
pw.SizedBox(
width: 1.2 * PdfPageFormat.inch,
height: 1.2 * PdfPageFormat.inch,
child: pw.Image(qrImage),
// ── Body ─────────────────────────────────────────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(11, 8, 11, 6),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// 商品名
pw.Text(name,
style: pw.TextStyle(
font: font, fontSize: 12,
fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.5)),
pw.SizedBox(height: 6),
// 规格 + 系列
_label2ColRow(font, '规 格', specVal, '系 列', seriesVal),
pw.SizedBox(height: 3),
// 批号 + 生产日期
_label2ColRow(font, '批 号', batchVal, '生产日期', dateVal),
if ((remark ?? '').isNotEmpty) ...[
pw.SizedBox(height: 3),
_labelSpecRow(font, '备 注', remark!),
],
],
),
),
pw.SizedBox(width: 8),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.SizedBox(
width: 60, height: 60,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 3),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font, fontSize: 4.5,
color: PdfColors.grey600)),
],
),
],
),
),
),
// ── Footer ───────────────────────────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 11),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
),
pw.Text(genTime,
style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)),
],
),
),
],
),
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
}
pw.Widget _label2ColRow(
pw.Font font, String lbl1, String val1, String lbl2, String val2) {
return pw.Row(
children: [
pw.SizedBox(
width: 28,
child: pw.Text(lbl1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val1,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
pw.SizedBox(width: 6),
pw.SizedBox(
width: 28,
child: pw.Text(lbl2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)),
),
pw.SizedBox(width: 3),
pw.Expanded(
child: pw.Text(val2,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
),
],
);
}
pw.Widget _buildOrderDoc({
required pw.Font font,
required pw.Font bold,
required String title,
required String orderNo,
required String? orderDate,
required String? partnerLabel,
required String? partnerName,
required String? warehouseName,
required String? operatorName,
required String? reviewerName,
required String? remark,
required List<String> headers,
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 metaStyle = pw.TextStyle(font: font, fontSize: 10);
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();
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Center(
child: pw.Text(title,
style: pw.TextStyle(font: bold, fontSize: 20, fontWeight: pw.FontWeight.bold)),
),
pw.SizedBox(height: 8),
pw.Container(
decoration: const pw.BoxDecoration(
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey600)),
),
padding: const pw.EdgeInsets.only(bottom: 5),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('$partnerLabel${partnerName ?? ''}', style: metaStyle),
pw.Text('日期:${orderDate ?? ''}', style: metaStyle),
pw.Text('NO: $orderNo', style: boldStyle),
],
),
),
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] },
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(),
),
...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,
),
);
}
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.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400),
color: PdfColors.grey50,
),
padding: const pw.EdgeInsets.all(5),
child: pw.Text(tipsText,
style: pw.TextStyle(font: font, fontSize: 9, color: PdfColors.grey700)),
),
],
pw.SizedBox(height: 36),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
_sig(font, bold, '收/出货人(签字)'),
_sig(font, bold, '经办人:${operatorName ?? ''}'),
_sig(font, bold, '制单人:${reviewerName ?? operatorName ?? ''}'),
],
),
],
);
}
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)),
),
],
);
}
Future<void> printStockInOrderImpl(StockInOrder order) async {
// Native PDF printing not implemented; no-op on non-web platforms.
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];
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.unitPrice.toStringAsFixed(2),
it.totalPrice.toStringAsFixed(2),
it.productionDate ?? '',
it.batchNo ?? '',
]);
}
final doc = pw.Document();
doc.addPage(pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 15 * PdfPageFormat.mm, vertical: 12 * PdfPageFormat.mm),
build: (_) => [
_buildOrderDoc(
font: font, bold: bold,
title: '入 库 单',
orderNo: order.orderNo,
orderDate: order.orderDate,
partnerLabel: '供应商',
partnerName: order.partnerName,
warehouseName: order.warehouseName,
operatorName: order.operatorName,
reviewerName: order.reviewerName,
remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '数量', '单位', '单价', '金额', '生产日期', '批次'],
rows: rows,
totalQty: totalQty,
totalAmt: totalAmt,
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
}
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
// Native PDF printing not implemented; no-op on non-web platforms.
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];
totalQty += it.quantity;
totalAmt += it.totalPrice;
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),
]);
}
final doc = pw.Document();
doc.addPage(pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 15 * PdfPageFormat.mm, vertical: 12 * PdfPageFormat.mm),
build: (_) => [
_buildOrderDoc(
font: font, bold: bold,
title: '出 库 单',
orderNo: order.orderNo,
orderDate: order.orderDate,
partnerLabel: '客户',
partnerName: order.partnerName,
warehouseName: order.warehouseName,
operatorName: order.operatorName,
reviewerName: order.reviewerName,
remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '单位', '数量', '单价', '金额'],
rows: rows,
totalQty: totalQty,
totalAmt: totalAmt,
tipsText: '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。',
),
],
));
await Printing.layoutPdf(onLayout: (_) async => doc.save());
}
+305 -123
View File
@@ -5,6 +5,14 @@ import 'package:web/web.dart' as web;
import '../../models/stock_in.dart';
import '../../models/stock_out.dart';
void _openPrintWindow(String html) {
final win = web.window.open('', '_blank');
if (win != null) {
win.document.write(html.toJS);
win.document.close();
}
}
Future<void> printProductLabelImpl({
required Uint8List qrBytes,
required String name,
@@ -13,206 +21,380 @@ Future<void> printProductLabelImpl({
String? series,
String? batchNo,
String? productionDate,
String? remark,
String shopName = '',
String shopAddress = '',
String shopPhone = '',
}) async {
final base64Img = base64Encode(qrBytes);
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 specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '';
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
final footerContact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}'
' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}';
final remarkRow = (remark ?? '').isNotEmpty
? '''<div class="spec-row">
<span class="lbl">备 注</span>
<span class="val-full">$remark</span>
</div>'''
: '';
final html = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<style>
@page{size:4in 2in;margin:0;}
*{box-sizing:border-box;margin:0;padding:0;}
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;}
@page { size: 4in 2in; margin: 0; }
* { box-sizing: border-box; margin: 0; padding: 0;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { width: 4in; height: 2in; overflow: hidden; background: #fff; }
.label {
width: 4in; height: 2in;
display: grid; grid-template-rows: 42px 1fr 18px;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
}
/* ── Header:酒行名称 ── */
.hdr {
background: #1f2a3a; color: #f4ecd8;
display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; overflow: hidden;
}
.hdr-name {
font-family: "Noto Serif SC", serif;
font-weight: 700; font-size: 17px; letter-spacing: 0.08em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hdr-tag {
font-size: 6.5px; letter-spacing: 0.2em; opacity: 0.8;
font-style: italic; white-space: nowrap; flex-shrink: 0; padding-left: 10px;
}
/* ── Body ── */
.body {
background: #fff; padding: 9px 15px 8px;
display: grid; grid-template-columns: 1fr 76px; gap: 10px;
align-items: center;
}
/* 商品名 */
.product-name {
font-family: "Noto Serif SC", serif;
font-weight: 700; font-size: 13px; letter-spacing: 0.04em;
margin-bottom: 7px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* 规格行 */
.spec-row {
display: flex; gap: 6px; align-items: baseline;
font-size: 6.5px; margin-bottom: 3.5px; flex-wrap: nowrap;
}
.lbl { color: #888; letter-spacing: 0.1em; white-space: nowrap; flex-shrink: 0; }
.val { font-family: "IBM Plex Mono", monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.val-full { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: #444; font-size: 6px; }
/* 同行双字段 */
.spec-row-2 {
display: grid; grid-template-columns: 28px 1fr 28px 1fr;
gap: 0 7px; font-size: 6.5px; margin-bottom: 3.5px;
align-items: baseline;
}
/* QR */
.qr-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.qr-col img { width: 68px; height: 68px; object-fit: contain; display: block; }
.qr-cap { font-size: 5px; letter-spacing: 0.2em; color: #666; text-align: center; }
/* ── Footer:地址 + 生成时间 ── */
.ftr {
background: #f4ecd8;
display: flex; align-items: center; justify-content: space-between;
padding: 0 15px;
font-family: "IBM Plex Mono", monospace;
font-size: 5px; color: #5a4e35; letter-spacing: 0.04em; overflow: hidden;
}
</style>
</head>
<body>
<div class="label">
<div class="left">$leftRows</div>
<div class="qr"><img src="data:image/png;base64,$base64Img"/></div>
<div class="label">
<div class="hdr">
<span class="hdr-name">$shopName</span>
<span class="hdr-tag">Certificate of Authenticity</span>
</div>
<script>window.onload=function(){window.print();};</script>
<div class="body">
<div>
<div class="product-name">$name</div>
<div class="spec-row-2">
<span class="lbl">规 格</span>
<span class="val">$specVal</span>
<span class="lbl">系 列</span>
<span class="val">$seriesVal</span>
</div>
<div class="spec-row-2">
<span class="lbl">批 号</span>
<span class="val">$batchVal</span>
<span class="lbl">生产日期</span>
<span class="val">$dateVal</span>
</div>
$remarkRow
</div>
<div class="qr-col">
<img src="data:image/png;base64,$base64Img" />
<div class="qr-cap">扫码溯源 · TRACE</div>
</div>
</div>
<div class="ftr">
<span>${footerContact.isNotEmpty ? footerContact : shopName}</span>
<span>$genTime</span>
</div>
</div>
<script>
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(function() { window.print(); });
} else {
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();
}
_openPrintWindow(html);
}
// ── 入库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockInOrderImpl(StockInOrder order) async {
final rows = StringBuffer();
double total = 0;
double totalQty = 0;
double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) {
final item = order.items[i];
total += item.totalPrice;
totalQty += item.quantity;
totalAmt += item.totalPrice;
rows.write('''<tr>
<td style="text-align:center">${i + 1}</td>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</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>
<td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</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>
</tr>''');
}
final remarkLine = (order.remark ?? '').isNotEmpty
? '<div class="remark">备注:${order.remark}</div>'
: '';
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;}
@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>
<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 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>
<table>
<thead><tr>
<th>序号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次</th>
<th>数量</th><th>单位</th><th>单价</th><th>金额</th>
<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>
<tfoot><tr class="foot">
<td colspan="8" style="text-align:right;">合计</td>
<td style="text-align:right">${total.toStringAsFixed(2)}</td>
<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"></div>审核人</div>
<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">采购员:${order.operatorName ?? ''}</div></div>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div>
<script>window.onload=function(){window.print();};</script>
<div class="footer-note">酒库管理系统</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();
}
_openPrintWindow(html);
}
// ── 出库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockOutOrderImpl(StockOutOrder order) async {
final rows = StringBuffer();
double total = 0;
double totalQty = 0;
double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) {
final item = order.items[i];
total += item.totalPrice;
totalQty += item.quantity;
totalAmt += item.totalPrice;
rows.write('''<tr>
<td style="text-align:center">${i + 1}</td>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</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>
<td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</td>
<td class="c">${item.productUnit ?? ''}</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>
</tr>''');
}
final remarkLine = (order.remark ?? '').isNotEmpty
? '<div class="remark">备注:${order.remark}</div>'
: '';
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;}
@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>
<body>
<div class="header">
<h1>温馨提示</h1>
<div class="subtitle">出库通知单</div>
<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="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 class="meta2">
<span>仓库:${order.warehouseName ?? ''}</span>
<span>经办人:${order.operatorName ?? ''}</span>
</div>
<table>
<thead><tr>
<th>序号</th><th>商品名称</th><th>系列</th><th>规格</th>
<th>数量</th><th>单位</th><th>单价</th><th>金额</th>
<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>
<tfoot><tr class="foot">
<td colspan="7" style="text-align:right;">合计</td>
<td style="text-align:right">${total.toStringAsFixed(2)}</td>
<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"></div>审核人</div>
<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">销售员:${order.operatorName ?? ''}</div></div>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div>
<script>window.onload=function(){window.print();};</script>
<div class="footer-note">酒库管理系统</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();
}
_openPrintWindow(html);
}