diff --git a/sql/migrate_label_pdf_fields.sql b/sql/migrate_label_pdf_fields.sql new file mode 100644 index 0000000..c7f736a --- /dev/null +++ b/sql/migrate_label_pdf_fields.sql @@ -0,0 +1,87 @@ +-- 已有库增量:订单面单 PDF 字段 + 销售产品 LabelTemplateId +-- 对应:docs/面单标签模板与PDF生成方案.md §4.2 / §4.3 +-- 可重复执行 + +-- ========== t_order ========== +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelUrl' +); +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `LabelUrl` VARCHAR(500) NULL COMMENT ''面单PDF相对路径'' AFTER `IsRemote`', + 'SELECT ''t_order.LabelUrl already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelTemplateId' +); +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `LabelTemplateId` INT NULL COMMENT ''面单模板Id'' AFTER `LabelUrl`', + 'SELECT ''t_order.LabelTemplateId already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelGenerateStatus' +); +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `LabelGenerateStatus` VARCHAR(32) NULL COMMENT ''Pending/Ready/Failed'' AFTER `LabelTemplateId`', + 'SELECT ''t_order.LabelGenerateStatus already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'LabelGenerateError' +); +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_order` ADD COLUMN `LabelGenerateError` VARCHAR(500) NULL COMMENT ''面单生成失败原因'' AFTER `LabelGenerateStatus`', + 'SELECT ''t_order.LabelGenerateError already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(1) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_order' AND INDEX_NAME = 'idx_ord_label_status' +); +SET @sql := IF( + @idx_exists = 0, + 'ALTER TABLE `t_order` ADD KEY `idx_ord_label_status` (`LabelGenerateStatus`)', + 'SELECT ''idx_ord_label_status already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========== t_sales_product ========== +SET @col_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_sales_product' AND COLUMN_NAME = 'LabelTemplateId' +); +SET @sql := IF( + @col_exists = 0, + 'ALTER TABLE `t_sales_product` ADD COLUMN `LabelTemplateId` INT NULL COMMENT ''客户面单模板Id'' AFTER `CustomerWaybill`', + 'SELECT ''t_sales_product.LabelTemplateId already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========== 全局默认模板配置种子(AppKey 填模板 Id,Enabled=0 默认关闭)========== +INSERT INTO `t_system_other_config` + (`ConfigCode`, `ConfigName`, `Enabled`, `AppKey`, `TimeoutSeconds`, `Remark`, `CreateTime`) +SELECT + 'label_default_template', + '全局默认面单模板', + 0, + NULL, + 30, + 'AppKey=标签模板Id;启用后产品未绑模板时回落此 Id', + NOW() +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_system_other_config` WHERE `ConfigCode` = 'label_default_template' +); diff --git a/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs b/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs new file mode 100644 index 0000000..10c393e --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/LabelTemplateListItemOutput.cs @@ -0,0 +1,17 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 标签模板列表项(不含 TemplateJson,列表更轻)。 +/// +public class LabelTemplateListItemOutput +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? TemplateType { get; set; } + + public DateTime CreateTime { get; set; } + + public DateTime? UpdateTime { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs b/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs new file mode 100644 index 0000000..112714e --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/LabelTemplatePreviewInput.cs @@ -0,0 +1,16 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 标签模板预览入参。 +/// +public class LabelTemplatePreviewInput +{ + /// 已保存模板 Id(与 TemplateJson 二选一,优先 Id)。 + public int? TemplateId { get; set; } + + /// 未保存时可直接传设计器 JSON。 + public object? TemplateJson { get; set; } + + /// 可选 PrintData;空则用 Mock。 + public Dictionary? PrintData { get; set; } +} diff --git a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs index 3bbedf3..e91886b 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductDetailOutput.cs @@ -40,6 +40,11 @@ public class SalesProductDetailOutput /// public string? CustomerWaybill { get; set; } + /// + /// 客户面单绑定的标签模板 Id。 + /// + public int? LabelTemplateId { get; set; } + /// /// 黑名单分区主键。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs index 9b16759..8ac24f0 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductListOutput.cs @@ -40,6 +40,11 @@ public class SalesProductListOutput /// public string? CustomerWaybill { get; set; } + /// + /// 客户面单绑定的标签模板 Id。 + /// + public int? LabelTemplateId { get; set; } + /// /// 黑名单分区主键。 /// diff --git a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs index 9ce59ed..65b1aa6 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductSaveInput.cs @@ -38,10 +38,15 @@ public class SalesProductSaveInput public int? MaxTimeliness { get; set; } /// - /// 客户面单类型。 + /// 客户面单类型(旧枚举,兼容展示)。 /// public string? CustomerWaybill { get; set; } + /// + /// 客户面单绑定的标签模板 Id。 + /// + public int? LabelTemplateId { get; set; } + /// /// 黑名单分区主键。 /// diff --git a/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs b/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs index 5acd01e..5cd1302 100644 --- a/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs +++ b/src/Oms.Application/Dtos/Operations/LabelPrintRecordOutput.cs @@ -62,6 +62,11 @@ public class LabelPrintRecordOutput /// public string? OperatorName { get; set; } + /// + /// 面单 PDF 地址。 + /// + public string? PrintUrl { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs index 268c93f..95f2988 100644 --- a/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderPrintOutput.cs @@ -6,7 +6,17 @@ namespace Oms.Application.Dtos.Orders; public class OrderPrintOutput { /// - /// 打印地址。 + /// 打印地址(PDF)。 /// public string PrintUrl { get; set; } = string.Empty; + + /// + /// 面单生成状态:Pending / Ready / Failed。 + /// + public string? LabelGenerateStatus { get; set; } + + /// + /// 面单生成失败原因。 + /// + public string? LabelGenerateError { get; set; } } diff --git a/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs b/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs new file mode 100644 index 0000000..49b11a9 --- /dev/null +++ b/src/Oms.Application/Labeling/Hiprint/BarcodeImageGenerator.cs @@ -0,0 +1,37 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using ZXing; +using ZXing.Common; +using ZXingImageSharp = ZXing.ImageSharp; + +namespace Oms.Application.Labeling.Hiprint; + +/// +/// 条码 / 二维码 PNG 生成。 +/// +public static class BarcodeImageGenerator +{ + public static byte[] CreateBarcodePng(string content, int widthPx, int heightPx, bool qrCode) + { + widthPx = Math.Clamp(widthPx, 40, 1200); + heightPx = Math.Clamp(heightPx, 40, 1200); + + var writer = new ZXingImageSharp.BarcodeWriter + { + Format = qrCode ? BarcodeFormat.QR_CODE : BarcodeFormat.CODE_128, + Options = new EncodingOptions + { + Width = widthPx, + Height = heightPx, + Margin = 1, + PureBarcode = !qrCode + } + }; + + using var image = writer.Write(content); + using var ms = new MemoryStream(); + image.Save(ms, new PngEncoder()); + return ms.ToArray(); + } +} diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs new file mode 100644 index 0000000..50c401b --- /dev/null +++ b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; + +namespace Oms.Application.Labeling.Hiprint; + +/// +/// vg-print / hiprint 导出的模板根节点(只映射渲染所需字段)。 +/// +public sealed class HiprintTemplateDocument +{ + [JsonPropertyName("panels")] + public List Panels { get; set; } = []; +} + +public sealed class HiprintPanel +{ + [JsonPropertyName("index")] + public int Index { get; set; } + + /// 纸宽,单位 mm。 + [JsonPropertyName("width")] + public float Width { get; set; } = 100f; + + /// 纸高,单位 mm。 + [JsonPropertyName("height")] + public float Height { get; set; } = 150f; + + [JsonPropertyName("printElements")] + public List PrintElements { get; set; } = []; +} + +public sealed class HiprintPrintElement +{ + [JsonPropertyName("options")] + public HiprintElementOptions Options { get; set; } = new(); + + [JsonPropertyName("printElementType")] + public HiprintElementType? PrintElementType { get; set; } +} + +public sealed class HiprintElementType +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } +} + +public sealed class HiprintElementOptions +{ + [JsonPropertyName("left")] + public float Left { get; set; } + + [JsonPropertyName("top")] + public float Top { get; set; } + + [JsonPropertyName("width")] + public float Width { get; set; } + + [JsonPropertyName("height")] + public float Height { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("field")] + public string? Field { get; set; } + + [JsonPropertyName("fontSize")] + public float? FontSize { get; set; } + + [JsonPropertyName("fontWeight")] + public string? FontWeight { get; set; } + + [JsonPropertyName("fontFamily")] + public string? FontFamily { get; set; } + + [JsonPropertyName("color")] + public string? Color { get; set; } + + [JsonPropertyName("textAlign")] + public string? TextAlign { get; set; } + + [JsonPropertyName("textType")] + public string? TextType { get; set; } + + [JsonPropertyName("hideTitle")] + public bool? HideTitle { get; set; } + + [JsonPropertyName("src")] + public string? Src { get; set; } + + [JsonPropertyName("borderWidth")] + public float? BorderWidth { get; set; } +} diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs new file mode 100644 index 0000000..ea2e5ec --- /dev/null +++ b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs @@ -0,0 +1,201 @@ +using System.Globalization; +using System.Text.Json; +using Fake.Application; +using Fake.DependencyInjection; + +namespace Oms.Application.Labeling.Hiprint; + +/// +/// 将 hiprint TemplateJson 转为中间绘制 IR。 +/// +public sealed class HiprintTemplateParser : ITransientDependency +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public LabelPageIr Parse(string templateJson, IReadOnlyDictionary printData) + { + if (string.IsNullOrWhiteSpace(templateJson)) + { + throw new ArgumentException("模板 JSON 为空", nameof(templateJson)); + } + + var doc = JsonSerializer.Deserialize(templateJson, JsonOptions) + ?? throw new BusinessException("模板 JSON 无法解析"); + + var panel = doc.Panels.OrderBy(x => x.Index).FirstOrDefault() + ?? throw new BusinessException("模板缺少 panels"); + + var widthMm = panel.Width > 0 ? panel.Width : 100f; + var heightMm = panel.Height > 0 ? panel.Height : 150f; + var ops = new List(); + + foreach (var element in panel.PrintElements) + { + var op = MapElement(element, printData); + if (op != null) + { + ops.Add(op); + } + } + + return new LabelPageIr + { + WidthMm = widthMm, + HeightMm = heightMm, + Ops = ops + }; + } + + private static LabelDrawOp? MapElement( + HiprintPrintElement element, + IReadOnlyDictionary printData) + { + var type = element.PrintElementType?.Type?.Trim().ToLowerInvariant() ?? "text"; + var opt = element.Options; + var left = opt.Left; + var top = opt.Top; + var width = Math.Max(opt.Width, 1f); + var height = Math.Max(opt.Height, 1f); + + switch (type) + { + case "hline": + return new LabelLineOp + { + LeftPt = left, + TopPt = top, + WidthPt = width, + HeightPt = Math.Max(opt.BorderWidth ?? 1f, 0.5f), + Vertical = false, + ColorHex = opt.Color, + ThicknessPt = opt.BorderWidth ?? 1f + }; + case "vline": + return new LabelLineOp + { + LeftPt = left, + TopPt = top, + WidthPt = Math.Max(opt.BorderWidth ?? 1f, 0.5f), + HeightPt = height, + Vertical = true, + ColorHex = opt.Color, + ThicknessPt = opt.BorderWidth ?? 1f + }; + case "rect": + return new LabelRectOp + { + LeftPt = left, + TopPt = top, + WidthPt = width, + HeightPt = height, + BorderColorHex = opt.Color, + BorderWidthPt = opt.BorderWidth ?? 1f + }; + case "image": + return new LabelImageOp + { + LeftPt = left, + TopPt = top, + WidthPt = width, + HeightPt = height, + Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData) + }; + case "longtext": + return BuildTextOp(opt, left, top, width, height, printData, wrap: true); + case "text": + default: + { + var textType = opt.TextType?.Trim().ToLowerInvariant(); + if (textType is "barcode" or "qrcode") + { + var content = ResolveValue(opt.Field, opt.Title, printData); + if (string.IsNullOrWhiteSpace(content)) + { + return null; + } + + return new LabelBarcodeOp + { + LeftPt = left, + TopPt = top, + WidthPt = width, + HeightPt = height, + Content = content, + QrCode = textType == "qrcode" + }; + } + + return BuildTextOp(opt, left, top, width, height, printData, wrap: false); + } + } + } + + private static LabelTextOp? BuildTextOp( + HiprintElementOptions opt, + float left, + float top, + float width, + float height, + IReadOnlyDictionary printData, + bool wrap) + { + var value = ResolveValue(opt.Field, null, printData); + string text; + if (!string.IsNullOrWhiteSpace(opt.Field)) + { + // 有 field:默认只打字段值;hideTitle=false 且有 title 时拼「标题:值」 + if (opt.HideTitle == false && !string.IsNullOrWhiteSpace(opt.Title) && !string.IsNullOrWhiteSpace(value)) + { + text = $"{opt.Title}:{value}"; + } + else + { + text = value ?? string.Empty; + } + } + else + { + text = opt.Title ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase) + || opt.FontWeight is "700" or "600"; + + return new LabelTextOp + { + LeftPt = left, + TopPt = top, + WidthPt = width, + HeightPt = height, + Text = text, + FontSizePt = opt.FontSize is > 0 ? opt.FontSize.Value : 10f, + Bold = bold, + ColorHex = opt.Color, + Align = (opt.TextAlign ?? "left").ToLowerInvariant(), + Wrap = wrap + }; + } + + private static string? ResolveValue( + string? field, + string? fallback, + IReadOnlyDictionary printData) + { + if (!string.IsNullOrWhiteSpace(field) && printData.TryGetValue(field, out var raw) && raw != null) + { + return Convert.ToString(raw, CultureInfo.InvariantCulture); + } + + return fallback; + } +} diff --git a/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs b/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs new file mode 100644 index 0000000..03329e5 --- /dev/null +++ b/src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs @@ -0,0 +1,56 @@ +namespace Oms.Application.Labeling.Hiprint; + +/// +/// 中间绘制指令(与具体 PDF 引擎解耦)。 +/// 坐标单位:pt;页面尺寸:mm(再换算)。 +/// +public sealed class LabelPageIr +{ + public float WidthMm { get; init; } = 100f; + + public float HeightMm { get; init; } = 150f; + + public List Ops { get; init; } = []; +} + +public abstract class LabelDrawOp +{ + public float LeftPt { get; init; } + public float TopPt { get; init; } + public float WidthPt { get; init; } + public float HeightPt { get; init; } +} + +public sealed class LabelTextOp : LabelDrawOp +{ + public string Text { get; init; } = string.Empty; + public float FontSizePt { get; init; } = 10f; + public bool Bold { get; init; } + public string? ColorHex { get; init; } + public string Align { get; init; } = "left"; + public bool Wrap { get; init; } +} + +public sealed class LabelBarcodeOp : LabelDrawOp +{ + public string Content { get; init; } = string.Empty; + public bool QrCode { get; init; } +} + +public sealed class LabelLineOp : LabelDrawOp +{ + public bool Vertical { get; init; } + public string? ColorHex { get; init; } + public float ThicknessPt { get; init; } = 1f; +} + +public sealed class LabelRectOp : LabelDrawOp +{ + public string? BorderColorHex { get; init; } + public float BorderWidthPt { get; init; } = 1f; +} + +public sealed class LabelImageOp : LabelDrawOp +{ + public string? Src { get; init; } +} diff --git a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs new file mode 100644 index 0000000..1d6f98f --- /dev/null +++ b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs @@ -0,0 +1,170 @@ +using Fake.DependencyInjection; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Oms.Application.Labeling.Hiprint; + +/// +/// QuestPDF 实现的面单渲染器。 +/// +public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabelPdfRenderer, ITransientDependency +{ + private static int _licenseConfigured; + + public Task RenderAsync( + string templateJson, + IReadOnlyDictionary printData, + CancellationToken cancellationToken = default) + { + EnsureLicense(); + cancellationToken.ThrowIfCancellationRequested(); + + var pageIr = parser.Parse(templateJson, printData); + var pageWidthPt = MmToPt(pageIr.WidthMm); + var pageHeightPt = MmToPt(pageIr.HeightMm); + + var bytes = Document.Create(container => + { + container.Page(page => + { + page.Size(pageWidthPt, pageHeightPt); + page.Margin(0); + page.Content().Layers(layers => + { + layers.PrimaryLayer().Background(Colors.White); + + foreach (var op in pageIr.Ops) + { + DrawOp(layers, op); + } + }); + }); + }).GeneratePdf(); + + return Task.FromResult(bytes); + } + + private static void DrawOp(LayersDescriptor layers, LabelDrawOp op) + { + switch (op) + { + case LabelTextOp text: + { + var box = layers.Layer() + .Unconstrained() + .TranslateX(text.LeftPt) + .TranslateY(text.TopPt) + .Width(text.WidthPt) + .Height(text.HeightPt); + + var aligned = text.Align switch + { + "center" => box.AlignCenter(), + "right" or "end" => box.AlignRight(), + _ => box.AlignLeft() + }; + + var span = aligned + .AlignTop() + .Text(text.Text) + .FontSize(text.FontSizePt) + .FontColor(ParseColor(text.ColorHex)); + + if (text.Bold) + { + span.Bold(); + } + + break; + } + + case LabelBarcodeOp barcode: + { + var pxW = Math.Max(40, (int)(barcode.WidthPt * 2)); + var pxH = Math.Max(40, (int)(barcode.HeightPt * 2)); + var png = BarcodeImageGenerator.CreateBarcodePng(barcode.Content, pxW, pxH, barcode.QrCode); + layers.Layer() + .Unconstrained() + .TranslateX(barcode.LeftPt) + .TranslateY(barcode.TopPt) + .Width(barcode.WidthPt) + .Height(barcode.HeightPt) + .Image(png) + .FitArea(); + break; + } + + case LabelLineOp line: + { + var w = line.Vertical ? Math.Max(line.ThicknessPt, 0.5f) : line.WidthPt; + var h = line.Vertical ? line.HeightPt : Math.Max(line.ThicknessPt, 0.5f); + layers.Layer() + .Unconstrained() + .TranslateX(line.LeftPt) + .TranslateY(line.TopPt) + .Width(w) + .Height(h) + .Background(ParseColor(line.ColorHex)); + break; + } + + case LabelRectOp rect: + layers.Layer() + .Unconstrained() + .TranslateX(rect.LeftPt) + .TranslateY(rect.TopPt) + .Width(rect.WidthPt) + .Height(rect.HeightPt) + .BorderColor(ParseColor(rect.BorderColorHex)) + .Border(rect.BorderWidthPt); + break; + + case LabelImageOp image when !string.IsNullOrWhiteSpace(image.Src) && File.Exists(image.Src): + layers.Layer() + .Unconstrained() + .TranslateX(image.LeftPt) + .TranslateY(image.TopPt) + .Width(image.WidthPt) + .Height(image.HeightPt) + .Image(image.Src) + .FitArea(); + break; + } + } + + private static void EnsureLicense() + { + if (Interlocked.Exchange(ref _licenseConfigured, 1) == 1) + { + return; + } + + QuestPDF.Settings.License = LicenseType.Community; + } + + private static float MmToPt(float mm) => mm * 72f / 25.4f; + + private static Color ParseColor(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) + { + return Colors.Black; + } + + var value = hex.Trim(); + if (!value.StartsWith('#')) + { + value = "#" + value; + } + + try + { + return Color.FromHex(value); + } + catch + { + return Colors.Black; + } + } +} diff --git a/src/Oms.Application/Labeling/ILabelPdfRenderer.cs b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs new file mode 100644 index 0000000..5b60aef --- /dev/null +++ b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs @@ -0,0 +1,12 @@ +namespace Oms.Application.Labeling; + +/// +/// 面单 PDF 渲染器:TemplateJson + PrintData → PDF bytes。 +/// +public interface ILabelPdfRenderer +{ + Task RenderAsync( + string templateJson, + IReadOnlyDictionary printData, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Application/Labeling/LabelFieldDictionary.cs b/src/Oms.Application/Labeling/LabelFieldDictionary.cs new file mode 100644 index 0000000..32113c6 --- /dev/null +++ b/src/Oms.Application/Labeling/LabelFieldDictionary.cs @@ -0,0 +1,120 @@ +namespace Oms.Application.Labeling; + +/// +/// 设计器字段字典(与 PrintData camelCase 对齐)。 +/// +public static class LabelFieldDictionary +{ + public static IReadOnlyList GetGroups() => + [ + new LabelFieldGroup + { + Code = "numbers", + Title = "单号", + Fields = + [ + Field("serviceNo", "运单号"), + Field("clientNo", "客户单号"), + Field("trackNo", "跟踪号"), + Field("requestId", "请求号") + ] + }, + new LabelFieldGroup + { + Code = "product", + Title = "产品 / 渠道 / 仓库", + Fields = + [ + Field("productCode", "产品编码"), + Field("salesProductName", "销售产品"), + Field("channelCode", "渠道编码"), + Field("channelName", "渠道名称"), + Field("warehouseName", "仓库"), + Field("customerCode", "客户编码"), + Field("customerName", "客户名称") + ] + }, + new LabelFieldGroup + { + Code = "recipient", + Title = "收件人", + Fields = + [ + Field("recipientName", "收件人"), + Field("recipientCompany", "收件公司"), + Field("recipientPhone", "收件电话"), + Field("recipientEmail", "收件邮箱"), + Field("recipientZip", "邮编"), + Field("recipientCountry", "国家代码"), + Field("recipientCountryName", "国家名称"), + Field("recipientProvince", "省/州"), + Field("recipientCity", "城市"), + Field("recipientStreet1", "街道1"), + Field("recipientStreet2", "街道2"), + Field("recipientStreet3", "街道3"), + Field("recipientAddressFull", "完整地址"), + Field("recipientTaxNo", "税号"), + Field("vatNo", "VAT"), + Field("eori", "EORI") + ] + }, + new LabelFieldGroup + { + Code = "sender", + Title = "发件人", + Fields = + [ + Field("senderName", "发件人"), + Field("senderCompany", "发件公司"), + Field("senderPhone", "发件电话"), + Field("senderEmail", "发件邮箱"), + Field("senderZip", "发件邮编"), + Field("senderProvince", "发件省/州"), + Field("senderCity", "发件城市"), + Field("senderStreet1", "发件街道1"), + Field("senderAddressFull", "发件完整地址") + ] + }, + new LabelFieldGroup + { + Code = "weight", + Title = "重量尺寸", + Fields = + [ + Field("actualWeight", "实重"), + Field("volumetricWeight", "体积重"), + Field("chargeableWeight", "计费重"), + Field("length", "长"), + Field("width", "宽"), + Field("height", "高"), + Field("weightUnit", "重量单位"), + Field("dimUnit", "尺寸单位") + ] + }, + new LabelFieldGroup + { + Code = "system", + Title = "系统", + Fields = + [ + Field("printTime", "打印时间"), + Field("operatorName", "操作人") + ] + } + ]; + + private static LabelFieldItem Field(string field, string title) => new() { Field = field, Title = title }; +} + +public sealed class LabelFieldGroup +{ + public string Code { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public List Fields { get; init; } = []; +} + +public sealed class LabelFieldItem +{ + public string Field { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; +} diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs new file mode 100644 index 0000000..c86365d --- /dev/null +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -0,0 +1,183 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Oms.Application.Dtos.Orders; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Consts; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Labeling; + +/// +/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。 +/// +public sealed class LabelPdfGenerationService( + ILabelPdfRenderer renderer, + ILabelTemplateRepository labelTemplateRepository, + ISalesProductRepository salesProductRepository, + ISystemOtherConfigRepository systemOtherConfigRepository, + IOrderRepository orderRepository, + IWebHostEnvironment webHostEnvironment, + ILogger logger) : ITransientDependency +{ + public async Task GenerateAndPersistAsync( + Order order, + OrderOutput orderOutput, + int? templateIdOverride = null, + string? operatorName = null, + CancellationToken cancellationToken = default) + { + try + { + var templateId = await ResolveTemplateIdAsync(order, templateIdOverride, cancellationToken); + if (!templateId.HasValue) + { + return await MarkFailedAsync(order, "未配置标签模板(产品未绑且无全局默认)", cancellationToken); + } + + var template = await labelTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId.Value); + if (template == null || string.IsNullOrWhiteSpace(template.TemplateJson)) + { + return await MarkFailedAsync(order, $"标签模板不存在或 JSON 为空: {templateId}", cancellationToken); + } + + var printData = LabelPrintDataBuilder.Build(orderOutput, operatorName); + var pdfBytes = await renderer.RenderAsync(template.TemplateJson, printData, cancellationToken); + + var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken); + order.LabelTemplateId = templateId; + order.LabelUrl = relativeUrl; + order.LabelGenerateStatus = LabelGenerateStatusCodes.Ready; + order.LabelGenerateError = null; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order); + + return new LabelPdfGenerateResult + { + Success = true, + LabelUrl = relativeUrl, + LabelTemplateId = templateId, + Status = LabelGenerateStatusCodes.Ready + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Generate label PDF failed for order {OrderId}", order.Id); + return await MarkFailedAsync(order, ex.Message, cancellationToken); + } + } + + public async Task PreviewAsync( + string templateJson, + IReadOnlyDictionary? printData = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(templateJson)) + { + throw new BusinessException("模板 JSON 为空"); + } + + var data = printData ?? LabelPrintDataBuilder.BuildMock(); + return await renderer.RenderAsync(templateJson, data, cancellationToken); + } + + public async Task ResolveTemplateIdAsync( + Order order, + int? templateIdOverride = null, + CancellationToken cancellationToken = default) + { + if (templateIdOverride is > 0) + { + return templateIdOverride; + } + + if (order.LabelTemplateId is > 0) + { + return order.LabelTemplateId; + } + + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product?.LabelTemplateId is > 0) + { + return product.LabelTemplateId; + } + } + + return await GetGlobalDefaultTemplateIdAsync(cancellationToken); + } + + public async Task GetGlobalDefaultTemplateIdAsync(CancellationToken cancellationToken = default) + { + var config = await systemOtherConfigRepository.GetByConfigCodeAsync( + SystemOtherConfigCodes.LabelDefaultTemplate, + cancellationToken); + if (config == null || !config.Enabled) + { + return null; + } + + if (int.TryParse(config.AppKey?.Trim(), out var id) && id > 0) + { + return id; + } + + if (int.TryParse(config.Remark?.Trim(), out id) && id > 0) + { + return id; + } + + return null; + } + + private async Task MarkFailedAsync( + Order order, + string error, + CancellationToken cancellationToken) + { + order.LabelGenerateStatus = LabelGenerateStatusCodes.Failed; + order.LabelGenerateError = error.Length > 500 ? error[..500] : error; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order); + return new LabelPdfGenerateResult + { + Success = false, + Status = LabelGenerateStatusCodes.Failed, + Error = order.LabelGenerateError, + LabelTemplateId = order.LabelTemplateId + }; + } + + private async Task SavePdfAsync(string serviceNo, byte[] pdfBytes, CancellationToken cancellationToken) + { + var webRoot = string.IsNullOrWhiteSpace(webHostEnvironment.WebRootPath) + ? Path.Combine(webHostEnvironment.ContentRootPath, "wwwroot") + : webHostEnvironment.WebRootPath; + var directory = Path.Combine(webRoot, "uploads", "prints", "orders"); + Directory.CreateDirectory(directory); + var safeNo = string.IsNullOrWhiteSpace(serviceNo) ? "order" : serviceNo.Trim(); + foreach (var c in Path.GetInvalidFileNameChars()) + { + safeNo = safeNo.Replace(c, '_'); + } + + var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; + var absolute = Path.Combine(directory, fileName); + await File.WriteAllBytesAsync(absolute, pdfBytes, cancellationToken); + return $"/uploads/prints/orders/{fileName}"; + } +} + +public sealed class LabelPdfGenerateResult +{ + public bool Success { get; init; } + public string? LabelUrl { get; init; } + public int? LabelTemplateId { get; init; } + public string Status { get; init; } = string.Empty; + public string? Error { get; init; } +} diff --git a/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs new file mode 100644 index 0000000..5192d79 --- /dev/null +++ b/src/Oms.Application/Labeling/LabelPrintDataBuilder.cs @@ -0,0 +1,199 @@ +using System.Globalization; +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Labeling; + +/// +/// 将订单出参映射为 hiprint PrintData(camelCase)。 +/// +public static class LabelPrintDataBuilder +{ + public static IReadOnlyDictionary Build( + OrderOutput order, + string? operatorName = null, + DateTime? printTime = null) + { + var receiver = order.Receiver; + var sender = order.Sender; + var weight = PickWeight(order.WeightList); + var data = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["serviceNo"] = order.ServiceNo, + ["clientNo"] = order.ClientNo, + ["trackNo"] = order.TrackNo, + ["requestId"] = order.RequestId, + ["productCode"] = order.ProductCode, + ["salesProductName"] = order.ProductName, + ["channelCode"] = order.ChannelCode, + ["channelName"] = order.ChannelName, + ["warehouseName"] = order.Warehouse, + ["customerCode"] = order.CustomerCode, + ["customerName"] = order.CustomerName, + + ["recipientName"] = receiver?.RecipientsName, + ["recipientCompany"] = receiver?.CompanyName, + ["recipientPhone"] = FirstNonEmpty(receiver?.Phone, receiver?.TelePhone), + ["recipientEmail"] = receiver?.Email, + ["recipientZip"] = receiver?.ZipCode, + ["recipientCountry"] = order.ConsigneeCountry, + ["recipientCountryName"] = order.ConsigneeCountryName, + ["recipientProvince"] = receiver?.Province, + ["recipientCity"] = receiver?.City, + ["recipientStreet1"] = receiver?.StreetAddress1, + ["recipientStreet2"] = receiver?.StreetAddress2, + ["recipientStreet3"] = receiver?.StreetAddress3, + ["recipientAddressFull"] = BuildAddressFull( + receiver?.StreetAddress1, + receiver?.StreetAddress2, + receiver?.StreetAddress3, + receiver?.StreetNumber, + receiver?.City, + receiver?.Province, + receiver?.ZipCode, + order.ConsigneeCountryName ?? order.ConsigneeCountry), + ["recipientTaxNo"] = receiver?.TaxNo, + ["vatNo"] = receiver?.Vat, + ["eori"] = receiver?.Eori, + + ["senderName"] = sender?.SenderName, + ["senderCompany"] = sender?.Company, + ["senderPhone"] = FirstNonEmpty(sender?.Phone, sender?.TelePhone), + ["senderEmail"] = sender?.Email, + ["senderZip"] = sender?.ZipCode, + ["senderProvince"] = sender?.Province, + ["senderCity"] = sender?.City, + ["senderStreet1"] = sender?.StreetAddress1, + ["senderStreet2"] = sender?.StreetAddress2, + ["senderStreet3"] = sender?.StreetAddress3, + ["senderAddressFull"] = BuildAddressFull( + sender?.StreetAddress1, + sender?.StreetAddress2, + sender?.StreetAddress3, + sender?.HouseNumber, + sender?.City, + sender?.Province, + sender?.ZipCode, + null), + + ["actualWeight"] = weight?.ActualWeight, + ["volumetricWeight"] = weight?.VolumetricWeight, + ["chargeableWeight"] = order.ChargeableWeight ?? weight?.ChargeableWeight, + ["length"] = weight?.Length, + ["width"] = weight?.Width, + ["height"] = weight?.Height, + ["weightUnit"] = "KG", + ["dimUnit"] = "CM", + + ["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = operatorName + }; + + data["items"] = order.Items.Select(x => new Dictionary + { + ["skuCode"] = x.Sku, + ["skuName"] = FirstNonEmpty(x.CnName, x.EnName), + ["qty"] = x.Quantity, + ["weight"] = x.Weight ?? x.UnitWeight, + ["declareNameEn"] = x.EnName, + ["declareValue"] = x.UnitPrice ?? x.Amount + }).ToList(); + + return data; + } + + /// + /// 设计预览用的 Mock PrintData。 + /// + public static IReadOnlyDictionary BuildMock() => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["serviceNo"] = "S202607180001", + ["clientNo"] = "C-10086", + ["trackNo"] = "YT1234567890", + ["requestId"] = "REQ-DEMO", + ["productCode"] = "DEMO-PRODUCT", + ["salesProductName"] = "专线-普货", + ["channelCode"] = "CH-01", + ["channelName"] = "Demo Channel", + ["warehouseName"] = "SZ-01", + ["customerCode"] = "AF0A1", + ["customerName"] = "Demo Customer", + ["recipientName"] = "John Lee", + ["recipientCompany"] = "ACME Inc", + ["recipientPhone"] = "+1-555-0100", + ["recipientEmail"] = "john@example.com", + ["recipientZip"] = "90001", + ["recipientCountry"] = "US", + ["recipientCountryName"] = "United States", + ["recipientProvince"] = "CA", + ["recipientCity"] = "Los Angeles", + ["recipientStreet1"] = "100 Demo Street", + ["recipientStreet2"] = "Suite 8", + ["recipientStreet3"] = null, + ["recipientAddressFull"] = "100 Demo Street, Suite 8, Los Angeles, CA 90001, United States", + ["recipientTaxNo"] = null, + ["vatNo"] = null, + ["eori"] = null, + ["senderName"] = "OMS Warehouse", + ["senderCompany"] = "OMS Co", + ["senderPhone"] = "+86-755-0000000", + ["senderEmail"] = "ops@example.com", + ["senderZip"] = "518000", + ["senderProvince"] = "Guangdong", + ["senderCity"] = "Shenzhen", + ["senderStreet1"] = "Warehouse Rd 1", + ["senderStreet2"] = null, + ["senderStreet3"] = null, + ["senderAddressFull"] = "Warehouse Rd 1, Shenzhen, Guangdong 518000", + ["actualWeight"] = 1.25m, + ["volumetricWeight"] = 1.10m, + ["chargeableWeight"] = 1.25m, + ["length"] = 30m, + ["width"] = 20m, + ["height"] = 10m, + ["weightUnit"] = "KG", + ["dimUnit"] = "CM", + ["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = "preview", + ["items"] = new List> + { + new() + { + ["skuCode"] = "SKU001", + ["skuName"] = "T-Shirt", + ["qty"] = 2, + ["weight"] = 0.2m, + ["declareNameEn"] = "T-Shirt", + ["declareValue"] = 9.9m + } + } + }; + + private static OrderWeightOutput? PickWeight(IReadOnlyList list) + { + if (list.Count == 0) + { + return null; + } + + return list.FirstOrDefault(x => + string.Equals(x.Type, "receive", StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Type, "收货", StringComparison.OrdinalIgnoreCase)) + ?? list.FirstOrDefault(x => + string.Equals(x.Type, "forecast", StringComparison.OrdinalIgnoreCase) + || string.Equals(x.Type, "预报", StringComparison.OrdinalIgnoreCase)) + ?? list[0]; + } + + private static string? BuildAddressFull(params string?[] parts) + { + var values = parts + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!.Trim()) + .ToArray(); + return values.Length == 0 ? null : string.Join(", ", values); + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); +} diff --git a/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs b/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs index 1a278a1..e61399d 100644 --- a/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs +++ b/src/Oms.Application/Messaging/OrderBatchQueueTopology.cs @@ -41,6 +41,7 @@ public static class OrderBatchQueueTopology Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkProblematic => RoutingExceptionPrefix + "mark-problematic", Domain.OrdersAggregate.Consts.OrderBatchActionCodes.MarkClaimed => RoutingExceptionPrefix + "mark-claimed", Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Print => RoutingDocumentPrefix + "print", + Domain.OrdersAggregate.Consts.OrderBatchActionCodes.GenerateLabelPdf => RoutingDocumentPrefix + "generate-label-pdf", Domain.OrdersAggregate.Consts.OrderBatchActionCodes.Export => RoutingDocumentPrefix + "export", _ => "unknown." + action }; diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 51fa64c..015e68f 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -26,7 +26,10 @@ + + + diff --git a/src/Oms.Application/Services/Channels/LabelTemplateService.cs b/src/Oms.Application/Services/Channels/LabelTemplateService.cs index 43995b5..b1ce432 100644 --- a/src/Oms.Application/Services/Channels/LabelTemplateService.cs +++ b/src/Oms.Application/Services/Channels/LabelTemplateService.cs @@ -1,6 +1,8 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Shared; +using Oms.Application.Labeling; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -14,12 +16,105 @@ namespace Oms.Application.Services.Channels; public class LabelTemplateService : ApplicationService { private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService(); + private LabelPdfGenerationService LabelPdfGenerationService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 标签模板列表(不含 JSON,供浏览/下拉)。 + /// + [HttpGet("/channel/label-template/list")] + public async Task> GetListAsync( + [FromQuery] string? keyword = null, + [FromQuery] string? templateType = null) + { + var keywordValue = keyword?.Trim(); + var typeValue = templateType?.Trim(); + var hasKeyword = !string.IsNullOrEmpty(keywordValue); + var hasType = !string.IsNullOrEmpty(typeValue); + + var entities = await LabelTemplateRepository.GetListAsync(x => + (!hasKeyword || x.Name.Contains(keywordValue!)) && + (!hasType || x.TemplateType == typeValue)); + + var items = entities + .OrderByDescending(x => x.UpdateTime) + .ThenByDescending(x => x.Id) + .Select(x => new LabelTemplateListItemOutput + { + Id = x.Id, + Name = x.Name, + TemplateType = x.TemplateType, + CreateTime = x.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(x.UpdateTime) + }) + .ToList(); + + return new OmsItemsResult { Items = items }; + } + + /// + /// 标签模板详情(含 TemplateJson,编辑用)。 + /// + [HttpGet("/channel/label-template/{id:int}")] + public async Task GetAsync(int id) + { + var entity = await GetEntityOrThrowAsync(id); + return MapOutput(entity); + } + + /// + /// 设计器字段字典(可拖字段分组)。 + /// + [HttpGet("/channel/label-template/field-dictionary")] + public Task> GetFieldDictionaryAsync() + { + return Task.FromResult(new OmsItemsResult + { + Items = LabelFieldDictionary.GetGroups().ToList() + }); + } + + /// + /// 预览 PDF(同一套 ILabelPdfRenderer)。 + /// + [HttpPost("/channel/label-template/preview-pdf")] + public async Task PreviewPdfAsync([FromBody] LabelTemplatePreviewInput input) + { + string? templateJson = null; + if (input.TemplateId is > 0) + { + var entity = await GetEntityOrThrowAsync(input.TemplateId.Value); + templateJson = entity.TemplateJson; + } + else + { + templateJson = OmsDocHelper.SerializeJson(input.TemplateJson); + } + + if (string.IsNullOrWhiteSpace(templateJson)) + { + throw new BusinessException("模板 JSON 为空"); + } + + IReadOnlyDictionary? printData = null; + if (input.PrintData is { Count: > 0 }) + { + printData = input.PrintData.ToDictionary( + x => x.Key, + x => x.Value, + StringComparer.OrdinalIgnoreCase); + } + + var bytes = await LabelPdfGenerationService.PreviewAsync(templateJson, printData); + return new FileContentResult(bytes, "application/pdf") + { + FileDownloadName = $"label-preview-{DateTime.Now:yyyyMMddHHmmss}.pdf" + }; + } /// /// 保存标签模板。 /// - /// 入参:标签模板保存参数。 - /// 标签模板保存结果。 [HttpPost("/channel/label-template/save")] public async Task SaveAsync(LabelTemplateSaveInput input) { @@ -48,6 +143,16 @@ public class LabelTemplateService : ApplicationService return MapOutput(entity); } + /// + /// 删除标签模板。 + /// + [HttpPost("/channel/label-template/delete")] + public async Task DeleteAsync(IdInput input) + { + var entity = await GetEntityOrThrowAsync(input.Id); + await LabelTemplateRepository.DeleteAsync(entity); + } + private async Task ValidateUniqueNameAsync(string name, string? templateType, int? id) { var existed = await LabelTemplateRepository.GetByNameAndTypeAsync(name, templateType); diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index 9d1a61e..578c4e2 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -207,6 +207,7 @@ public partial class SalesProductService MinTimeliness = product.MinTimeliness, MaxTimeliness = product.MaxTimeliness, CustomerWaybill = product.CustomerWaybill, + LabelTemplateId = product.LabelTemplateId, BlacklistAreaId = product.BlacklistAreaId, RemoteAreaId = product.RemoteAreaId, PriceAreaId = product.PriceAreaId, @@ -311,6 +312,7 @@ public partial class SalesProductService entity.MinTimeliness = input.MinTimeliness; entity.MaxTimeliness = input.MaxTimeliness; entity.CustomerWaybill = input.CustomerWaybill; + entity.LabelTemplateId = input.LabelTemplateId; entity.BlacklistAreaId = input.BlacklistAreaId; entity.RemoteAreaId = input.RemoteAreaId; entity.PriceAreaId = input.PriceAreaId; @@ -334,6 +336,7 @@ public partial class SalesProductService MinTimeliness = entity.MinTimeliness, MaxTimeliness = entity.MaxTimeliness, CustomerWaybill = entity.CustomerWaybill, + LabelTemplateId = entity.LabelTemplateId, BlacklistAreaId = entity.BlacklistAreaId, RemoteAreaId = entity.RemoteAreaId, PriceAreaId = entity.PriceAreaId, @@ -360,6 +363,7 @@ public partial class SalesProductService MinTimeliness = entity.MinTimeliness, MaxTimeliness = entity.MaxTimeliness, CustomerWaybill = entity.CustomerWaybill, + LabelTemplateId = entity.LabelTemplateId, BlacklistAreaId = entity.BlacklistAreaId, RemoteAreaId = entity.RemoteAreaId, PriceAreaId = entity.PriceAreaId, diff --git a/src/Oms.Application/Services/Operations/LabelPrintService.cs b/src/Oms.Application/Services/Operations/LabelPrintService.cs index 14ca1b8..d711a56 100644 --- a/src/Oms.Application/Services/Operations/LabelPrintService.cs +++ b/src/Oms.Application/Services/Operations/LabelPrintService.cs @@ -1,7 +1,9 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Operations; +using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; +using Oms.Application.Services.Orders; using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -31,6 +33,7 @@ public class LabelPrintService : ApplicationService private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); + private OrderService OrderService => LazyServiceProvider.GetRequiredService(); /// /// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/SKU → 订单 + 绑定明细。 @@ -107,6 +110,32 @@ public class LabelPrintService : ApplicationService labelTemplate = list.OrderByDescending(x => x.UpdateTime).ThenByDescending(x => x.Id).FirstOrDefault(); } + var raw = input.Number?.Trim() ?? string.Empty; + var type = NormalizeNumberType(input.NumberType); + Order? order; + if (type is "auto" or "") + { + (order, _, _) = await ResolveAutoAsync(raw); + } + else + { + (order, _, _) = await ResolveByTypeAsync(raw, type); + } + + if (order == null) + { + throw new BusinessException("未找到对应订单,无法打印面单"); + } + + if (labelTemplate != null) + { + order.LabelTemplateId = labelTemplate.Id; + order.LabelGenerateStatus = LabelGenerateStatusCodes.Pending; + await OrderRepository.UpdateAsync(order); + } + + var print = await OrderService.PrintAsync(new OrderActionInput { Id = order.Id }); + var now = DateTime.Now; var operatorId = CurrentUser.GetId(); var waybillNo = string.Empty; @@ -127,17 +156,17 @@ public class LabelPrintService : ApplicationService otherNumber = input.Number; break; default: - waybillNo = input.Number; + waybillNo = string.IsNullOrWhiteSpace(input.Number) ? order.ServiceNo : input.Number; break; } var entity = new LabelPrintRecord { - WaybillNo = waybillNo, - CustomerOrderNo = customerOrderNo, - TrackingNo = trackingNo, + WaybillNo = string.IsNullOrWhiteSpace(waybillNo) ? order.ServiceNo : waybillNo, + CustomerOrderNo = customerOrderNo ?? order.ClientNo, + TrackingNo = trackingNo ?? order.TrackNo, OtherNumber = otherNumber, - LabelTemplateId = labelTemplate?.Id ?? input.LabelTemplateId, + LabelTemplateId = labelTemplate?.Id ?? order.LabelTemplateId ?? input.LabelTemplateId, PrintSuccess = EnableStatus.Enabled, PrintTime = now, OperatorId = operatorId @@ -147,6 +176,7 @@ public class LabelPrintService : ApplicationService var output = ObjectMapper.Map(savedEntity); output.LabelTemplateName = labelTemplate?.Name; output.OperatorName = CurrentUser.UserName; + output.PrintUrl = print.PrintUrl; return output; } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs index 6ab74ae..b203e7c 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchActionExecutor.cs @@ -39,6 +39,7 @@ public sealed class OrderBatchActionExecutor( OrderBatchActionCodes.CancelIntercept => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Delete => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.Print => await orderActionHandler.ExecuteAsync(message, cancellationToken), + OrderBatchActionCodes.GenerateLabelPdf => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkProblematic => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.MarkClaimed => await orderActionHandler.ExecuteAsync(message, cancellationToken), OrderBatchActionCodes.HandleStatusUpdate => await orderActionHandler.ExecuteAsync(message, cancellationToken), diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index 008b906..4e48e98 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -51,6 +51,18 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto resultJson: JsonConvert.SerializeObject(new { printUrl = print.PrintUrl })); } + case OrderBatchActionCodes.GenerateLabelPdf: + { + var result = await orderService.GenerateLabelPdfForBatchAsync(message.OrderId, cancellationToken); + if (!result.Success) + { + return OrderBatchActionExecuteResult.BusinessFail(result.Error ?? "面单生成失败"); + } + + return OrderBatchActionExecuteResult.Ok( + resultJson: JsonConvert.SerializeObject(new { labelUrl = result.LabelUrl })); + } + case OrderBatchActionCodes.MarkProblematic: await orderService.MarkProblematicForBatchAsync( message.OrderId, diff --git a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs index b8942c1..b681d90 100644 --- a/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs +++ b/src/Oms.Application/Services/Orders/OrderService.BatchWorker.cs @@ -1,6 +1,7 @@ using Fake.Application; using Newtonsoft.Json; using Oms.Application.Dtos.Orders; +using Oms.Application.Labeling; using Oms.Application.Services.Orders.Issues; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; @@ -27,6 +28,20 @@ public partial class OrderService public Task PrintForBatchAsync(int orderId, CancellationToken cancellationToken = default) => PrintAsync(new OrderActionInput { Id = orderId }); + public async Task GenerateLabelPdfForBatchAsync( + int orderId, + CancellationToken cancellationToken = default) + { + var order = await GetOrderOrThrowAsync(orderId); + var mapped = (await MapOrderOutputsAsync([order])).First(); + return await LabelPdfGenerationService.GenerateAndPersistAsync( + order, + mapped, + templateIdOverride: null, + operatorName: null, + cancellationToken); + } + public async Task MarkProblematicForBatchAsync( int orderId, string? payloadJson, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index b463def..bb94ad3 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Orders; using Oms.Application.Integrations.SmallBagTrack; +using Oms.Application.Labeling; +using Oms.Application.Messaging; using Oms.Application.Shared; using Oms.Application.Services.Orders.Issues; using Oms.Domain.ChannelsAggregate.Consts; @@ -166,6 +168,7 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); await EnqueueSmallBagTrackUpsertAsync(order, customer, input.Receiver); + await EnqueueGenerateLabelPdfAsync(order); try { @@ -1128,6 +1131,8 @@ public partial class OrderService PredeclareFailReason = string.Empty, SalesProductRulesVersion = snapshotBinding.RulesVersion, SalesProductPriceVersion = snapshotBinding.PriceVersion, + LabelTemplateId = product.LabelTemplateId, + LabelGenerateStatus = LabelGenerateStatusCodes.Pending, OrderStatus = OrderStatusCodes.Draft }; } @@ -1482,15 +1487,45 @@ public partial class OrderService TrackNo = order.TrackNo, ConsigneeCountry = order.ConsigneeCountry, ConsigneeCountryName = order.ConsigneeCountryName, - LabelUrl = order.OrderStatus == OrderStatusCodes.Predeclared - ? await CreateOrderPrintFileAsync(mapped) - : string.Empty + LabelUrl = !string.IsNullOrWhiteSpace(order.LabelUrl) + ? order.LabelUrl! + : order.OrderStatus == OrderStatusCodes.Predeclared + ? await CreateOrderPrintFileAsync(mapped) + : string.Empty }; } private ISmallBagTrackOrderNotifier SmallBagTrackOrderNotifier => LazyServiceProvider.GetRequiredService(); + private async Task EnqueueGenerateLabelPdfAsync(Order order) + { + try + { + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + // 本地未开队列:同步生成,避免 Pending 悬挂 + var mapped = (await MapOrderOutputsAsync([order])).First(); + await LabelPdfGenerationService.GenerateAndPersistAsync(order, mapped); + return; + } + + await BatchJobCoordinator.CreateAndEnqueueAsync( + new OrderBatchJobCreateInput + { + Action = OrderBatchActionCodes.GenerateLabelPdf, + OrderIds = [order.Id], + RequestId = $"label-pdf:{order.Id}:{Guid.NewGuid():N}" + }, + CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + } + catch (Exception) + { + // 投递失败不阻断创单;打印时会补生成 + } + } + /// /// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index f13ac6a..8afe2a8 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -6,6 +6,7 @@ using MiniExcelLibs; using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.Shared; using Oms.Application.DataPerm; +using Oms.Application.Labeling; using Oms.Application.Shared; using Newtonsoft.Json; using Oms.Application.Services.Orders.Batch; @@ -33,6 +34,8 @@ public partial class OrderService : ApplicationService private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService(); private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService(); + private LabelPdfGenerationService LabelPdfGenerationService => + LazyServiceProvider.GetRequiredService(); private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => LazyServiceProvider.GetRequiredService(); @@ -223,8 +226,44 @@ public partial class OrderService : ApplicationService public async Task PrintAsync(OrderActionInput input) { var order = await GetOrderOrThrowAsync(input.Id); - var printUrl = await CreateOrderPrintFileAsync((await MapOrderOutputsAsync([order])).First()); - return new OrderPrintOutput { PrintUrl = printUrl }; + var mapped = (await MapOrderOutputsAsync([order])).First(); + + if (string.Equals(order.LabelGenerateStatus, LabelGenerateStatusCodes.Ready, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(order.LabelUrl)) + { + return new OrderPrintOutput + { + PrintUrl = order.LabelUrl!, + LabelGenerateStatus = order.LabelGenerateStatus, + LabelGenerateError = order.LabelGenerateError + }; + } + + // Pending / Failed / 无状态:即时补生成(打印场景优先出 PDF) + var result = await LabelPdfGenerationService.GenerateAndPersistAsync( + order, + mapped, + templateIdOverride: null, + operatorName: CurrentUser.UserName); + + if (!result.Success || string.IsNullOrWhiteSpace(result.LabelUrl)) + { + // 兼容回落:仍无模板时给 HTML stub,避免彻底不可打 + var fallbackUrl = await CreateOrderPrintFileAsync(mapped); + return new OrderPrintOutput + { + PrintUrl = fallbackUrl, + LabelGenerateStatus = result.Status, + LabelGenerateError = result.Error ?? "面单 PDF 未就绪,已回落 HTML" + }; + } + + return new OrderPrintOutput + { + PrintUrl = result.LabelUrl!, + LabelGenerateStatus = result.Status, + LabelGenerateError = result.Error + }; } /// diff --git a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs index 62d034b..d60c922 100644 --- a/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs +++ b/src/Oms.Domain/ChannelsAggregate/Entities/SalesProduct.cs @@ -19,6 +19,11 @@ public class SalesProduct : OmsAuditedAggregateRoot public string? CustomerWaybill { get; set; } + /// + /// 客户面单绑定的标签模板 Id。 + /// + public int? LabelTemplateId { get; set; } + public int? BlacklistAreaId { get; set; } public int? RemoteAreaId { get; set; } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs new file mode 100644 index 0000000..067a23e --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/LabelGenerateStatusCodes.cs @@ -0,0 +1,11 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +/// +/// 订单面单 PDF 生成状态。 +/// +public static class LabelGenerateStatusCodes +{ + public const string Pending = "Pending"; + public const string Ready = "Ready"; + public const string Failed = "Failed"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs index a403872..ec1ac4c 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderBatchJobStatusCodes.cs @@ -31,6 +31,7 @@ public static class OrderBatchActionCodes public const string MarkClaimed = "mark-claimed"; public const string HandleStatusUpdate = "handle-status-update"; public const string Print = "print"; + public const string GenerateLabelPdf = "generate-label-pdf"; public const string Export = "export"; public const string ParcelCreate = "parcel-create"; public const string ImportRow = "import-row"; diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs index 80e6fff..196abb8 100644 --- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs +++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs @@ -140,4 +140,27 @@ public class Order : OmsAuditedAggregateRoot public byte IsIntercept { get; set; } public byte IsRemote { get; set; } + + /// + /// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。 + /// + [SugarColumn(Length = 500, IsNullable = true)] + public string? LabelUrl { get; set; } + + /// + /// 出单时实际使用的标签模板 Id。 + /// + public int? LabelTemplateId { get; set; } + + /// + /// 面单生成状态:Pending / Ready / Failed。 + /// + [SugarColumn(Length = 32, IsNullable = true)] + public string? LabelGenerateStatus { get; set; } + + /// + /// 面单生成失败原因。 + /// + [SugarColumn(Length = 500, IsNullable = true)] + public string? LabelGenerateError { get; set; } } diff --git a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs index 35ae9b6..0d76177 100644 --- a/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs +++ b/src/Oms.Domain/SystemAggregate/Consts/SystemOtherConfigCodes.cs @@ -39,4 +39,9 @@ public static class SystemOtherConfigCodes /// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。 /// public const string PlatformShopTemu = "platform_shop_temu"; + + /// + /// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。 + /// + public const string LabelDefaultTemplate = "label_default_template"; } diff --git a/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json b/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json new file mode 100644 index 0000000..45a29f6 --- /dev/null +++ b/src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json @@ -0,0 +1,138 @@ +{ + "panels": [ + { + "index": 0, + "name": "panel_100x150", + "width": 100, + "height": 150, + "paperHeader": 0, + "paperFooter": 420, + "printElements": [ + { + "options": { + "left": 14.17, + "top": 14.17, + "height": 18, + "width": 255, + "title": "OMS Label Sample", + "fontSize": 14, + "fontWeight": "bold", + "textAlign": "left", + "hideTitle": true + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 14.17, + "top": 42.52, + "height": 16, + "width": 255, + "field": "serviceNo", + "title": "运单号", + "fontSize": 11, + "hideTitle": false + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 14.17, + "top": 70.87, + "height": 40, + "width": 255, + "field": "serviceNo", + "textType": "barcode", + "hideTitle": true + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 14.17, + "top": 127.56, + "height": 50, + "width": 50, + "field": "serviceNo", + "textType": "qrcode", + "hideTitle": true + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 70, + "top": 127.56, + "height": 48, + "width": 200, + "field": "recipientAddressFull", + "title": "地址", + "fontSize": 9, + "hideTitle": true + }, + "printElementType": { "title": "长文", "type": "longText" } + }, + { + "options": { + "left": 14.17, + "top": 190, + "height": 1.5, + "width": 255, + "borderWidth": 1, + "color": "#000000" + }, + "printElementType": { "title": "横线", "type": "hline" } + }, + { + "options": { + "left": 14.17, + "top": 205, + "height": 16, + "width": 120, + "field": "recipientName", + "title": "收件人", + "fontSize": 10, + "hideTitle": false + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 14.17, + "top": 230, + "height": 16, + "width": 80, + "field": "actualWeight", + "title": "实重", + "fontSize": 10, + "hideTitle": false + }, + "printElementType": { "title": "文本", "type": "text" } + }, + { + "options": { + "left": 14.17, + "top": 260, + "height": 60, + "width": 255, + "borderWidth": 1, + "color": "#333333" + }, + "printElementType": { "title": "矩形", "type": "rect" } + }, + { + "options": { + "left": 20, + "top": 270, + "height": 40, + "width": 240, + "title": "Static note inside rect", + "fontSize": 9, + "hideTitle": true + }, + "printElementType": { "title": "文本", "type": "text" } + } + ] + } + ] +} diff --git a/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs new file mode 100644 index 0000000..bcff18c --- /dev/null +++ b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs @@ -0,0 +1,64 @@ +using System.Reflection; +using System.Text; +using FluentAssertions; +using Oms.Application.Labeling.Hiprint; + +namespace Oms.Tests.Labeling; + +public class HiprintTemplateParserTests +{ + [Fact] + public void Parse_SampleTemplate_ShouldMapUnitsAndElements() + { + var json = LoadFixture("sample_100x150.json"); + var printData = new Dictionary + { + ["serviceNo"] = "S202607180001", + ["recipientName"] = "John Lee", + ["recipientAddressFull"] = "xxx Street, City, CA 90001", + ["actualWeight"] = 1.25 + }; + + var parser = new HiprintTemplateParser(); + var page = parser.Parse(json, printData); + + page.WidthMm.Should().Be(100f); + page.HeightMm.Should().Be(150f); + page.Ops.Should().NotBeEmpty(); + + page.Ops.OfType().Should().Contain(x => + x.Text.Contains("S202607180001", StringComparison.Ordinal) && + x.LeftPt > 0 && + x.FontSizePt == 11f); + + page.Ops.OfType().Should().Contain(x => !x.QrCode && x.Content == "S202607180001"); + page.Ops.OfType().Should().Contain(x => x.QrCode && x.Content == "S202607180001"); + page.Ops.OfType().Should().Contain(x => !x.Vertical); + page.Ops.OfType().Should().ContainSingle(); + + // 纸张 mm → pt:100mm ≈ 283.46pt + var pageWidthPt = page.WidthMm * 72f / 25.4f; + pageWidthPt.Should().BeApproximately(283.46f, 0.1f); + } + + [Fact] + public void Parse_MissingPanels_ShouldThrow() + { + var parser = new HiprintTemplateParser(); + var act = () => parser.Parse("""{"panels":[]}""", new Dictionary()); + act.Should().Throw(); + } + + internal static string LoadFixture(string fileName) + { + var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var path = Path.Combine(dir, "Fixtures", "LabelTemplates", fileName); + if (!File.Exists(path)) + { + // 开发态:从源码目录相对查找 + path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "LabelTemplates", fileName)); + } + + return File.ReadAllText(path, Encoding.UTF8); + } +} diff --git a/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs new file mode 100644 index 0000000..e201f8d --- /dev/null +++ b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs @@ -0,0 +1,29 @@ +using System.Text; +using FluentAssertions; +using Oms.Application.Labeling.Hiprint; + +namespace Oms.Tests.Labeling; + +public class QuestPdfLabelRendererTests +{ + [Fact] + public async Task RenderAsync_SampleTemplate_ShouldProducePdfBytes() + { + var json = HiprintTemplateParserTests.LoadFixture("sample_100x150.json"); + var printData = new Dictionary + { + ["serviceNo"] = "S202607180001", + ["recipientName"] = "John Lee", + ["recipientAddressFull"] = "xxx Street, City, CA 90001", + ["actualWeight"] = 1.25 + }; + + var renderer = new QuestPdfLabelRenderer(new HiprintTemplateParser()); + var bytes = await renderer.RenderAsync(json, printData); + + bytes.Should().NotBeNullOrEmpty(); + // %PDF + Encoding.ASCII.GetString(bytes.AsSpan(0, 4)).Should().Be("%PDF"); + bytes.Length.Should().BeGreaterThan(500); + } +} diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj index 07d7250..19f3343 100644 --- a/src/Oms.Tests/Oms.Tests.csproj +++ b/src/Oms.Tests/Oms.Tests.csproj @@ -29,4 +29,8 @@ + + + +