diff --git a/db.sql b/db.sql
index 198f297..c8867a0 100644
--- a/db.sql
+++ b/db.sql
@@ -1413,6 +1413,9 @@ CREATE TABLE `c_sku` (
`ExHsCode` VARCHAR(64) NULL COMMENT '出口HSCode',
`ImHsCode` VARCHAR(64) NULL COMMENT '进口HSCode',
`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)',
`LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)',
`WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)',
diff --git a/sql/migrate_c_sku_product_ids_20261023.sql b/sql/migrate_c_sku_product_ids_20261023.sql
new file mode 100644
index 0000000..ff50289
--- /dev/null
+++ b/sql/migrate_c_sku_product_ids_20261023.sql
@@ -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;
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
index ed71218..6ce63c7 100644
--- a/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelItemInput.cs
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace Oms.Application.Dtos.Orders;
@@ -68,21 +69,24 @@ public class OrderParcelItemInput
public string? DeclareElement { get; set; }
///
- /// 产品标识编码(选填)。
+ /// 产品标识编码(选填)。预报下游始终序列化,无值时为 null。
///
[StringLength(100)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? ProductId { get; set; }
///
- /// 非标产品标识编码(选填)。
+ /// 非标产品标识编码(选填)。预报下游始终序列化,无值时为 null。
///
[StringLength(100)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? NonStdProductId { get; set; }
///
- /// 标准产品标识编码(选填)。
+ /// 标准产品标识编码(选填)。预报下游始终序列化,无值时为 null。
///
[StringLength(100)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? StdProductId { get; set; }
///
diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
index 47b6cff..630cb9a 100644
--- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
+++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs
@@ -47,6 +47,21 @@ public class WarehouseSkuOutput
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码。
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码。
+ ///
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码(如 GTIN / UPC / EAN)。
+ ///
+ public string? StdProductId { get; set; }
+
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
@@ -91,6 +106,21 @@ public class WarehouseSkuSaveInput
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码。
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码。
+ ///
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码(如 GTIN / UPC / EAN)。
+ ///
+ public string? StdProductId { get; set; }
+
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
diff --git a/src/Oms.Application/Fonts/OmsLabelCjk.ttf b/src/Oms.Application/Fonts/OmsLabelCjk.ttf
new file mode 100644
index 0000000..1f1bdf8
Binary files /dev/null and b/src/Oms.Application/Fonts/OmsLabelCjk.ttf differ
diff --git a/src/Oms.Application/Fonts/README.md b/src/Oms.Application/Fonts/README.md
new file mode 100644
index 0000000..3059493
--- /dev/null
+++ b/src/Oms.Application/Fonts/README.md
@@ -0,0 +1,5 @@
+# 面单 PDF 中文字体
+
+`OmsLabelCjk.ttf`:随程序嵌入,供 QuestPDF 渲染箱唛/SKU 中文(避免默认字体缺字形显示 `?`/`□`)。
+
+部署镜像无需再依赖系统 Noto;仍可通过环境变量 `OMS_LABEL_FONT` 覆盖为其它 ttf/otf。
diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
index 50c401b..1411b1d 100644
--- a/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
+++ b/src/Oms.Application/Labeling/Hiprint/HiprintModels.cs
@@ -63,6 +63,10 @@ public sealed class HiprintElementOptions
[JsonPropertyName("title")]
public string? Title { get; set; }
+ /// 设计器「测试数据」;无 field 的纯文本有时只把文案写在这里。
+ [JsonPropertyName("testData")]
+ public string? TestData { get; set; }
+
[JsonPropertyName("field")]
public string? Field { get; set; }
diff --git a/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
index a913556..db27950 100644
--- a/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
+++ b/src/Oms.Application/Labeling/Hiprint/HiprintTemplateParser.cs
@@ -105,14 +105,15 @@ public sealed class HiprintTemplateParser
Src = ResolveValue(opt.Field, opt.Src ?? opt.Title, printData)
};
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":
default:
{
var textType = opt.TextType?.Trim().ToLowerInvariant();
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))
{
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 height,
IReadOnlyDictionary printData,
- bool wrap)
+ bool wrap,
+ string? elementTypeTitle = null)
{
var value = ResolveValue(opt.Field, null, printData);
+ // 与 vg-print 一致:无 field 时打印 title;有 field 时按 hideTitle 决定是否带标题
+ var title = FirstNonEmpty(opt.Title, elementTypeTitle);
string text;
if (!string.IsNullOrWhiteSpace(opt.Field))
{
- // 有 field:默认只打字段值;hideTitle=false 且有 title 时拼「标题:值」
- if (opt.HideTitle == false && !string.IsNullOrWhiteSpace(opt.Title) && !string.IsNullOrWhiteSpace(value))
+ var hideTitle = opt.HideTitle != false; // null/true → 隐藏标题(OMS 字段默认 hideTitle:true)
+ 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
{
- text = value ?? string.Empty;
+ text = string.Empty;
}
}
else
{
- text = opt.Title ?? string.Empty;
+ // 纯自定义文本:title → testData → 元素类型名
+ text = FirstNonEmpty(opt.Title, opt.TestData, elementTypeTitle) ?? string.Empty;
}
if (string.IsNullOrWhiteSpace(text))
@@ -169,15 +184,18 @@ public sealed class HiprintTemplateParser
var bold = string.Equals(opt.FontWeight, "bold", StringComparison.OrdinalIgnoreCase)
|| 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
{
LeftPt = left,
TopPt = top,
WidthPt = width,
- HeightPt = height,
+ HeightPt = boxHeight,
Text = text,
- FontSizePt = opt.FontSize is > 0 ? opt.FontSize.Value : 10f,
+ FontSizePt = fontSize,
Bold = bold,
ColorHex = opt.Color,
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(
string? field,
string? fallback,
diff --git a/src/Oms.Application/Labeling/Hiprint/LabelPdfFontRegistrar.cs b/src/Oms.Application/Labeling/Hiprint/LabelPdfFontRegistrar.cs
new file mode 100644
index 0000000..4003af5
--- /dev/null
+++ b/src/Oms.Application/Labeling/Hiprint/LabelPdfFontRegistrar.cs
@@ -0,0 +1,120 @@
+using System.Reflection;
+using QuestPDF.Drawing;
+
+namespace Oms.Application.Labeling.Hiprint;
+
+///
+/// 面单 PDF 中文字体:QuestPDF 默认字体无 CJK;优先嵌入字体,避免依赖容器内 TTC。
+///
+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;
+
+ ///
+ /// 确保已注册可用中文字体;成功返回 ,否则 null。
+ ///
+ 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 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 Open);
+}
diff --git a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
index 7d265a6..061a574 100644
--- a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
+++ b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs
@@ -23,6 +23,9 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
CancellationToken cancellationToken = default)
{
EnsureLicense();
+ var fontFamily = LabelPdfFontRegistrar.EnsureRegistered()
+ ?? throw new InvalidOperationException(
+ "面单中文字体未就绪:" + (LabelPdfFontRegistrar.LastError ?? "未找到可用 CJK 字体"));
cancellationToken.ThrowIfCancellationRequested();
if (printDataPages == null || printDataPages.Count == 0)
@@ -48,13 +51,15 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
{
page.Size(pageWidthPt, pageHeightPt);
page.Margin(0);
+ // 页级默认中文字体;单元素再设字号/颜色。勿对嵌入 Regular 调 Bold(会回退缺字西文字体 → ???)
+ page.DefaultTextStyle(x => x.FontFamily(fontFamily).FontColor(Colors.Black));
page.Content().Layers(layers =>
{
layers.PrimaryLayer().Background(Colors.White);
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);
}
- private static void DrawOp(LayersDescriptor layers, LabelDrawOp op)
+ private static void DrawOp(LayersDescriptor layers, LabelDrawOp op, string fontFamily)
{
switch (op)
{
@@ -84,16 +89,16 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
_ => box.AlignLeft()
};
- var span = aligned
- .AlignTop()
- .Text(text.Text)
- .FontSize(text.FontSizePt)
- .FontColor(ParseColor(text.ColorHex));
-
- if (text.Bold)
+ aligned.AlignTop().Text(t =>
{
- 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;
}
@@ -160,6 +165,8 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
}
QuestPDF.Settings.License = LicenseType.Community;
+ // 缺字形时不抛,但仍应依赖嵌入 CJK 字体
+ QuestPDF.Settings.CheckIfAllTextGlyphsAreAvailable = false;
}
private static float MmToPt(float mm) => mm * 72f / 25.4f;
diff --git a/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs
index 31e3d23..c55b6a2 100644
--- a/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs
+++ b/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs
@@ -61,6 +61,7 @@ public static class InboundBoxLabelPrintDataBuilder
["boxId"] = box.Id,
["boxSeqNo"] = box.BoxSeqNo,
+ ["boxCount"] = pageCount,
["markBoxNo"] = box.MarkBoxNo,
["barcode"] = barcode,
@@ -103,6 +104,7 @@ public static class InboundBoxLabelPrintDataBuilder
["remark"] = null,
["boxId"] = 1,
["boxSeqNo"] = "1",
+ ["boxCount"] = 2,
["markBoxNo"] = "BOX-001",
["barcode"] = "BOX-001",
["warehouseName"] = "SZ-01",
diff --git a/src/Oms.Application/Labeling/LabelFieldDictionary.cs b/src/Oms.Application/Labeling/LabelFieldDictionary.cs
index 410535c..58b9f1c 100644
--- a/src/Oms.Application/Labeling/LabelFieldDictionary.cs
+++ b/src/Oms.Application/Labeling/LabelFieldDictionary.cs
@@ -85,6 +85,7 @@ public static class LabelFieldDictionary
Field("markReference", "唛头参考"),
Field("markBoxNo", "箱唛号"),
Field("boxSeqNo", "箱序号"),
+ Field("boxCount", "箱子总数"),
Field("barcode", "条码内容"),
Field("boxIndexText", "箱序页码"),
Field("skuSummary", "SKU 摘要"),
diff --git a/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs
index 30b459c..4086a85 100644
--- a/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs
+++ b/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs
@@ -29,6 +29,9 @@ public static class SkuLabelPrintDataBuilder
["exHsCode"] = sku.ExHsCode,
["imHsCode"] = sku.ImHsCode,
["declareElement"] = sku.DeclareElement,
+ ["productId"] = sku.ProductId,
+ ["nonStdProductId"] = sku.NonStdProductId,
+ ["stdProductId"] = sku.StdProductId,
["weightG"] = sku.WeightG,
["lengthCm"] = sku.LengthCm,
["widthCm"] = sku.WidthCm,
@@ -61,6 +64,9 @@ public static class SkuLabelPrintDataBuilder
["exHsCode"] = "610910",
["imHsCode"] = "610910",
["declareElement"] = "棉质 T 恤",
+ ["productId"] = null,
+ ["nonStdProductId"] = null,
+ ["stdProductId"] = "06901234567890",
["weightG"] = 200m,
["lengthCm"] = 30m,
["widthCm"] = 20m,
diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj
index 63f8eff..35e7187 100644
--- a/src/Oms.Application/Oms.Application.csproj
+++ b/src/Oms.Application/Oms.Application.csproj
@@ -41,6 +41,13 @@
+
+
+
+
+
+
+
diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
index d61441a..c64ecff 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs
@@ -16,6 +16,7 @@ using Oms.Application.Storage;
using Oms.Domain.OrdersAggregate;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
+using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.SystemAggregate.Entities;
@@ -296,6 +297,272 @@ public partial class OrderService
return outputs;
}
+ ///
+ /// 列表页专用映射:不加载申报明细/费用明细,批量 enrichment,减小 JSON 与 DB 开销。
+ ///
+ private async Task> MapOrderListOutputsAsync(IReadOnlyCollection 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>(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
{
public string BindStatus { get; init; } = "none";
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index d4e4090..068e567 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -70,7 +70,11 @@ public partial class OrderService : ApplicationService
pageIndex,
pageSize,
filter);
- return new OmsPageResult { Items = await MapOrderOutputsAsync(orders), Total = total };
+ return new OmsPageResult
+ {
+ Items = await MapOrderListOutputsAsync(orders),
+ Total = total,
+ };
}
///
diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
index 1e460e5..6530da0 100644
--- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
+++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs
@@ -267,7 +267,9 @@ public static class PlatformStoreOrderParcelBuilder
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 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
{
@@ -279,7 +281,13 @@ public static class PlatformStoreOrderParcelBuilder
UnitWeight = unitWeightKg,
Quantity = 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,
});
}
diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
index 991a6d0..599c21b 100644
--- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
+++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs
@@ -139,6 +139,11 @@ public partial class WarehouseService
entity.ExHsCode = string.IsNullOrWhiteSpace(input.ExHsCode) ? null : input.ExHsCode.Trim();
entity.ImHsCode = string.IsNullOrWhiteSpace(input.ImHsCode) ? null : input.ImHsCode.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.LengthCm = input.LengthCm;
entity.WidthCm = input.WidthCm;
@@ -309,6 +314,9 @@ public partial class WarehouseService
ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode,
DeclareElement = entity.DeclareElement,
+ ProductId = entity.ProductId,
+ NonStdProductId = entity.NonStdProductId,
+ StdProductId = entity.StdProductId,
WeightG = entity.WeightG,
LengthCm = entity.LengthCm,
WidthCm = entity.WidthCm,
@@ -331,6 +339,9 @@ public partial class WarehouseService
ExHsCode = entity.ExHsCode,
ImHsCode = entity.ImHsCode,
DeclareElement = entity.DeclareElement,
+ ProductId = entity.ProductId,
+ NonStdProductId = entity.NonStdProductId,
+ StdProductId = entity.StdProductId,
WeightG = entity.WeightG,
LengthCm = entity.LengthCm,
WidthCm = entity.WidthCm,
diff --git a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
index 501a2a9..89f17ac 100644
--- a/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
+++ b/src/Oms.Application/Shared/OrderDownstreamCreateOneBodyBuilder.cs
@@ -201,9 +201,10 @@ public static class OrderDownstreamCreateOneBodyBuilder
ImHsCode = x.ImHsCode,
ExHsCode = x.ExHsCode ?? x.HsCode,
DeclareElement = x.DeclareElement,
- ProductId = x.ProductId,
- NonStdProductId = x.NonStdProductId,
- StdProductId = x.StdProductId,
+ // 始终赋值;空白→null。配合 OrderParcelItemInput 上 JsonIgnore(Never),请求体固定出现 "xxx": null。
+ ProductId = NullIfWhiteSpace(x.ProductId),
+ NonStdProductId = NullIfWhiteSpace(x.NonStdProductId),
+ StdProductId = NullIfWhiteSpace(x.StdProductId),
SkuImage = fileStorage?.GetPublicUrl(x.SkuImage) ?? x.SkuImage,
Category = x.Category,
Brand = x.Brand,
@@ -220,4 +221,7 @@ public static class OrderDownstreamCreateOneBodyBuilder
Specifications = x.Specifications,
Comment = x.Comment
};
+
+ private static string? NullIfWhiteSpace(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
diff --git a/src/Oms.Application/Storage/FileStoragePath.cs b/src/Oms.Application/Storage/FileStoragePath.cs
index a9af070..6a17bbe 100644
--- a/src/Oms.Application/Storage/FileStoragePath.cs
+++ b/src/Oms.Application/Storage/FileStoragePath.cs
@@ -28,7 +28,32 @@ public static class FileStoragePath
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);
+ }
+
+ ///
+ /// 去掉公开 URL 上的 PathBase(可重复的 admin/),保留 uploads/... 等逻辑 key。
+ ///
+ 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;
}
///
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
index c5d8ab0..1e1397d 100644
--- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
@@ -70,6 +70,13 @@ public interface IOrderRepository : IRepository
Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default);
+ ///
+ /// 列表页:按订单聚合应收金额合计(不加载费用明细行)。
+ ///
+ Task> GetReceivableSumByOrderIdsAsync(
+ IEnumerable orderIds,
+ CancellationToken cancellationToken = default);
+
Task<(List Items, int Total)> GetReceivablePayablePagedListAsync(
int pageIndex,
int pageSize,
diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs
index 7c7559b..1124b02 100644
--- a/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs
+++ b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs
@@ -27,6 +27,21 @@ public class CSku : OmsAuditedAggregateRoot
///
public string? DeclareElement { get; set; }
+ ///
+ /// 产品标识编码。
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 非标产品标识编码。
+ ///
+ public string? NonStdProductId { get; set; }
+
+ ///
+ /// 标准产品标识编码(如 GTIN / UPC / EAN)。
+ ///
+ public string? StdProductId { get; set; }
+
public decimal? WeightG { get; set; }
public decimal? LengthCm { get; set; }
diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
index ad4e985..9819fbd 100644
--- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
+++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
@@ -23,7 +23,9 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I
var query = ApplyListFilter(db.Queryable(), filter);
var total = await query.CountAsync(cancellationToken);
query = ApplyListSort(query, filter);
+ // 列表仅需 HasAllocationTrace 布尔语义,勿拉取大字段 AllocationTraceJson
var items = await query
+ .IgnoreColumns(x => x.AllocationTraceJson)
.ToPageListAsync(pageIndex, pageSize, cancellationToken);
return (items, total);
}
@@ -1104,6 +1106,34 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I
.ToListAsync(cancellationToken);
}
+ ///
+ /// 列表页:按订单聚合应收金额合计(不加载费用明细行)。
+ ///
+ public async Task> GetReceivableSumByOrderIdsAsync(
+ IEnumerable orderIds,
+ CancellationToken cancellationToken = default)
+ {
+ var ids = orderIds.Distinct().ToArray();
+ if (ids.Length == 0)
+ {
+ return new Dictionary();
+ }
+
+ var db = await GetDbContextAsync(cancellationToken);
+ var rows = await db.Queryable()
+ .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 Items, int Total)> GetReceivablePayablePagedListAsync(
int pageIndex,
int pageSize,
diff --git a/src/Oms.Tests/FileStoragePathTests.cs b/src/Oms.Tests/FileStoragePathTests.cs
index 9a26ce1..493c7d9 100644
--- a/src/Oms.Tests/FileStoragePathTests.cs
+++ b/src/Oms.Tests/FileStoragePathTests.cs
@@ -15,8 +15,9 @@ public class FileStoragePathTests
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("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
}
[Theory]
@@ -41,6 +42,47 @@ public class FileStoragePathTests
.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(MockBehavior.Strict);
+ storage
+ .Setup(x => x.GetUrlAsync(
+ "uploads/prints/orders/IDE2610231052073356-20261023105208.pdf",
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(fresh);
+
+ var url = await storage.Object.GetPublicUrlAsync(expired);
+
+ url.Should().Be(fresh);
+ storage.VerifyAll();
+ }
+
[Fact]
public void ToStorageKey_ShouldKeepExternalAbsoluteUrl()
{
diff --git a/src/Oms.Tests/LabelPdfFontTests.cs b/src/Oms.Tests/LabelPdfFontTests.cs
new file mode 100644
index 0000000..3dd87f6
--- /dev/null
+++ b/src/Oms.Tests/LabelPdfFontTests.cs
@@ -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 { ["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));
+ }
+}
diff --git a/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
index bcff18c..344dfbd 100644
--- a/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
+++ b/src/Oms.Tests/Labeling/HiprintTemplateParserTests.cs
@@ -49,6 +49,59 @@ public class HiprintTemplateParserTests
act.Should().Throw();
}
+ [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());
+ var text = page.Ops.OfType().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());
+ page.Ops.OfType().Should().ContainSingle(x => x.Text == "仅测试数据文案");
+ }
+
internal static string LoadFixture(string fileName)
{
var dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
diff --git a/src/Oms.Tests/OrderDownstreamCreateOneBodyBuilderTests.cs b/src/Oms.Tests/OrderDownstreamCreateOneBodyBuilderTests.cs
new file mode 100644
index 0000000..6593b18
--- /dev/null
+++ b/src/Oms.Tests/OrderDownstreamCreateOneBodyBuilderTests.cs
@@ -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
+ {
+ 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(MockBehavior.Loose).Object,
+ new Mock(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\"");
+ }
+}
diff --git a/src/SimpleAdmin.Api/Dockerfile b/src/SimpleAdmin.Api/Dockerfile
index 0d22ae1..d209edf 100644
--- a/src/SimpleAdmin.Api/Dockerfile
+++ b/src/SimpleAdmin.Api/Dockerfile
@@ -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
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 .
# Local 存储落盘 wwwroot;非 root 用户需可写 uploads(Provider=Cos 时可不依赖此目录)
RUN mkdir -p /app/wwwroot/uploads \