feat: Hiprint模板转QuestPDF异步出面单,并接通打印链路
支持模板预览/字段字典、产品绑定 LabelTemplateId、订单 Pending→Ready 生成 PDF,以及 order/print、批量与仓内 printUrl。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9957b79b19
commit
b679151eb1
87
sql/migrate_label_pdf_fields.sql
Normal file
87
sql/migrate_label_pdf_fields.sql
Normal file
@ -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'
|
||||
);
|
||||
@ -0,0 +1,17 @@
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 标签模板列表项(不含 TemplateJson,列表更轻)。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
namespace Oms.Application.Dtos.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// 标签模板预览入参。
|
||||
/// </summary>
|
||||
public class LabelTemplatePreviewInput
|
||||
{
|
||||
/// <summary>已保存模板 Id(与 TemplateJson 二选一,优先 Id)。</summary>
|
||||
public int? TemplateId { get; set; }
|
||||
|
||||
/// <summary>未保存时可直接传设计器 JSON。</summary>
|
||||
public object? TemplateJson { get; set; }
|
||||
|
||||
/// <summary>可选 PrintData;空则用 Mock。</summary>
|
||||
public Dictionary<string, object?>? PrintData { get; set; }
|
||||
}
|
||||
@ -40,6 +40,11 @@ public class SalesProductDetailOutput
|
||||
/// </summary>
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户面单绑定的标签模板 Id。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区主键。
|
||||
/// </summary>
|
||||
|
||||
@ -40,6 +40,11 @@ public class SalesProductListOutput
|
||||
/// </summary>
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户面单绑定的标签模板 Id。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区主键。
|
||||
/// </summary>
|
||||
|
||||
@ -38,10 +38,15 @@ public class SalesProductSaveInput
|
||||
public int? MaxTimeliness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户面单类型。
|
||||
/// 客户面单类型(旧枚举,兼容展示)。
|
||||
/// </summary>
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户面单绑定的标签模板 Id。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 黑名单分区主键。
|
||||
/// </summary>
|
||||
|
||||
@ -62,6 +62,11 @@ public class LabelPrintRecordOutput
|
||||
/// </summary>
|
||||
public string? OperatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单 PDF 地址。
|
||||
/// </summary>
|
||||
public string? PrintUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -6,7 +6,17 @@ namespace Oms.Application.Dtos.Orders;
|
||||
public class OrderPrintOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 打印地址。
|
||||
/// 打印地址(PDF)。
|
||||
/// </summary>
|
||||
public string PrintUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 面单生成状态:Pending / Ready / Failed。
|
||||
/// </summary>
|
||||
public string? LabelGenerateStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单生成失败原因。
|
||||
/// </summary>
|
||||
public string? LabelGenerateError { get; set; }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 条码 / 二维码 PNG 生成。
|
||||
/// </summary>
|
||||
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<Rgba32>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
95
src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
Normal file
95
src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
Normal file
@ -0,0 +1,95 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Oms.Application.Labeling.Hiprint;
|
||||
|
||||
/// <summary>
|
||||
/// vg-print / hiprint 导出的模板根节点(只映射渲染所需字段)。
|
||||
/// </summary>
|
||||
public sealed class HiprintTemplateDocument
|
||||
{
|
||||
[JsonPropertyName("panels")]
|
||||
public List<HiprintPanel> Panels { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class HiprintPanel
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>纸宽,单位 mm。</summary>
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; } = 100f;
|
||||
|
||||
/// <summary>纸高,单位 mm。</summary>
|
||||
[JsonPropertyName("height")]
|
||||
public float Height { get; set; } = 150f;
|
||||
|
||||
[JsonPropertyName("printElements")]
|
||||
public List<HiprintPrintElement> 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; }
|
||||
}
|
||||
201
src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
Normal file
201
src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
Normal file
@ -0,0 +1,201 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Fake.Application;
|
||||
using Fake.DependencyInjection;
|
||||
|
||||
namespace Oms.Application.Labeling.Hiprint;
|
||||
|
||||
/// <summary>
|
||||
/// 将 hiprint TemplateJson 转为中间绘制 IR。
|
||||
/// </summary>
|
||||
public sealed class HiprintTemplateParser : ITransientDependency
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public LabelPageIr Parse(string templateJson, IReadOnlyDictionary<string, object?> printData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateJson))
|
||||
{
|
||||
throw new ArgumentException("模板 JSON 为空", nameof(templateJson));
|
||||
}
|
||||
|
||||
var doc = JsonSerializer.Deserialize<HiprintTemplateDocument>(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<LabelDrawOp>();
|
||||
|
||||
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<string, object?> 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<string, object?> 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<string, object?> printData)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(field) && printData.TryGetValue(field, out var raw) && raw != null)
|
||||
{
|
||||
return Convert.ToString(raw, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
56
src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
Normal file
56
src/Oms.Application/Labeling/Hiprint/LabelDrawOps.cs
Normal file
@ -0,0 +1,56 @@
|
||||
namespace Oms.Application.Labeling.Hiprint;
|
||||
|
||||
/// <summary>
|
||||
/// 中间绘制指令(与具体 PDF 引擎解耦)。
|
||||
/// 坐标单位:pt;页面尺寸:mm(再换算)。
|
||||
/// </summary>
|
||||
public sealed class LabelPageIr
|
||||
{
|
||||
public float WidthMm { get; init; } = 100f;
|
||||
|
||||
public float HeightMm { get; init; } = 150f;
|
||||
|
||||
public List<LabelDrawOp> 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; }
|
||||
}
|
||||
170
src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
Normal file
170
src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
Normal file
@ -0,0 +1,170 @@
|
||||
using Fake.DependencyInjection;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace Oms.Application.Labeling.Hiprint;
|
||||
|
||||
/// <summary>
|
||||
/// QuestPDF 实现的面单渲染器。
|
||||
/// </summary>
|
||||
public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabelPdfRenderer, ITransientDependency
|
||||
{
|
||||
private static int _licenseConfigured;
|
||||
|
||||
public Task<byte[]> RenderAsync(
|
||||
string templateJson,
|
||||
IReadOnlyDictionary<string, object?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Oms.Application/Labeling/ILabelPdfRenderer.cs
Normal file
12
src/Oms.Application/Labeling/ILabelPdfRenderer.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace Oms.Application.Labeling;
|
||||
|
||||
/// <summary>
|
||||
/// 面单 PDF 渲染器:TemplateJson + PrintData → PDF bytes。
|
||||
/// </summary>
|
||||
public interface ILabelPdfRenderer
|
||||
{
|
||||
Task<byte[]> RenderAsync(
|
||||
string templateJson,
|
||||
IReadOnlyDictionary<string, object?> printData,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
120
src/Oms.Application/Labeling/LabelFieldDictionary.cs
Normal file
120
src/Oms.Application/Labeling/LabelFieldDictionary.cs
Normal file
@ -0,0 +1,120 @@
|
||||
namespace Oms.Application.Labeling;
|
||||
|
||||
/// <summary>
|
||||
/// 设计器字段字典(与 PrintData camelCase 对齐)。
|
||||
/// </summary>
|
||||
public static class LabelFieldDictionary
|
||||
{
|
||||
public static IReadOnlyList<LabelFieldGroup> 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<LabelFieldItem> Fields { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class LabelFieldItem
|
||||
{
|
||||
public string Field { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
}
|
||||
183
src/Oms.Application/Labeling/LabelPdfGenerationService.cs
Normal file
183
src/Oms.Application/Labeling/LabelPdfGenerationService.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 面单 PDF 生成:解析模板 → 渲染 → 落盘 → 回写订单。
|
||||
/// </summary>
|
||||
public sealed class LabelPdfGenerationService(
|
||||
ILabelPdfRenderer renderer,
|
||||
ILabelTemplateRepository labelTemplateRepository,
|
||||
ISalesProductRepository salesProductRepository,
|
||||
ISystemOtherConfigRepository systemOtherConfigRepository,
|
||||
IOrderRepository orderRepository,
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
ILogger<LabelPdfGenerationService> logger) : ITransientDependency
|
||||
{
|
||||
public async Task<LabelPdfGenerateResult> 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<byte[]> PreviewAsync(
|
||||
string templateJson,
|
||||
IReadOnlyDictionary<string, object?>? 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<int?> 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<int?> 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<LabelPdfGenerateResult> 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<string> 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; }
|
||||
}
|
||||
199
src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
Normal file
199
src/Oms.Application/Labeling/LabelPrintDataBuilder.cs
Normal file
@ -0,0 +1,199 @@
|
||||
using System.Globalization;
|
||||
using Oms.Application.Dtos.Orders;
|
||||
|
||||
namespace Oms.Application.Labeling;
|
||||
|
||||
/// <summary>
|
||||
/// 将订单出参映射为 hiprint PrintData(camelCase)。
|
||||
/// </summary>
|
||||
public static class LabelPrintDataBuilder
|
||||
{
|
||||
public static IReadOnlyDictionary<string, object?> 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<string, object?>(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<string, object?>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设计预览用的 Mock PrintData。
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, object?> BuildMock() =>
|
||||
new Dictionary<string, object?>(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<Dictionary<string, object?>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
["skuCode"] = "SKU001",
|
||||
["skuName"] = "T-Shirt",
|
||||
["qty"] = 2,
|
||||
["weight"] = 0.2m,
|
||||
["declareNameEn"] = "T-Shirt",
|
||||
["declareValue"] = 9.9m
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static OrderWeightOutput? PickWeight(IReadOnlyList<OrderWeightOutput> 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));
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -26,7 +26,10 @@
|
||||
<PackageReference Include="FreeRedis" Version="1.5.5" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="MiniExcel" Version="1.43.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.10" />
|
||||
<PackageReference Include="ZXing.Net.Bindings.ImageSharp.V3" Version="0.16.15" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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<ILabelTemplateRepository>();
|
||||
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||
|
||||
/// <summary>
|
||||
/// 标签模板列表(不含 JSON,供浏览/下拉)。
|
||||
/// </summary>
|
||||
[HttpGet("/channel/label-template/list")]
|
||||
public async Task<OmsItemsResult<LabelTemplateListItemOutput>> 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<LabelTemplateListItemOutput> { Items = items };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标签模板详情(含 TemplateJson,编辑用)。
|
||||
/// </summary>
|
||||
[HttpGet("/channel/label-template/{id:int}")]
|
||||
public async Task<LabelTemplateSaveOutput> GetAsync(int id)
|
||||
{
|
||||
var entity = await GetEntityOrThrowAsync(id);
|
||||
return MapOutput(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设计器字段字典(可拖字段分组)。
|
||||
/// </summary>
|
||||
[HttpGet("/channel/label-template/field-dictionary")]
|
||||
public Task<OmsItemsResult<LabelFieldGroup>> GetFieldDictionaryAsync()
|
||||
{
|
||||
return Task.FromResult(new OmsItemsResult<LabelFieldGroup>
|
||||
{
|
||||
Items = LabelFieldDictionary.GetGroups().ToList()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览 PDF(同一套 ILabelPdfRenderer)。
|
||||
/// </summary>
|
||||
[HttpPost("/channel/label-template/preview-pdf")]
|
||||
public async Task<FileContentResult> 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<string, object?>? 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"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存标签模板。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:标签模板保存参数。</param>
|
||||
/// <returns>标签模板保存结果。</returns>
|
||||
[HttpPost("/channel/label-template/save")]
|
||||
public async Task<LabelTemplateSaveOutput> SaveAsync(LabelTemplateSaveInput input)
|
||||
{
|
||||
@ -48,6 +143,16 @@ public class LabelTemplateService : ApplicationService
|
||||
return MapOutput(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除标签模板。
|
||||
/// </summary>
|
||||
[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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<ISalesProductRepository>();
|
||||
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
|
||||
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
|
||||
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
|
||||
|
||||
/// <summary>
|
||||
/// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/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<int>();
|
||||
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<LabelPrintRecord, LabelPrintRecordOutput>(savedEntity);
|
||||
output.LabelTemplateName = labelTemplate?.Name;
|
||||
output.OperatorName = CurrentUser.UserName;
|
||||
output.PrintUrl = print.PrintUrl;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<OrderPrintOutput> PrintForBatchAsync(int orderId, CancellationToken cancellationToken = default) =>
|
||||
PrintAsync(new OrderActionInput { Id = orderId });
|
||||
|
||||
public async Task<LabelPdfGenerateResult> 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,
|
||||
|
||||
@ -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<ISmallBagTrackOrderNotifier>();
|
||||
|
||||
private async Task EnqueueGenerateLabelPdfAsync(Order order)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publisher = LazyServiceProvider.GetRequiredService<IOrderBatchJobPublisher>();
|
||||
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<int>() : 0);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 投递失败不阻断创单;打印时会补生成
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 小包预报成功后异步同步轨迹订单(POST /OrderV2/UpsertSmallBag)。
|
||||
/// </summary>
|
||||
|
||||
@ -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<ISalesProductRepository>();
|
||||
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
|
||||
private IWebHostEnvironment WebHostEnvironment => LazyServiceProvider.GetRequiredService<IWebHostEnvironment>();
|
||||
private LabelPdfGenerationService LabelPdfGenerationService =>
|
||||
LazyServiceProvider.GetRequiredService<LabelPdfGenerationService>();
|
||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||||
|
||||
@ -223,8 +226,44 @@ public partial class OrderService : ApplicationService
|
||||
public async Task<OrderPrintOutput> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -19,6 +19,11 @@ public class SalesProduct : OmsAuditedAggregateRoot
|
||||
|
||||
public string? CustomerWaybill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户面单绑定的标签模板 Id。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
public int? BlacklistAreaId { get; set; }
|
||||
|
||||
public int? RemoteAreaId { get; set; }
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
namespace Oms.Domain.OrdersAggregate.Consts;
|
||||
|
||||
/// <summary>
|
||||
/// 订单面单 PDF 生成状态。
|
||||
/// </summary>
|
||||
public static class LabelGenerateStatusCodes
|
||||
{
|
||||
public const string Pending = "Pending";
|
||||
public const string Ready = "Ready";
|
||||
public const string Failed = "Failed";
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -140,4 +140,27 @@ public class Order : OmsAuditedAggregateRoot
|
||||
public byte IsIntercept { get; set; }
|
||||
|
||||
public byte IsRemote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单 PDF 相对路径(如 /uploads/prints/orders/xxx.pdf)。
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string? LabelUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 出单时实际使用的标签模板 Id。
|
||||
/// </summary>
|
||||
public int? LabelTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单生成状态:Pending / Ready / Failed。
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 32, IsNullable = true)]
|
||||
public string? LabelGenerateStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 面单生成失败原因。
|
||||
/// </summary>
|
||||
[SugarColumn(Length = 500, IsNullable = true)]
|
||||
public string? LabelGenerateError { get; set; }
|
||||
}
|
||||
|
||||
@ -39,4 +39,9 @@ public static class SystemOtherConfigCodes
|
||||
/// Temu(AppKey/AppSecret,BaseUrl=HostAuthUrl)。
|
||||
/// </summary>
|
||||
public const string PlatformShopTemu = "platform_shop_temu";
|
||||
|
||||
/// <summary>
|
||||
/// 全局默认面单模板(AppKey=模板 Id,Enabled 控制是否生效)。
|
||||
/// </summary>
|
||||
public const string LabelDefaultTemplate = "label_default_template";
|
||||
}
|
||||
|
||||
138
src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
Normal file
138
src/Oms.Tests/Fixtures/LabelTemplates/sample_100x150.json
Normal file
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
64
src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
Normal file
64
src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
Normal file
@ -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<string, object?>
|
||||
{
|
||||
["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<LabelTextOp>().Should().Contain(x =>
|
||||
x.Text.Contains("S202607180001", StringComparison.Ordinal) &&
|
||||
x.LeftPt > 0 &&
|
||||
x.FontSizePt == 11f);
|
||||
|
||||
page.Ops.OfType<LabelBarcodeOp>().Should().Contain(x => !x.QrCode && x.Content == "S202607180001");
|
||||
page.Ops.OfType<LabelBarcodeOp>().Should().Contain(x => x.QrCode && x.Content == "S202607180001");
|
||||
page.Ops.OfType<LabelLineOp>().Should().Contain(x => !x.Vertical);
|
||||
page.Ops.OfType<LabelRectOp>().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<string, object?>());
|
||||
act.Should().Throw<Exception>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
29
src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
Normal file
29
src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs
Normal file
@ -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<string, object?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
@ -29,4 +29,8 @@
|
||||
<ProjectReference Include="..\SimpleAdmin.Api\SimpleAdmin.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\LabelTemplates\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user