Merge pull request 'fix: 运单列表 enrichment 改为顺序查询,避免 MySqlConnection 并发复用' (#13) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 2m2s
All checks were successful
Build and Deploy / deploy (push) Successful in 2m2s
Reviewed-on: #13
This commit is contained in:
commit
ae78834d7c
3
db.sql
3
db.sql
@ -1413,6 +1413,9 @@ CREATE TABLE `c_sku` (
|
|||||||
`ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode',
|
`ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode',
|
||||||
`ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode',
|
`ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode',
|
||||||
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
|
`DeclareElement` VARCHAR(255) NULL COMMENT '出口申报要素',
|
||||||
|
`ProductId` VARCHAR(100) NULL COMMENT '产品标识编码',
|
||||||
|
`NonStdProductId` VARCHAR(100) NULL COMMENT '非标产品标识编码',
|
||||||
|
`StdProductId` VARCHAR(100) NULL COMMENT '标准产品标识编码',
|
||||||
`WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)',
|
`WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)',
|
||||||
`LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)',
|
`LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)',
|
||||||
`WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)',
|
`WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)',
|
||||||
|
|||||||
55
sql/migrate_c_sku_product_ids_20261023.sql
Normal file
55
sql/migrate_c_sku_product_ids_20261023.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- c_sku:新增产品标识编码 / 非标产品标识编码 / 标准产品标识编码(对齐订单申报明细)。
|
||||||
|
-- 可重复执行。
|
||||||
|
|
||||||
|
SET @db := DATABASE();
|
||||||
|
|
||||||
|
-- c_sku.ProductId
|
||||||
|
SET @exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db
|
||||||
|
AND TABLE_NAME = 'c_sku'
|
||||||
|
AND COLUMN_NAME = 'ProductId'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@exists = 0,
|
||||||
|
'ALTER TABLE `c_sku` ADD COLUMN `ProductId` VARCHAR(100) NULL COMMENT ''产品标识编码'' AFTER `DeclareElement`',
|
||||||
|
'SELECT ''c_sku.ProductId already exists'' AS info'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- c_sku.NonStdProductId
|
||||||
|
SET @exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db
|
||||||
|
AND TABLE_NAME = 'c_sku'
|
||||||
|
AND COLUMN_NAME = 'NonStdProductId'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@exists = 0,
|
||||||
|
'ALTER TABLE `c_sku` ADD COLUMN `NonStdProductId` VARCHAR(100) NULL COMMENT ''非标产品标识编码'' AFTER `ProductId`',
|
||||||
|
'SELECT ''c_sku.NonStdProductId already exists'' AS info'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- c_sku.StdProductId
|
||||||
|
SET @exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db
|
||||||
|
AND TABLE_NAME = 'c_sku'
|
||||||
|
AND COLUMN_NAME = 'StdProductId'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@exists = 0,
|
||||||
|
'ALTER TABLE `c_sku` ADD COLUMN `StdProductId` VARCHAR(100) NULL COMMENT ''标准产品标识编码'' AFTER `NonStdProductId`',
|
||||||
|
'SELECT ''c_sku.StdProductId already exists'' AS info'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Oms.Application.Dtos.Orders;
|
namespace Oms.Application.Dtos.Orders;
|
||||||
|
|
||||||
@ -68,21 +69,24 @@ public class OrderParcelItemInput
|
|||||||
public string? DeclareElement { get; set; }
|
public string? DeclareElement { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 产品标识编码(选填)。
|
/// 产品标识编码(选填)。预报下游始终序列化,无值时为 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[StringLength(100)]
|
[StringLength(100)]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public string? ProductId { get; set; }
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 非标产品标识编码(选填)。
|
/// 非标产品标识编码(选填)。预报下游始终序列化,无值时为 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[StringLength(100)]
|
[StringLength(100)]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public string? NonStdProductId { get; set; }
|
public string? NonStdProductId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 标准产品标识编码(选填)。
|
/// 标准产品标识编码(选填)。预报下游始终序列化,无值时为 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[StringLength(100)]
|
[StringLength(100)]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||||
public string? StdProductId { get; set; }
|
public string? StdProductId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -47,6 +47,21 @@ public class WarehouseSkuOutput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DeclareElement { get; set; }
|
public string? DeclareElement { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非标产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? NonStdProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标准产品标识编码(如 GTIN / UPC / EAN)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StdProductId { get; set; }
|
||||||
|
|
||||||
public decimal? WeightG { get; set; }
|
public decimal? WeightG { get; set; }
|
||||||
|
|
||||||
public decimal? LengthCm { get; set; }
|
public decimal? LengthCm { get; set; }
|
||||||
@ -91,6 +106,21 @@ public class WarehouseSkuSaveInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DeclareElement { get; set; }
|
public string? DeclareElement { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非标产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? NonStdProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标准产品标识编码(如 GTIN / UPC / EAN)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StdProductId { get; set; }
|
||||||
|
|
||||||
public decimal? WeightG { get; set; }
|
public decimal? WeightG { get; set; }
|
||||||
|
|
||||||
public decimal? LengthCm { get; set; }
|
public decimal? LengthCm { get; set; }
|
||||||
|
|||||||
BIN
src/Oms.Application/Fonts/OmsLabelCjk.ttf
Normal file
BIN
src/Oms.Application/Fonts/OmsLabelCjk.ttf
Normal file
Binary file not shown.
5
src/Oms.Application/Fonts/README.md
Normal file
5
src/Oms.Application/Fonts/README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# 面单 PDF 中文字体
|
||||||
|
|
||||||
|
`OmsLabelCjk.ttf`:随程序嵌入,供 QuestPDF 渲染箱唛/SKU 中文(避免默认字体缺字形显示 `?`/`□`)。
|
||||||
|
|
||||||
|
部署镜像无需再依赖系统 Noto;仍可通过环境变量 `OMS_LABEL_FONT` 覆盖为其它 ttf/otf。
|
||||||
@ -63,6 +63,10 @@ public sealed class HiprintElementOptions
|
|||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>设计器「测试数据」;无 field 的纯文本有时只把文案写在这里。</summary>
|
||||||
|
[JsonPropertyName("testData")]
|
||||||
|
public string? TestData { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("field")]
|
[JsonPropertyName("field")]
|
||||||
public string? Field { get; set; }
|
public string? Field { get; set; }
|
||||||
|
|
||||||
|
|||||||
@ -105,14 +105,15 @@ public sealed class HiprintTemplateParser
|
|||||||
Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData)
|
Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData)
|
||||||
};
|
};
|
||||||
case "longtext":
|
case "longtext":
|
||||||
return BuildTextOp(opt, left, top, width, height, printData, wrap: true);
|
return BuildTextOp(
|
||||||
|
opt, left, top, width, height, printData, wrap: true, element.PrintElementType?.Title);
|
||||||
case "text":
|
case "text":
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
var textType = opt.TextType?.Trim().ToLowerInvariant();
|
var textType = opt.TextType?.Trim().ToLowerInvariant();
|
||||||
if (textType is "barcode" or "qrcode")
|
if (textType is "barcode" or "qrcode")
|
||||||
{
|
{
|
||||||
var content = ResolveValue(opt.Field, opt.Title, printData);
|
var content = ResolveValue(opt.Field, FirstNonEmpty(opt.Title, opt.TestData), printData);
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@ -129,7 +130,8 @@ public sealed class HiprintTemplateParser
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildTextOp(opt, left, top, width, height, printData, wrap: false);
|
return BuildTextOp(
|
||||||
|
opt, left, top, width, height, printData, wrap: false, element.PrintElementType?.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,25 +143,38 @@ public sealed class HiprintTemplateParser
|
|||||||
float width,
|
float width,
|
||||||
float height,
|
float height,
|
||||||
IReadOnlyDictionary<string, object?> printData,
|
IReadOnlyDictionary<string, object?> printData,
|
||||||
bool wrap)
|
bool wrap,
|
||||||
|
string? elementTypeTitle = null)
|
||||||
{
|
{
|
||||||
var value = ResolveValue(opt.Field, null, printData);
|
var value = ResolveValue(opt.Field, null, printData);
|
||||||
|
// 与 vg-print 一致:无 field 时打印 title;有 field 时按 hideTitle 决定是否带标题
|
||||||
|
var title = FirstNonEmpty(opt.Title, elementTypeTitle);
|
||||||
string text;
|
string text;
|
||||||
if (!string.IsNullOrWhiteSpace(opt.Field))
|
if (!string.IsNullOrWhiteSpace(opt.Field))
|
||||||
{
|
{
|
||||||
// 有 field:默认只打字段值;hideTitle=false 且有 title 时拼「标题:值」
|
var hideTitle = opt.HideTitle != false; // null/true → 隐藏标题(OMS 字段默认 hideTitle:true)
|
||||||
if (opt.HideTitle == false && !string.IsNullOrWhiteSpace(opt.Title) && !string.IsNullOrWhiteSpace(value))
|
if (!hideTitle && !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
text = $"{opt.Title}:{value}";
|
text = $"{title}:{value}";
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
text = value!;
|
||||||
|
}
|
||||||
|
else if (!hideTitle && !string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
// 仅标题(值为空时仍保留自定义标题文案)
|
||||||
|
text = title!;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
text = value ?? string.Empty;
|
text = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
text = opt.Title ?? string.Empty;
|
// 纯自定义文本:title → testData → 元素类型名
|
||||||
|
text = FirstNonEmpty(opt.Title, opt.TestData, elementTypeTitle) ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
@ -169,15 +184,18 @@ public sealed class HiprintTemplateParser
|
|||||||
|
|
||||||
var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase)
|
var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase)
|
||||||
|| opt.FontWeight is "700" or "600";
|
|| opt.FontWeight is "700" or "600";
|
||||||
|
var fontSize = opt.FontSize is > 0 ? opt.FontSize.Value : 10f;
|
||||||
|
// 设计器文本框常只有十余 pt 高,行高略大于字号时会被裁成「空白」
|
||||||
|
var boxHeight = Math.Max(height, fontSize * 1.4f);
|
||||||
|
|
||||||
return new LabelTextOp
|
return new LabelTextOp
|
||||||
{
|
{
|
||||||
LeftPt = left,
|
LeftPt = left,
|
||||||
TopPt = top,
|
TopPt = top,
|
||||||
WidthPt = width,
|
WidthPt = width,
|
||||||
HeightPt = height,
|
HeightPt = boxHeight,
|
||||||
Text = text,
|
Text = text,
|
||||||
FontSizePt = opt.FontSize is > 0 ? opt.FontSize.Value : 10f,
|
FontSizePt = fontSize,
|
||||||
Bold = bold,
|
Bold = bold,
|
||||||
ColorHex = opt.Color,
|
ColorHex = opt.Color,
|
||||||
Align = (opt.TextAlign ?? "left").ToLowerInvariant(),
|
Align = (opt.TextAlign ?? "left").ToLowerInvariant(),
|
||||||
@ -185,6 +203,9 @@ public sealed class HiprintTemplateParser
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
|
||||||
|
|
||||||
private static string? ResolveValue(
|
private static string? ResolveValue(
|
||||||
string? field,
|
string? field,
|
||||||
string? fallback,
|
string? fallback,
|
||||||
|
|||||||
120
src/Oms.Application/Labeling/Hiprint/LabelPdfFontRegistrar.cs
Normal file
120
src/Oms.Application/Labeling/Hiprint/LabelPdfFontRegistrar.cs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using QuestPDF.Drawing;
|
||||||
|
|
||||||
|
namespace Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 面单 PDF 中文字体:QuestPDF 默认字体无 CJK;优先嵌入字体,避免依赖容器内 TTC。
|
||||||
|
/// </summary>
|
||||||
|
public static class LabelPdfFontRegistrar
|
||||||
|
{
|
||||||
|
public const string FamilyName = "OmsLabelSans";
|
||||||
|
|
||||||
|
private const string EmbeddedName = "Oms.Application.Fonts.OmsLabelCjk.ttf";
|
||||||
|
|
||||||
|
private static int _state; // 0=未试 1=成功 2=失败
|
||||||
|
private static string? _source;
|
||||||
|
private static string? _lastError;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保已注册可用中文字体;成功返回 <see cref="FamilyName"/>,否则 null。
|
||||||
|
/// </summary>
|
||||||
|
public static string? EnsureRegistered()
|
||||||
|
{
|
||||||
|
var state = Volatile.Read(ref _state);
|
||||||
|
if (state == 1)
|
||||||
|
{
|
||||||
|
return FamilyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var attempt in EnumerateAttempts())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = attempt.Open();
|
||||||
|
if (stream == null || stream.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读入内存:Register 后不可依赖外部流生命周期;并规避部分 TTC 流问题
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
stream.CopyTo(ms);
|
||||||
|
ms.Position = 0;
|
||||||
|
FontManager.RegisterFontWithCustomName(FamilyName, ms);
|
||||||
|
_source = attempt.Description;
|
||||||
|
Interlocked.Exchange(ref _state, 1);
|
||||||
|
return FamilyName;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_lastError = $"{attempt.Description}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _state, 2);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? RegisteredSource => _source;
|
||||||
|
|
||||||
|
public static string? LastError => _lastError;
|
||||||
|
|
||||||
|
private static IEnumerable<FontAttempt> EnumerateAttempts()
|
||||||
|
{
|
||||||
|
yield return new FontAttempt(
|
||||||
|
"embedded:" + EmbeddedName,
|
||||||
|
() => Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedName));
|
||||||
|
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable("OMS_LABEL_FONT");
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||||
|
{
|
||||||
|
var path = fromEnv.Trim();
|
||||||
|
yield return new FontAttempt("env:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
foreach (var name in new[]
|
||||||
|
{
|
||||||
|
"OmsLabelCjk.ttf",
|
||||||
|
"NotoSansSC-Regular.otf",
|
||||||
|
"NotoSansSC-Regular.ttf",
|
||||||
|
"NotoSansCJKsc-Regular.otf",
|
||||||
|
"simhei.ttf"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var path = Path.Combine(baseDir, "Fonts", name);
|
||||||
|
yield return new FontAttempt("file:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winFonts = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
|
||||||
|
if (!string.IsNullOrWhiteSpace(winFonts))
|
||||||
|
{
|
||||||
|
// 单文件 TTF 优先于 TTC(集合字体在部分 Skia 版本上注册失败/缺简体)
|
||||||
|
foreach (var name in new[] { "simhei.ttf", "msyh.ttc", "msyhbd.ttc", "simsun.ttc" })
|
||||||
|
{
|
||||||
|
var path = Path.Combine(winFonts, name);
|
||||||
|
yield return new FontAttempt("win:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in new[]
|
||||||
|
{
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/arphic/uming.ttc",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
yield return new FontAttempt("linux:" + path, () => File.Exists(path) ? File.OpenRead(path) : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct FontAttempt(string Description, Func<Stream?> Open);
|
||||||
|
}
|
||||||
@ -23,6 +23,9 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
EnsureLicense();
|
EnsureLicense();
|
||||||
|
var fontFamily = LabelPdfFontRegistrar.EnsureRegistered()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"面单中文字体未就绪:" + (LabelPdfFontRegistrar.LastError ?? "未找到可用 CJK 字体"));
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (printDataPages == null || printDataPages.Count == 0)
|
if (printDataPages == null || printDataPages.Count == 0)
|
||||||
@ -48,13 +51,15 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
|||||||
{
|
{
|
||||||
page.Size(pageWidthPt, pageHeightPt);
|
page.Size(pageWidthPt, pageHeightPt);
|
||||||
page.Margin(0);
|
page.Margin(0);
|
||||||
|
// 页级默认中文字体;单元素再设字号/颜色。勿对嵌入 Regular 调 Bold(会回退缺字西文字体 → ???)
|
||||||
|
page.DefaultTextStyle(x => x.FontFamily(fontFamily).FontColor(Colors.Black));
|
||||||
page.Content().Layers(layers =>
|
page.Content().Layers(layers =>
|
||||||
{
|
{
|
||||||
layers.PrimaryLayer().Background(Colors.White);
|
layers.PrimaryLayer().Background(Colors.White);
|
||||||
|
|
||||||
foreach (var op in pageIr.Ops)
|
foreach (var op in pageIr.Ops)
|
||||||
{
|
{
|
||||||
DrawOp(layers, op);
|
DrawOp(layers, op, fontFamily);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -64,7 +69,7 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
|||||||
return Task.FromResult(bytes);
|
return Task.FromResult(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DrawOp(LayersDescriptor layers, LabelDrawOp op)
|
private static void DrawOp(LayersDescriptor layers, LabelDrawOp op, string fontFamily)
|
||||||
{
|
{
|
||||||
switch (op)
|
switch (op)
|
||||||
{
|
{
|
||||||
@ -84,16 +89,16 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
|||||||
_ => box.AlignLeft()
|
_ => box.AlignLeft()
|
||||||
};
|
};
|
||||||
|
|
||||||
var span = aligned
|
aligned.AlignTop().Text(t =>
|
||||||
.AlignTop()
|
|
||||||
.Text(text.Text)
|
|
||||||
.FontSize(text.FontSizePt)
|
|
||||||
.FontColor(ParseColor(text.ColorHex));
|
|
||||||
|
|
||||||
if (text.Bold)
|
|
||||||
{
|
{
|
||||||
span.Bold();
|
// 勿对嵌入 Regular 调用 Bold():会回退缺字西文字体,中文变成 ???
|
||||||
}
|
var size = text.Bold ? text.FontSizePt * 1.02f : text.FontSizePt;
|
||||||
|
t.DefaultTextStyle(style => style
|
||||||
|
.FontFamily(fontFamily)
|
||||||
|
.FontSize(size)
|
||||||
|
.FontColor(ParseColor(text.ColorHex)));
|
||||||
|
t.Span(text.Text ?? string.Empty);
|
||||||
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -160,6 +165,8 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
|||||||
}
|
}
|
||||||
|
|
||||||
QuestPDF.Settings.License = LicenseType.Community;
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
// 缺字形时不抛,但仍应依赖嵌入 CJK 字体
|
||||||
|
QuestPDF.Settings.CheckIfAllTextGlyphsAreAvailable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float MmToPt(float mm) => mm * 72f / 25.4f;
|
private static float MmToPt(float mm) => mm * 72f / 25.4f;
|
||||||
|
|||||||
@ -61,6 +61,7 @@ public static class InboundBoxLabelPrintDataBuilder
|
|||||||
|
|
||||||
["boxId"] = box.Id,
|
["boxId"] = box.Id,
|
||||||
["boxSeqNo"] = box.BoxSeqNo,
|
["boxSeqNo"] = box.BoxSeqNo,
|
||||||
|
["boxCount"] = pageCount,
|
||||||
["markBoxNo"] = box.MarkBoxNo,
|
["markBoxNo"] = box.MarkBoxNo,
|
||||||
["barcode"] = barcode,
|
["barcode"] = barcode,
|
||||||
|
|
||||||
@ -103,6 +104,7 @@ public static class InboundBoxLabelPrintDataBuilder
|
|||||||
["remark"] = null,
|
["remark"] = null,
|
||||||
["boxId"] = 1,
|
["boxId"] = 1,
|
||||||
["boxSeqNo"] = "1",
|
["boxSeqNo"] = "1",
|
||||||
|
["boxCount"] = 2,
|
||||||
["markBoxNo"] = "BOX-001",
|
["markBoxNo"] = "BOX-001",
|
||||||
["barcode"] = "BOX-001",
|
["barcode"] = "BOX-001",
|
||||||
["warehouseName"] = "SZ-01",
|
["warehouseName"] = "SZ-01",
|
||||||
|
|||||||
@ -85,6 +85,7 @@ public static class LabelFieldDictionary
|
|||||||
Field("markReference", "唛头参考"),
|
Field("markReference", "唛头参考"),
|
||||||
Field("markBoxNo", "箱唛号"),
|
Field("markBoxNo", "箱唛号"),
|
||||||
Field("boxSeqNo", "箱序号"),
|
Field("boxSeqNo", "箱序号"),
|
||||||
|
Field("boxCount", "箱子总数"),
|
||||||
Field("barcode", "条码内容"),
|
Field("barcode", "条码内容"),
|
||||||
Field("boxIndexText", "箱序页码"),
|
Field("boxIndexText", "箱序页码"),
|
||||||
Field("skuSummary", "SKU 摘要"),
|
Field("skuSummary", "SKU 摘要"),
|
||||||
|
|||||||
@ -29,6 +29,9 @@ public static class SkuLabelPrintDataBuilder
|
|||||||
["exHsCode"] = sku.ExHsCode,
|
["exHsCode"] = sku.ExHsCode,
|
||||||
["imHsCode"] = sku.ImHsCode,
|
["imHsCode"] = sku.ImHsCode,
|
||||||
["declareElement"] = sku.DeclareElement,
|
["declareElement"] = sku.DeclareElement,
|
||||||
|
["productId"] = sku.ProductId,
|
||||||
|
["nonStdProductId"] = sku.NonStdProductId,
|
||||||
|
["stdProductId"] = sku.StdProductId,
|
||||||
["weightG"] = sku.WeightG,
|
["weightG"] = sku.WeightG,
|
||||||
["lengthCm"] = sku.LengthCm,
|
["lengthCm"] = sku.LengthCm,
|
||||||
["widthCm"] = sku.WidthCm,
|
["widthCm"] = sku.WidthCm,
|
||||||
@ -61,6 +64,9 @@ public static class SkuLabelPrintDataBuilder
|
|||||||
["exHsCode"] = "610910",
|
["exHsCode"] = "610910",
|
||||||
["imHsCode"] = "610910",
|
["imHsCode"] = "610910",
|
||||||
["declareElement"] = "棉质 T 恤",
|
["declareElement"] = "棉质 T 恤",
|
||||||
|
["productId"] = null,
|
||||||
|
["nonStdProductId"] = null,
|
||||||
|
["stdProductId"] = "06901234567890",
|
||||||
["weightG"] = 200m,
|
["weightG"] = 200m,
|
||||||
["lengthCm"] = 30m,
|
["lengthCm"] = 30m,
|
||||||
["widthCm"] = 20m,
|
["widthCm"] = 20m,
|
||||||
|
|||||||
@ -41,6 +41,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Services\Ai\AiSkillSeedContent.json" />
|
<EmbeddedResource Include="Services\Ai\AiSkillSeedContent.json" />
|
||||||
|
<!-- 面单 QuestPDF 中文:嵌入单文件 TTF,避免容器仅有 TTC/未装字体时出 ??? -->
|
||||||
|
<EmbeddedResource Include="Fonts\OmsLabelCjk.ttf" LogicalName="Oms.Application.Fonts.OmsLabelCjk.ttf" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="Fonts\OmsLabelCjk.ttf" CopyToOutputDirectory="PreserveNewest" Link="Fonts\OmsLabelCjk.ttf" />
|
||||||
|
<None Include="Fonts\README.md" CopyToOutputDirectory="Never" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ using Oms.Application.Storage;
|
|||||||
using Oms.Domain.OrdersAggregate;
|
using Oms.Domain.OrdersAggregate;
|
||||||
using Oms.Domain.ChannelsAggregate.Consts;
|
using Oms.Domain.ChannelsAggregate.Consts;
|
||||||
using Oms.Domain.ChannelsAggregate.Entities;
|
using Oms.Domain.ChannelsAggregate.Entities;
|
||||||
|
using Oms.Domain.CustomersAggregate.Entities;
|
||||||
using Oms.Domain.OrdersAggregate.Consts;
|
using Oms.Domain.OrdersAggregate.Consts;
|
||||||
using Oms.Domain.OrdersAggregate.Entities;
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
using Oms.Domain.SystemAggregate.Entities;
|
using Oms.Domain.SystemAggregate.Entities;
|
||||||
@ -296,6 +297,272 @@ public partial class OrderService
|
|||||||
return outputs;
|
return outputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表页专用映射:不加载申报明细/费用明细,批量 enrichment,减小 JSON 与 DB 开销。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<OrderOutput>> MapOrderListOutputsAsync(IReadOnlyCollection<Order> orders)
|
||||||
|
{
|
||||||
|
if (orders.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderIds = orders.Select(x => x.Id).ToArray();
|
||||||
|
var warehouseIds = orders.Where(x => x.WarehouseId.HasValue).Select(x => x.WarehouseId!.Value).Distinct().ToArray();
|
||||||
|
var productCodes = orders
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.ProductCode))
|
||||||
|
.Select(x => x.ProductCode!.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var channelCodes = orders
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.ChannelCode))
|
||||||
|
.Select(x => x.ChannelCode!.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var countryCodes = orders
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.ConsigneeCountry))
|
||||||
|
.Select(x => x.ConsigneeCountry!.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var customerIds = orders
|
||||||
|
.Where(x => x.CustomerId is > 0)
|
||||||
|
.Select(x => x.CustomerId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var pickupOrderIds = orders
|
||||||
|
.Where(x => x.PickupOrderId is > 0)
|
||||||
|
.Select(x => x.PickupOrderId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// SqlSugarScope 共用同一 MySqlConnection,不能 Task.WhenAll 并发查库(会报 already in use)
|
||||||
|
var senders = await OrderRepository.GetSendersByOrderIdsAsync(orderIds);
|
||||||
|
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync(orderIds);
|
||||||
|
var weights = await OrderRepository.GetWeightsByOrderIdsAsync(orderIds);
|
||||||
|
var receivableSumMap = await OrderRepository.GetReceivableSumByOrderIdsAsync(orderIds);
|
||||||
|
var warehouses = warehouseIds.Length == 0
|
||||||
|
? []
|
||||||
|
: await WarehouseAddressRepository.GetListAsync(x => warehouseIds.Contains(x.Id));
|
||||||
|
var products = productCodes.Length == 0
|
||||||
|
? []
|
||||||
|
: await SalesProductRepository.GetListAsync(x => productCodes.Contains(x.ProductCode));
|
||||||
|
var channels = channelCodes.Length == 0
|
||||||
|
? []
|
||||||
|
: await ServiceChannelRepository.GetListAsync(x => channelCodes.Contains(x.ChannelCode));
|
||||||
|
var countries = countryCodes.Length == 0
|
||||||
|
? []
|
||||||
|
: await CountryRepository.GetListAsync(x => countryCodes.Contains(x.CountryCode));
|
||||||
|
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
||||||
|
orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
|
||||||
|
var customers = customerIds.Length == 0
|
||||||
|
? []
|
||||||
|
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
||||||
|
var pickupOrders = pickupOrderIds.Length == 0
|
||||||
|
? []
|
||||||
|
: await PickupOrderRepository.GetListAsync(x => pickupOrderIds.Contains(x.Id));
|
||||||
|
var bindSummaryMap = await LoadStockBindListSummariesAsync(orderIds);
|
||||||
|
|
||||||
|
var senderMap = senders.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.First());
|
||||||
|
var recipientMap = recipients.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.First());
|
||||||
|
var weightMap = weights.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
|
||||||
|
var warehouseMap = warehouses.ToDictionary(x => x.Id);
|
||||||
|
var productMap = products.ToDictionary(x => x.ProductCode, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var channelMap = channels.ToDictionary(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var countryMap = countries.ToDictionary(x => x.CountryCode, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var customerMap = customers.ToDictionary(x => x.Id);
|
||||||
|
var pickupNoMap = pickupOrders.ToDictionary(x => x.Id, x => x.PickupNo);
|
||||||
|
var carrierWaybillNoMap = pickupOrders
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.CarrierWaybillNo))
|
||||||
|
.ToDictionary(x => x.Id, x => x.CarrierWaybillNo!);
|
||||||
|
|
||||||
|
var addressCountryIds = senders
|
||||||
|
.Select(x => x.Country)
|
||||||
|
.Concat(recipients.Select(x => x.Country))
|
||||||
|
.Where(x => x.HasValue)
|
||||||
|
.Select(x => x!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var addressCountries = addressCountryIds.Length == 0
|
||||||
|
? []
|
||||||
|
: await CountryRepository.GetListAsync(x => addressCountryIds.Contains(x.Id));
|
||||||
|
var countryIdCodeMap = addressCountries.ToDictionary(
|
||||||
|
x => x.Id,
|
||||||
|
x => x.CountryCode ?? string.Empty);
|
||||||
|
|
||||||
|
var outputs = orders.Select(order =>
|
||||||
|
{
|
||||||
|
senderMap.TryGetValue(order.Id, out var sender);
|
||||||
|
recipientMap.TryGetValue(order.Id, out var recipient);
|
||||||
|
weightMap.TryGetValue(order.Id, out var orderWeights);
|
||||||
|
var chargeableWeight = ResolveDisplayChargeableWeight(orderWeights);
|
||||||
|
decimal? receivableAmount = receivableSumMap.TryGetValue(order.Id, out var sum)
|
||||||
|
? sum
|
||||||
|
: null;
|
||||||
|
var additionalServices = NormalizeAdditionalServiceCodes(
|
||||||
|
OmsDocHelper.DeserializeJson<List<string>>(order.AdditionalServices));
|
||||||
|
var hasInsurance = additionalServices.Any(x =>
|
||||||
|
string.Equals(x, "insurance", StringComparison.OrdinalIgnoreCase));
|
||||||
|
string? customerName = null;
|
||||||
|
string? customerCode = null;
|
||||||
|
if (order.CustomerId is > 0 && customerMap.TryGetValue(order.CustomerId.Value, out var customer))
|
||||||
|
{
|
||||||
|
customerName = customer.CustomerName;
|
||||||
|
customerCode = customer.CustomerCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
SalesProduct? product = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(order.ProductCode))
|
||||||
|
{
|
||||||
|
productMap.TryGetValue(order.ProductCode.Trim(), out product);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindSummaryMap.TryGetValue(order.Id, out var bindSummary);
|
||||||
|
|
||||||
|
return new OrderOutput
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
ClientNo = order.ClientNo,
|
||||||
|
TrackNo = order.TrackNo,
|
||||||
|
DownstreamServiceNo = order.DownstreamServiceNo,
|
||||||
|
ServiceNo = order.ServiceNo,
|
||||||
|
ProductCode = order.ProductCode,
|
||||||
|
ProductName = product?.ProductName,
|
||||||
|
MaxTimeliness = product?.MaxTimeliness,
|
||||||
|
ChannelCode = order.ChannelCode,
|
||||||
|
DownstreamSupplierCode = order.DownstreamSupplierCode,
|
||||||
|
DownstreamSupplierName = order.DownstreamSupplierName,
|
||||||
|
DownstreamAppKey = order.DownstreamAppKey,
|
||||||
|
DownstreamAppId = order.DownstreamAppId,
|
||||||
|
ChannelForecastCode = order.ChannelForecastCode,
|
||||||
|
ChannelName = !string.IsNullOrWhiteSpace(order.ChannelCode)
|
||||||
|
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
|
||||||
|
? channel.ChannelName
|
||||||
|
: null,
|
||||||
|
ConsigneeCountry = order.ConsigneeCountry,
|
||||||
|
ConsigneeCountryName = ResolveConsigneeCountryName(order, countryMap),
|
||||||
|
CargoAttribute = order.CargoAttribute,
|
||||||
|
WarehouseId = order.WarehouseId,
|
||||||
|
Warehouse = order.WarehouseId.HasValue && warehouseMap.TryGetValue(order.WarehouseId.Value, out var warehouse)
|
||||||
|
? warehouse.WarehouseName
|
||||||
|
: null,
|
||||||
|
CustomerId = order.CustomerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
CustomerCode = customerCode,
|
||||||
|
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
|
||||||
|
? OrderSourceCodes.Manual
|
||||||
|
: order.OrderSource,
|
||||||
|
PickupTime = order.PickupTime,
|
||||||
|
CustomerSenderAddressId = order.CustomerSenderAddressId,
|
||||||
|
InboundMethod = order.InboundMethod,
|
||||||
|
FulfillmentType = string.IsNullOrWhiteSpace(order.FulfillmentType)
|
||||||
|
? FulfillmentTypeCodes.Direct
|
||||||
|
: order.FulfillmentType,
|
||||||
|
FulfillmentSource = order.FulfillmentSource,
|
||||||
|
StockBindStatus = bindSummary?.BindStatus ?? "none",
|
||||||
|
StockBindLineCount = bindSummary?.LineCount ?? 0,
|
||||||
|
StockBindQtyTotal = bindSummary?.QtyTotal ?? 0,
|
||||||
|
DomesticTrackingNos = bindSummary?.TrackingNosText,
|
||||||
|
FulfillProgress = FulfillmentProgressCodes.Resolve(
|
||||||
|
FulfillmentTypeCodes.IsWarehouse(order.FulfillmentType),
|
||||||
|
bindSummary?.BindStatus ?? "none",
|
||||||
|
bindSummary?.LineCount ?? 0,
|
||||||
|
bindSummary?.HasInTransitParcel ?? false),
|
||||||
|
PrimaryBinCode = bindSummary?.PrimaryBinCode,
|
||||||
|
PrimarySkuCodes = bindSummary?.PrimarySkuCodes,
|
||||||
|
PickupOrderId = order.PickupOrderId,
|
||||||
|
PickupNo = order.PickupOrderId is > 0
|
||||||
|
&& pickupNoMap.TryGetValue(order.PickupOrderId.Value, out var pickupNo)
|
||||||
|
? pickupNo
|
||||||
|
: null,
|
||||||
|
CarrierWaybillNo = order.PickupOrderId is > 0
|
||||||
|
&& carrierWaybillNoMap.TryGetValue(order.PickupOrderId.Value, out var carrierWaybillNo)
|
||||||
|
? carrierWaybillNo
|
||||||
|
: null,
|
||||||
|
RequestId = order.RequestId,
|
||||||
|
ExportCustomsMode = order.ExportCustomsMode,
|
||||||
|
ExportQualificationId = order.ExportQualificationId,
|
||||||
|
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
|
||||||
|
? null
|
||||||
|
: order.PredeclareFailReason.Trim(),
|
||||||
|
AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason)
|
||||||
|
? null
|
||||||
|
: order.AllocationPendingReason.Trim(),
|
||||||
|
AllocationScheme = string.IsNullOrWhiteSpace(order.AllocationScheme)
|
||||||
|
? null
|
||||||
|
: order.AllocationScheme.Trim(),
|
||||||
|
// 分页已 IgnoreColumns(AllocationTraceJson);与写入路径一并落库的 TraceTime 作为布尔依据
|
||||||
|
HasAllocationTrace = order.AllocationTraceTime.HasValue
|
||||||
|
|| !string.IsNullOrWhiteSpace(order.AllocationTraceJson),
|
||||||
|
DeclaredCurrency = order.DeclaredCurrency,
|
||||||
|
DeclaredSum = order.DeclaredSum,
|
||||||
|
Quantity = order.Quantity,
|
||||||
|
IsCod = order.IsCod > 0,
|
||||||
|
CodCurrency = order.CodCurrency,
|
||||||
|
CodSum = order.CodSum,
|
||||||
|
OrderStatus = OmsDocHelper.GetOrderStatusName(order.OrderStatus),
|
||||||
|
Status = order.OrderStatus,
|
||||||
|
HandleStatus = OrderHandleStatusCodes.IsProcessed(order.HandleStatus)
|
||||||
|
? OrderHandleStatusCodes.Processed
|
||||||
|
: OrderHandleStatusCodes.Pending,
|
||||||
|
HandleStatusName = OrderHandleStatusCodes.GetDisplayName(order.HandleStatus),
|
||||||
|
IssueType = order.IssueType,
|
||||||
|
IssueTypeName = IssueTypeCodes.GetDisplayName(order.IssueType),
|
||||||
|
IssueTitle = order.IssueTitle,
|
||||||
|
IssueContent = order.IssueContent,
|
||||||
|
IssueTemplateId = order.IssueTemplateId,
|
||||||
|
IssueRemark = order.IssueRemark,
|
||||||
|
IssueTime = order.IssueTime,
|
||||||
|
ClaimType = order.ClaimType,
|
||||||
|
ClaimTypeName = ClaimTypeCodes.GetDisplayName(order.ClaimType),
|
||||||
|
ClaimAmount = order.ClaimAmount,
|
||||||
|
ClaimCurrency = order.ClaimCurrency,
|
||||||
|
ClaimReason = order.ClaimReason,
|
||||||
|
ClaimTime = order.ClaimTime,
|
||||||
|
PreviousOrderStatus = order.PreviousOrderStatus,
|
||||||
|
ReceiveTime = order.ReceiveTime,
|
||||||
|
ShipTime = order.ShipTime,
|
||||||
|
PayTime = order.PayTime,
|
||||||
|
IsPaid = order.IsPaid > 0,
|
||||||
|
SignTime = order.SignTime,
|
||||||
|
IsIntercept = order.IsIntercept > 0,
|
||||||
|
IsRemote = order.IsRemote > 0,
|
||||||
|
AdditionalServices = additionalServices,
|
||||||
|
HasInsurance = hasInsurance,
|
||||||
|
ForecastReceivableAmount = order.ForecastReceivableAmount,
|
||||||
|
ForecastPayableAmount = order.ForecastPayableAmount,
|
||||||
|
ReceivableSubtotal = order.ReceivableSubtotal,
|
||||||
|
LevelDiscountAmount = order.LevelDiscountAmount,
|
||||||
|
CouponDiscountAmount = order.CouponDiscountAmount,
|
||||||
|
ActualPayAmount = order.PayableAmount,
|
||||||
|
CouponInstanceId = order.CouponInstanceId,
|
||||||
|
SalesProductRulesVersion = string.IsNullOrWhiteSpace(order.SalesProductRulesVersion)
|
||||||
|
? null
|
||||||
|
: order.SalesProductRulesVersion.Trim(),
|
||||||
|
SalesProductPriceVersion = string.IsNullOrWhiteSpace(order.SalesProductPriceVersion)
|
||||||
|
? null
|
||||||
|
: order.SalesProductPriceVersion.Trim(),
|
||||||
|
CreateTime = order.CreateTime,
|
||||||
|
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
|
||||||
|
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
|
||||||
|
UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.UpdateUserId, auditUserNameMap),
|
||||||
|
Sender = MapSender(sender, countryIdCodeMap),
|
||||||
|
Receiver = MapReceiver(recipient, countryIdCodeMap),
|
||||||
|
Items = [],
|
||||||
|
WeightList = [],
|
||||||
|
FeeList = [],
|
||||||
|
ChargeableWeight = chargeableWeight,
|
||||||
|
ReceivableAmount = receivableAmount,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
|
||||||
|
{
|
||||||
|
RedactChannelFieldsForCustomer(outputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class StockBindListSummary
|
private sealed class StockBindListSummary
|
||||||
{
|
{
|
||||||
public string BindStatus { get; init; } = "none";
|
public string BindStatus { get; init; } = "none";
|
||||||
|
|||||||
@ -70,7 +70,11 @@ public partial class OrderService : ApplicationService
|
|||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
filter);
|
filter);
|
||||||
return new OmsPageResult<OrderOutput> { Items = await MapOrderOutputsAsync(orders), Total = total };
|
return new OmsPageResult<OrderOutput>
|
||||||
|
{
|
||||||
|
Items = await MapOrderListOutputsAsync(orders),
|
||||||
|
Total = total,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -267,7 +267,9 @@ public static class PlatformStoreOrderParcelBuilder
|
|||||||
var unitWeightKg = sku.WeightG is > 0 ? Math.Max(sku.WeightG.Value / 1000m, MinActualWeightKg) : MinActualWeightKg;
|
var unitWeightKg = sku.WeightG is > 0 ? Math.Max(sku.WeightG.Value / 1000m, MinActualWeightKg) : MinActualWeightKg;
|
||||||
var unitPrice = line.UnitPrice is > 0 ? line.UnitPrice.Value : 1m;
|
var unitPrice = line.UnitPrice is > 0 ? line.UnitPrice.Value : 1m;
|
||||||
var cnName = string.IsNullOrWhiteSpace(line.ProductName) ? sku.SkuName : line.ProductName.Trim();
|
var cnName = string.IsNullOrWhiteSpace(line.ProductName) ? sku.SkuName : line.ProductName.Trim();
|
||||||
var enName = string.IsNullOrWhiteSpace(sku.SkuName) ? cnName : sku.SkuName;
|
var enName = string.IsNullOrWhiteSpace(sku.SkuNameEn)
|
||||||
|
? (string.IsNullOrWhiteSpace(sku.SkuName) ? cnName : sku.SkuName)
|
||||||
|
: sku.SkuNameEn.Trim();
|
||||||
|
|
||||||
items.Add(new OrderParcelItemInput
|
items.Add(new OrderParcelItemInput
|
||||||
{
|
{
|
||||||
@ -279,7 +281,13 @@ public static class PlatformStoreOrderParcelBuilder
|
|||||||
UnitWeight = unitWeightKg,
|
UnitWeight = unitWeightKg,
|
||||||
Quantity = line.Quantity,
|
Quantity = line.Quantity,
|
||||||
Amount = unitPrice * line.Quantity,
|
Amount = unitPrice * line.Quantity,
|
||||||
Weight = unitWeightKg * line.Quantity
|
Weight = unitWeightKg * line.Quantity,
|
||||||
|
ExHsCode = sku.ExHsCode,
|
||||||
|
ImHsCode = sku.ImHsCode,
|
||||||
|
DeclareElement = sku.DeclareElement,
|
||||||
|
ProductId = sku.ProductId,
|
||||||
|
NonStdProductId = sku.NonStdProductId,
|
||||||
|
StdProductId = sku.StdProductId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -139,6 +139,11 @@ public partial class WarehouseService
|
|||||||
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
|
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
|
||||||
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
|
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.Trim();
|
||||||
entity.DeclareElement = string.IsNullOrWhiteSpace(input.DeclareElement) ? null : input.DeclareElement.Trim();
|
entity.DeclareElement = string.IsNullOrWhiteSpace(input.DeclareElement) ? null : input.DeclareElement.Trim();
|
||||||
|
entity.ProductId = string.IsNullOrWhiteSpace(input.ProductId) ? null : input.ProductId.Trim();
|
||||||
|
entity.NonStdProductId = string.IsNullOrWhiteSpace(input.NonStdProductId)
|
||||||
|
? null
|
||||||
|
: input.NonStdProductId.Trim();
|
||||||
|
entity.StdProductId = string.IsNullOrWhiteSpace(input.StdProductId) ? null : input.StdProductId.Trim();
|
||||||
entity.WeightG = input.WeightG;
|
entity.WeightG = input.WeightG;
|
||||||
entity.LengthCm = input.LengthCm;
|
entity.LengthCm = input.LengthCm;
|
||||||
entity.WidthCm = input.WidthCm;
|
entity.WidthCm = input.WidthCm;
|
||||||
@ -309,6 +314,9 @@ public partial class WarehouseService
|
|||||||
ExHsCode = entity.ExHsCode,
|
ExHsCode = entity.ExHsCode,
|
||||||
ImHsCode = entity.ImHsCode,
|
ImHsCode = entity.ImHsCode,
|
||||||
DeclareElement = entity.DeclareElement,
|
DeclareElement = entity.DeclareElement,
|
||||||
|
ProductId = entity.ProductId,
|
||||||
|
NonStdProductId = entity.NonStdProductId,
|
||||||
|
StdProductId = entity.StdProductId,
|
||||||
WeightG = entity.WeightG,
|
WeightG = entity.WeightG,
|
||||||
LengthCm = entity.LengthCm,
|
LengthCm = entity.LengthCm,
|
||||||
WidthCm = entity.WidthCm,
|
WidthCm = entity.WidthCm,
|
||||||
@ -331,6 +339,9 @@ public partial class WarehouseService
|
|||||||
ExHsCode = entity.ExHsCode,
|
ExHsCode = entity.ExHsCode,
|
||||||
ImHsCode = entity.ImHsCode,
|
ImHsCode = entity.ImHsCode,
|
||||||
DeclareElement = entity.DeclareElement,
|
DeclareElement = entity.DeclareElement,
|
||||||
|
ProductId = entity.ProductId,
|
||||||
|
NonStdProductId = entity.NonStdProductId,
|
||||||
|
StdProductId = entity.StdProductId,
|
||||||
WeightG = entity.WeightG,
|
WeightG = entity.WeightG,
|
||||||
LengthCm = entity.LengthCm,
|
LengthCm = entity.LengthCm,
|
||||||
WidthCm = entity.WidthCm,
|
WidthCm = entity.WidthCm,
|
||||||
|
|||||||
@ -201,9 +201,10 @@ public static class OrderDownstreamCreateOneBodyBuilder
|
|||||||
ImHsCode = x.ImHsCode,
|
ImHsCode = x.ImHsCode,
|
||||||
ExHsCode = x.ExHsCode ?? x.HsCode,
|
ExHsCode = x.ExHsCode ?? x.HsCode,
|
||||||
DeclareElement = x.DeclareElement,
|
DeclareElement = x.DeclareElement,
|
||||||
ProductId = x.ProductId,
|
// 始终赋值;空白→null。配合 OrderParcelItemInput 上 JsonIgnore(Never),请求体固定出现 "xxx": null。
|
||||||
NonStdProductId = x.NonStdProductId,
|
ProductId = NullIfWhiteSpace(x.ProductId),
|
||||||
StdProductId = x.StdProductId,
|
NonStdProductId = NullIfWhiteSpace(x.NonStdProductId),
|
||||||
|
StdProductId = NullIfWhiteSpace(x.StdProductId),
|
||||||
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
|
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
|
||||||
Category = x.Category,
|
Category = x.Category,
|
||||||
Brand = x.Brand,
|
Brand = x.Brand,
|
||||||
@ -220,4 +221,7 @@ public static class OrderDownstreamCreateOneBodyBuilder
|
|||||||
Specifications = x.Specifications,
|
Specifications = x.Specifications,
|
||||||
Comment = x.Comment
|
Comment = x.Comment
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,32 @@ public static class FileStoragePath
|
|||||||
throw new ArgumentException("object key 不能为空", nameof(pathOrUrl));
|
throw new ArgumentException("object key 不能为空", nameof(pathOrUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
return key;
|
// 自定义域公开 URL 常带 PathBase「admin/」;库内/COS 逻辑 key 为 uploads/...。
|
||||||
|
// 若不剥离,GetUrl 再拼 KeyPrefix/PathBase 会变成 admin/admin/uploads → NoSuchKey。
|
||||||
|
return StripPublicPathBase(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 去掉公开 URL 上的 PathBase(可重复的 admin/),保留 uploads/... 等逻辑 key。
|
||||||
|
/// </summary>
|
||||||
|
public static string StripPublicPathBase(string key)
|
||||||
|
{
|
||||||
|
var value = key.Replace('\\', '/').Trim().TrimStart('/');
|
||||||
|
while (value.StartsWith("admin/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var rest = value["admin/".Length..];
|
||||||
|
// 仅剥「PathBase + 业务前缀」;避免误伤 admin 业务目录(若有)
|
||||||
|
if (rest.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| rest.StartsWith("admin/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = rest;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -70,6 +70,13 @@ public interface IOrderRepository : IRepository<Order>
|
|||||||
|
|
||||||
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
|
Task<List<OrderFee>> GetFeesByOrderIdsAsync(IEnumerable<int> orderIds, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表页:按订单聚合应收金额合计(不加载费用明细行)。
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<int, decimal>> GetReceivableSumByOrderIdsAsync(
|
||||||
|
IEnumerable<int> orderIds,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<(List<OrderReceivablePayableRow> Items, int Total)> GetReceivablePayablePagedListAsync(
|
Task<(List<OrderReceivablePayableRow> Items, int Total)> GetReceivablePayablePagedListAsync(
|
||||||
int pageIndex,
|
int pageIndex,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|||||||
@ -27,6 +27,21 @@ public class CSku : OmsAuditedAggregateRoot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DeclareElement { get; set; }
|
public string? DeclareElement { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? ProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非标产品标识编码。
|
||||||
|
/// </summary>
|
||||||
|
public string? NonStdProductId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标准产品标识编码(如 GTIN / UPC / EAN)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StdProductId { get; set; }
|
||||||
|
|
||||||
public decimal? WeightG { get; set; }
|
public decimal? WeightG { get; set; }
|
||||||
|
|
||||||
public decimal? LengthCm { get; set; }
|
public decimal? LengthCm { get; set; }
|
||||||
|
|||||||
@ -23,7 +23,9 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
|||||||
var query = ApplyListFilter(db.Queryable<Order>(), filter);
|
var query = ApplyListFilter(db.Queryable<Order>(), filter);
|
||||||
var total = await query.CountAsync(cancellationToken);
|
var total = await query.CountAsync(cancellationToken);
|
||||||
query = ApplyListSort(query, filter);
|
query = ApplyListSort(query, filter);
|
||||||
|
// 列表仅需 HasAllocationTrace 布尔语义,勿拉取大字段 AllocationTraceJson
|
||||||
var items = await query
|
var items = await query
|
||||||
|
.IgnoreColumns(x => x.AllocationTraceJson)
|
||||||
.ToPageListAsync(pageIndex, pageSize, cancellationToken);
|
.ToPageListAsync(pageIndex, pageSize, cancellationToken);
|
||||||
return (items, total);
|
return (items, total);
|
||||||
}
|
}
|
||||||
@ -1104,6 +1106,34 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表页:按订单聚合应收金额合计(不加载费用明细行)。
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Dictionary<int, decimal>> GetReceivableSumByOrderIdsAsync(
|
||||||
|
IEnumerable<int> orderIds,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ids = orderIds.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<int, decimal>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var db = await GetDbContextAsync(cancellationToken);
|
||||||
|
var rows = await db.Queryable<OrderFee>()
|
||||||
|
.Where(x => ids.Contains(x.OrderId)
|
||||||
|
&& !x.IsDeleted
|
||||||
|
&& x.Type == OrderFeeTypeCodes.Receivable)
|
||||||
|
.GroupBy(x => x.OrderId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
OrderId = x.OrderId,
|
||||||
|
Amount = SqlFunc.AggregateSum(x.Amount),
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return rows.ToDictionary(x => x.OrderId, x => x.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(List<OrderReceivablePayableRow> Items, int Total)> GetReceivablePayablePagedListAsync(
|
public async Task<(List<OrderReceivablePayableRow> Items, int Total)> GetReceivablePayablePagedListAsync(
|
||||||
int pageIndex,
|
int pageIndex,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|||||||
@ -15,8 +15,9 @@ public class FileStoragePathTests
|
|||||||
|
|
||||||
var keys = FileStoragePath.ExpandReadKeys(url);
|
var keys = FileStoragePath.ExpandReadKeys(url);
|
||||||
|
|
||||||
keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
// NormalizeKey 已剥 PathBase,主 key 为 uploads/...;Expand 仍保留 admin/ 候选以兼容历史对象
|
||||||
keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
||||||
|
keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@ -41,6 +42,47 @@ public class FileStoragePathTests
|
|||||||
.Be("uploads/prints/orders/IDE2610081704036507-20261008170404.pdf");
|
.Be("uploads/prints/orders/IDE2610081704036507-20261008170404.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToStorageKey_ShouldStripPathBaseAdminFromCustomDomainUrl()
|
||||||
|
{
|
||||||
|
var url =
|
||||||
|
"https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610231052073356-20261023105208.pdf?q-sign-algorithm=sha1";
|
||||||
|
|
||||||
|
OmsObjectStorageExtensions.ToStorageKey(url)
|
||||||
|
.Should()
|
||||||
|
.Be("uploads/prints/orders/IDE2610231052073356-20261023105208.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeKey_ShouldCollapseDoubleAdminPathBase()
|
||||||
|
{
|
||||||
|
FileStoragePath.NormalizeKey("admin/admin/uploads/prints/orders/a.pdf")
|
||||||
|
.Should()
|
||||||
|
.Be("uploads/prints/orders/a.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPublicUrl_ShouldResignCustomDomainUrlWithoutDoubleAdmin()
|
||||||
|
{
|
||||||
|
var expired =
|
||||||
|
"https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610231052073356-20261023105208.pdf?q-sign-algorithm=sha1&q-sign-time=1;2";
|
||||||
|
const string fresh =
|
||||||
|
"https://cos.test.ideship.com/admin/uploads/prints/orders/IDE2610231052073356-20261023105208.pdf?q-sign-algorithm=sha1&fresh=1";
|
||||||
|
|
||||||
|
var storage = new Mock<IObjectStorage>(MockBehavior.Strict);
|
||||||
|
storage
|
||||||
|
.Setup(x => x.GetUrlAsync(
|
||||||
|
"uploads/prints/orders/IDE2610231052073356-20261023105208.pdf",
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(fresh);
|
||||||
|
|
||||||
|
var url = await storage.Object.GetPublicUrlAsync(expired);
|
||||||
|
|
||||||
|
url.Should().Be(fresh);
|
||||||
|
storage.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToStorageKey_ShouldKeepExternalAbsoluteUrl()
|
public void ToStorageKey_ShouldKeepExternalAbsoluteUrl()
|
||||||
{
|
{
|
||||||
|
|||||||
56
src/Oms.Tests/LabelPdfFontTests.cs
Normal file
56
src/Oms.Tests/LabelPdfFontTests.cs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Oms.Application.Labeling.Hiprint;
|
||||||
|
|
||||||
|
namespace Oms.Tests;
|
||||||
|
|
||||||
|
public class LabelPdfFontTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EnsureRegistered_ShouldLoadEmbeddedCjkFont()
|
||||||
|
{
|
||||||
|
var family = LabelPdfFontRegistrar.EnsureRegistered();
|
||||||
|
|
||||||
|
family.Should().Be(LabelPdfFontRegistrar.FamilyName);
|
||||||
|
LabelPdfFontRegistrar.RegisteredSource.Should().NotBeNullOrWhiteSpace();
|
||||||
|
LabelPdfFontRegistrar.RegisteredSource.Should().StartWith("embedded:");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Render_ShouldEmbedChineseWithoutThrowing()
|
||||||
|
{
|
||||||
|
LabelPdfFontRegistrar.EnsureRegistered().Should().NotBeNull();
|
||||||
|
|
||||||
|
const string templateJson = """
|
||||||
|
{
|
||||||
|
"panels": [{
|
||||||
|
"width": 100,
|
||||||
|
"height": 50,
|
||||||
|
"printElements": [{
|
||||||
|
"options": {
|
||||||
|
"left": 2,
|
||||||
|
"top": 2,
|
||||||
|
"width": 90,
|
||||||
|
"height": 20,
|
||||||
|
"title": "红米手机 摘要×25",
|
||||||
|
"fontSize": 12
|
||||||
|
},
|
||||||
|
"printElementType": { "type": "text" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var renderer = new QuestPdfLabelRenderer(new HiprintTemplateParser());
|
||||||
|
var pdf = await renderer.RenderAsync(
|
||||||
|
templateJson,
|
||||||
|
new Dictionary<string, object?> { ["skuSummary"] = "红米手机 512g×25" });
|
||||||
|
|
||||||
|
pdf.Should().NotBeNull();
|
||||||
|
pdf.Length.Should().BeGreaterThan(500);
|
||||||
|
// RegisterFontWithCustomName 后 PDF 仍写入真实 PostScript 名(如 SimHei)
|
||||||
|
var ascii = System.Text.Encoding.ASCII.GetString(pdf);
|
||||||
|
ascii.Should().Match(s => s.Contains("SimHei", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| s.Contains("OmsLabelSans", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| s.Contains("Noto", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,59 @@ public class HiprintTemplateParserTests
|
|||||||
act.Should().Throw<Exception>();
|
act.Should().Throw<Exception>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_StaticCustomText_ShouldKeepTitleAndNotClipHeight()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"panels": [{
|
||||||
|
"width": 100,
|
||||||
|
"height": 50,
|
||||||
|
"printElements": [{
|
||||||
|
"options": {
|
||||||
|
"left": 10,
|
||||||
|
"top": 10,
|
||||||
|
"width": 80,
|
||||||
|
"height": 13,
|
||||||
|
"title": "箱子数量",
|
||||||
|
"fontSize": 10,
|
||||||
|
"hideTitle": true
|
||||||
|
},
|
||||||
|
"printElementType": { "type": "text", "title": "文本" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var page = new HiprintTemplateParser().Parse(json, new Dictionary<string, object?>());
|
||||||
|
var text = page.Ops.OfType<LabelTextOp>().Should().ContainSingle().Subject;
|
||||||
|
text.Text.Should().Be("箱子数量");
|
||||||
|
text.HeightPt.Should().BeGreaterThanOrEqualTo(14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_StaticTextFromTestData_WhenTitleEmpty()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"panels": [{
|
||||||
|
"width": 100,
|
||||||
|
"height": 50,
|
||||||
|
"printElements": [{
|
||||||
|
"options": {
|
||||||
|
"left": 1, "top": 1, "width": 50, "height": 16,
|
||||||
|
"testData": "仅测试数据文案"
|
||||||
|
},
|
||||||
|
"printElementType": { "type": "text" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var page = new HiprintTemplateParser().Parse(json, new Dictionary<string, object?>());
|
||||||
|
page.Ops.OfType<LabelTextOp>().Should().ContainSingle(x => x.Text == "仅测试数据文案");
|
||||||
|
}
|
||||||
|
|
||||||
internal static string LoadFixture(string fileName)
|
internal static string LoadFixture(string fileName)
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||||
|
|||||||
67
src/Oms.Tests/OrderDownstreamCreateOneBodyBuilderTests.cs
Normal file
67
src/Oms.Tests/OrderDownstreamCreateOneBodyBuilderTests.cs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using Oms.Application.Shared;
|
||||||
|
using Oms.Domain.ChannelsAggregate.Repositories;
|
||||||
|
using Oms.Domain.OrdersAggregate.Entities;
|
||||||
|
using Oms.Domain.SystemAggregate.Repositories;
|
||||||
|
|
||||||
|
namespace Oms.Tests;
|
||||||
|
|
||||||
|
public class OrderDownstreamCreateOneBodyBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildAsync_ShouldAlwaysSerializeProductIdFieldsIncludingNull()
|
||||||
|
{
|
||||||
|
var order = new Order
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClientNo = "C1",
|
||||||
|
ServiceNo = "S1",
|
||||||
|
ChannelCode = "CH1",
|
||||||
|
ChannelForecastCode = "FC1",
|
||||||
|
};
|
||||||
|
var declarations = new List<OrderDeclaration>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OrderId = 1,
|
||||||
|
CnName = "衬衫",
|
||||||
|
EnName = "Shirt",
|
||||||
|
UnitPrice = 1,
|
||||||
|
Quantity = 1,
|
||||||
|
ProductId = "P-1",
|
||||||
|
NonStdProductId = " ",
|
||||||
|
StdProductId = " 06901234567890 ",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = await OrderDownstreamCreateOneBodyBuilder.BuildAsync(
|
||||||
|
order,
|
||||||
|
sender: null,
|
||||||
|
recipient: null,
|
||||||
|
declarations,
|
||||||
|
weights: [],
|
||||||
|
new Mock<ICountryRepository>(MockBehavior.Loose).Object,
|
||||||
|
new Mock<IServiceChannelRepository>(MockBehavior.Loose).Object);
|
||||||
|
|
||||||
|
body.Items.Should().ContainSingle();
|
||||||
|
body.Items[0].ProductId.Should().Be("P-1");
|
||||||
|
body.Items[0].NonStdProductId.Should().BeNull();
|
||||||
|
body.Items[0].StdProductId.Should().Be("06901234567890");
|
||||||
|
|
||||||
|
// 与 ItfxForwardClient 一致:全局 WhenWritingNull,但三字段 JsonIgnore(Never) 仍写出 null
|
||||||
|
var json = JsonSerializer.Serialize(
|
||||||
|
body,
|
||||||
|
new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
});
|
||||||
|
|
||||||
|
json.Should().Contain("\"productId\":\"P-1\"");
|
||||||
|
json.Should().Contain("\"nonStdProductId\":null");
|
||||||
|
json.Should().Contain("\"stdProductId\":\"06901234567890\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,10 @@ RUN dotnet publish "SimpleAdmin.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publ
|
|||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# 面单 QuestPDF 需 CJK 字体,否则中文 SKU 等渲染为 □
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends fonts-noto-cjk \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
# Local 存储落盘 wwwroot;非 root 用户需可写 uploads(Provider=Cos 时可不依赖此目录)
|
# Local 存储落盘 wwwroot;非 root 用户需可写 uploads(Provider=Cos 时可不依赖此目录)
|
||||||
RUN mkdir -p /app/wwwroot/uploads \
|
RUN mkdir -p /app/wwwroot/uploads \
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user