fix: 运单列表 enrichment 改为顺序查询,避免 MySqlConnection 并发复用 #13

Merged
zzh merged 7 commits from test into master 2026-10-24 13:36:05 +08:00
28 changed files with 891 additions and 33 deletions

3
db.sql
View File

@ -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)',

View 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;

View File

@ -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>

View File

@ -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; }

Binary file not shown.

View File

@ -0,0 +1,5 @@
# 面单 PDF 中文字体
`OmsLabelCjk.ttf`:随程序嵌入,供 QuestPDF 渲染箱唛/SKU 中文(避免默认字体缺字形显示 `?`/`□`)。
部署镜像无需再依赖系统 Noto;仍可通过环境变量 `OMS_LABEL_FONT` 覆盖为其它 ttf/otf。

View File

@ -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; }

View File

@ -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,

View 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);
}

View File

@ -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;

View File

@ -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",

View File

@ -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 摘要"),

View File

@ -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,

View File

@ -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>

View File

@ -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";

View File

@ -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>

View File

@ -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,
}); });
} }

View File

@ -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,

View File

@ -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();
} }

View File

@ -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>

View File

@ -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,

View File

@ -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; }

View File

@ -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,

View File

@ -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()
{ {

View 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));
}
}

View File

@ -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)!;

View 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\"");
}
}

View File

@ -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 \