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 \