Merge pull request 'fix: 运单列表 enrichment 改为顺序查询,避免 MySqlConnection 并发复用' (#13) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 2m2s

Reviewed-on: #13
This commit is contained in:
zzh 2026-10-24 13:36:05 +08:00
commit ae78834d7c
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',
`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)',

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.Text.Json.Serialization;
namespace Oms.Application.Dtos.Orders;
@ -68,21 +69,24 @@ public class OrderParcelItemInput
public string? DeclareElement { get; set; }
/// <summary>
/// 产品标识编码(选填)。
/// 产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? ProductId { get; set; }
/// <summary>
/// 非标产品标识编码(选填)。
/// 非标产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? NonStdProductId { get; set; }
/// <summary>
/// 标准产品标识编码(选填)。
/// 标准产品标识编码(选填)。预报下游始终序列化,无值时为 null。
/// </summary>
[StringLength(100)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? StdProductId { get; set; }
/// <summary>

View File

@ -47,6 +47,21 @@ public class WarehouseSkuOutput
/// </summary>
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? LengthCm { get; set; }
@ -91,6 +106,21 @@ public class WarehouseSkuSaveInput
/// </summary>
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? 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")]
public string? Title { get; set; }
/// <summary>设计器「测试数据」;无 field 的纯文本有时只把文案写在这里。</summary>
[JsonPropertyName("testData")]
public string? TestData { get; set; }
[JsonPropertyName("field")]
public string? Field { get; set; }

View File

@ -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<string, object?> 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,

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)
{
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;

View File

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

View File

@ -85,6 +85,7 @@ public static class LabelFieldDictionary
Field("markReference", "唛头参考"),
Field("markBoxNo", "箱唛号"),
Field("boxSeqNo", "箱序号"),
Field("boxCount", "箱子总数"),
Field("barcode", "条码内容"),
Field("boxIndexText", "箱序页码"),
Field("skuSummary", "SKU 摘要"),

View File

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

View File

@ -41,6 +41,13 @@
<ItemGroup>
<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>
</Project>

View File

@ -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;
}
/// <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
{
public string BindStatus { get; init; } = "none";

View File

@ -70,7 +70,11 @@ public partial class OrderService : ApplicationService
pageIndex,
pageSize,
filter);
return new OmsPageResult<OrderOutput> { Items = await MapOrderOutputsAsync(orders), Total = total };
return new OmsPageResult<OrderOutput>
{
Items = await MapOrderListOutputsAsync(orders),
Total = total,
};
}
/// <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 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,
});
}

View File

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

View File

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

View File

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

View File

@ -70,6 +70,13 @@ public interface IOrderRepository : IRepository<Order>
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(
int pageIndex,
int pageSize,

View File

@ -27,6 +27,21 @@ public class CSku : OmsAuditedAggregateRoot
/// </summary>
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? LengthCm { get; set; }

View File

@ -23,7 +23,9 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
var query = ApplyListFilter(db.Queryable<Order>(), 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<Order>, IOrderRepository, I
.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(
int pageIndex,
int pageSize,

View File

@ -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<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]
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>();
}
[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)
{
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
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 \