diff --git a/src/Oms.Application/Dtos/Orders/OrderChangeProductInput.cs b/src/Oms.Application/Dtos/Orders/OrderChangeProductInput.cs new file mode 100644 index 0000000..cbcce32 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderChangeProductInput.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 修改订单销售产品入参。 +/// +public class OrderChangeProductInput +{ + /// + /// 订单主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 新销售产品编码。 + /// + [Required] + [StringLength(64)] + public string ProductCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs index f7a61ca..332da0d 100644 --- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs +++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs @@ -364,6 +364,60 @@ public class PlatformStoreOrderMatchInventoryOutput public string? ClientNo { get; set; } } +/// +/// 店铺订单创建运单时填写的运单基本信息(对齐下单页基本信息)。 +/// +public class PlatformStoreOrderParcelBasicInput +{ + /// 客户单号;空则沿用平台订单号。 + public string? ClientNo { get; set; } + + /// 货物属性:general / electronics / sensitive。 + public string? CargoAttribute { get; set; } + + /// 实重(kg)。 + public decimal? ActualWeight { get; set; } + + /// 包裹数量。 + public int? Quantity { get; set; } + + /// 长(cm)。 + public decimal? Length { get; set; } + + /// 宽(cm)。 + public decimal? Width { get; set; } + + /// 高(cm)。 + public decimal? Height { get; set; } + + /// 出口报关方式。 + public string? ExportCustomsMode { get; set; } + + /// 出口资质备案 Id(9610Summary 时必填)。 + public int? ExportQualificationId { get; set; } + + /// 电商平台编码(9610Summary 时必填)。 + public string? PlatformCode { get; set; } + + /// 到仓方式:selfShip / doorPickup。 + public string? InboundMethod { get; set; } + + /// 上门揽收发件地址簿 Id。 + public int? SenderAddressId { get; set; } + + /// 期望揽收时间。 + public DateTime? ExpectPickupTime { get; set; } + + /// 揽收方向。 + public string? PickupDirection { get; set; } + + /// 承运商编码。 + public string? CarrierCode { get; set; } + + /// 承运服务。 + public string? CarrierService { get; set; } +} + public class PlatformStoreOrderBatchCreateLogisticsInput { [Required] @@ -373,6 +427,9 @@ public class PlatformStoreOrderBatchCreateLogisticsInput public int? CustomerId { get; set; } + /// 运单基本信息(创建运单/代发运单必填)。 + public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; } + [Required] public List Items { get; set; } = []; } @@ -387,6 +444,9 @@ public class PlatformStoreOrderBatchCreateFulfillInput public int? CustomerId { get; set; } + /// 运单基本信息(创建运单/代发运单必填)。 + public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; } + [Required] public List Items { get; set; } = []; } @@ -396,6 +456,9 @@ public class PlatformStoreOrderBatchCreateItemInput [Required] public int StoreOrderId { get; set; } + /// 覆盖本单客户单号;空则用 ParcelBasic.ClientNo 或平台订单号。 + public string? ClientNo { get; set; } + public OrderParcelReceiverInput? Receiver { get; set; } public List BindItems { get; set; } = []; diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs index ed1c63d..47b6cff 100644 --- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs +++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs @@ -258,6 +258,7 @@ public class WarehouseInboundSaveInput [Required] public int WarehouseId { get; set; } + /// 唛头/快递单号,选填。 public string? MarkReference { get; set; } public string InboundType { get; set; } = "normal"; @@ -345,6 +346,77 @@ public class WarehouseInboundSummaryOutput public int Completed { get; set; } } +/// +/// 打印库内 SKU 标签入参。 +/// +public class WarehouseSkuPrintLabelInput +{ + /// + /// SKU ID。 + /// + [Required] + public int Id { get; set; } + + /// + /// 客户 ID(管理端可选)。 + /// + public int? CustomerId { get; set; } +} + +/// +/// 打印库内 SKU 标签出参。 +/// +public class WarehouseSkuPrintLabelOutput +{ + /// + /// 可打印的 PDF 公网地址。 + /// + public string PrintUrl { get; set; } = string.Empty; + + /// + /// SKU 编码。 + /// + public string SkuCode { get; set; } = string.Empty; +} + +/// +/// 打印入库箱唛标签入参。 +/// +public class WarehouseInboundPrintLabelsInput +{ + /// + /// 入库预报 ID。 + /// + [Required] + public int Id { get; set; } + + /// + /// 客户 ID(管理端可选)。 + /// + public int? CustomerId { get; set; } +} + +/// +/// 打印入库箱唛标签出参(多箱合并为一个多页 PDF)。 +/// +public class WarehouseInboundPrintLabelsOutput +{ + /// + /// 可打印的 PDF 公网地址。 + /// + public string PrintUrl { get; set; } = string.Empty; + + /// + /// 预报单号。 + /// + public string NoticeNo { get; set; } = string.Empty; + + /// + /// PDF 页数(通常等于箱数)。 + /// + public int PageCount { get; set; } +} + public class WarehouseParcelSummaryOutput { public int InTransit { get; set; } diff --git a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs index 15132db..7d265a6 100644 --- a/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs +++ b/src/Oms.Application/Labeling/Hiprint/QuestPdfLabelRenderer.cs @@ -14,31 +14,51 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel public Task RenderAsync( string templateJson, IReadOnlyDictionary printData, + CancellationToken cancellationToken = default) => + RenderManyAsync(templateJson, [printData], cancellationToken); + + public Task RenderManyAsync( + string templateJson, + IReadOnlyList> printDataPages, CancellationToken cancellationToken = default) { EnsureLicense(); cancellationToken.ThrowIfCancellationRequested(); - var pageIr = parser.Parse(templateJson, printData); - var pageWidthPt = MmToPt(pageIr.WidthMm); - var pageHeightPt = MmToPt(pageIr.HeightMm); + if (printDataPages == null || printDataPages.Count == 0) + { + throw new ArgumentException("printDataPages 不能为空", nameof(printDataPages)); + } + + if (string.IsNullOrWhiteSpace(templateJson)) + { + throw new ArgumentException("templateJson 不能为空", nameof(templateJson)); + } var bytes = Document.Create(container => { - container.Page(page => + for (var i = 0; i < printDataPages.Count; i++) { - page.Size(pageWidthPt, pageHeightPt); - page.Margin(0); - page.Content().Layers(layers => - { - layers.PrimaryLayer().Background(Colors.White); + cancellationToken.ThrowIfCancellationRequested(); + var pageIr = parser.Parse(templateJson, printDataPages[i]); + var pageWidthPt = MmToPt(pageIr.WidthMm); + var pageHeightPt = MmToPt(pageIr.HeightMm); - foreach (var op in pageIr.Ops) + container.Page(page => + { + page.Size(pageWidthPt, pageHeightPt); + page.Margin(0); + page.Content().Layers(layers => { - DrawOp(layers, op); - } + layers.PrimaryLayer().Background(Colors.White); + + foreach (var op in pageIr.Ops) + { + DrawOp(layers, op); + } + }); }); - }); + } }).GeneratePdf(); return Task.FromResult(bytes); diff --git a/src/Oms.Application/Labeling/ILabelPdfRenderer.cs b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs index 5b60aef..0817aeb 100644 --- a/src/Oms.Application/Labeling/ILabelPdfRenderer.cs +++ b/src/Oms.Application/Labeling/ILabelPdfRenderer.cs @@ -5,8 +5,19 @@ namespace Oms.Application.Labeling; /// public interface ILabelPdfRenderer { + /// + /// 渲染单页 PDF。 + /// Task RenderAsync( string templateJson, IReadOnlyDictionary printData, CancellationToken cancellationToken = default); + + /// + /// 同一模板按多组 PrintData 渲染多页,合并为单个 PDF。 + /// + Task RenderManyAsync( + string templateJson, + IReadOnlyList> printDataPages, + CancellationToken cancellationToken = default); } diff --git a/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs new file mode 100644 index 0000000..31e3d23 --- /dev/null +++ b/src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs @@ -0,0 +1,157 @@ +using System.Globalization; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Labeling; + +/// +/// 入库箱唛 PrintData(camelCase,与 hiprint 字段对齐;箱唛为主、SKU 为辅)。 +/// +public static class InboundBoxLabelPrintDataBuilder +{ + public static IReadOnlyDictionary Build( + CInboundNotice notice, + CInboundBox box, + IReadOnlyList lines, + IReadOnlyDictionary skuMap, + string? warehouseName = null, + string? customerName = null, + string? customerCode = null, + string? operatorName = null, + DateTime? printTime = null, + int pageIndex = 1, + int pageCount = 1) + { + var barcode = ResolveBarcode(notice.NoticeNo, box); + var skuLines = lines + .OrderBy(x => x.Id) + .Select(line => + { + skuMap.TryGetValue(line.SkuId, out var sku); + var skuCode = FirstNonEmpty(line.SkuCode, sku?.SkuCode) ?? string.Empty; + var skuName = sku?.SkuName ?? string.Empty; + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skuCode"] = skuCode, + ["skuName"] = skuName, + ["qty"] = line.PlannedQty + }; + }) + .ToList(); + + var first = skuLines.FirstOrDefault(); + var skuSummary = string.Join( + "\n", + skuLines.Select(x => + { + var code = Convert.ToString(x.GetValueOrDefault("skuCode")) ?? string.Empty; + var name = Convert.ToString(x.GetValueOrDefault("skuName")) ?? string.Empty; + var qty = Convert.ToString(x.GetValueOrDefault("qty")) ?? "0"; + return string.IsNullOrWhiteSpace(name) + ? $"{code}×{qty}" + : $"{code} {name}×{qty}"; + })); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["noticeNo"] = notice.NoticeNo, + ["markReference"] = notice.MarkReference, + ["inboundType"] = notice.InboundType, + ["receiptStatus"] = notice.ReceiptStatus, + ["remark"] = notice.Remark, + + ["boxId"] = box.Id, + ["boxSeqNo"] = box.BoxSeqNo, + ["markBoxNo"] = box.MarkBoxNo, + ["barcode"] = barcode, + + ["warehouseName"] = warehouseName, + ["customerCode"] = customerCode, + ["customerName"] = customerName, + + ["weightG"] = box.WeightG, + ["lengthCm"] = box.LengthCm, + ["widthCm"] = box.WidthCm, + ["heightCm"] = box.HeightCm, + ["weightUnit"] = "G", + ["dimUnit"] = "CM", + + ["skuSummary"] = skuSummary, + ["skuCode"] = first?.GetValueOrDefault("skuCode"), + ["skuName"] = first?.GetValueOrDefault("skuName"), + ["skuQty"] = first?.GetValueOrDefault("qty"), + ["items"] = skuLines, + + ["pageIndex"] = pageIndex, + ["pageCount"] = pageCount, + ["boxIndexText"] = $"{pageIndex}/{pageCount}", + + ["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = operatorName + }; + } + + /// + /// 设计预览用 Mock PrintData。 + /// + public static IReadOnlyDictionary BuildMock() => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["noticeNo"] = "IN202610220001", + ["markReference"] = "SF1234567890", + ["inboundType"] = "normal", + ["receiptStatus"] = "forecasted", + ["remark"] = null, + ["boxId"] = 1, + ["boxSeqNo"] = "1", + ["markBoxNo"] = "BOX-001", + ["barcode"] = "BOX-001", + ["warehouseName"] = "SZ-01", + ["customerCode"] = "AF0A1", + ["customerName"] = "Demo Customer", + ["weightG"] = 1200m, + ["lengthCm"] = 30m, + ["widthCm"] = 20m, + ["heightCm"] = 15m, + ["weightUnit"] = "G", + ["dimUnit"] = "CM", + ["skuSummary"] = "SKU001 T-Shirt×2\nSKU002 Pants×1", + ["skuCode"] = "SKU001", + ["skuName"] = "T-Shirt", + ["skuQty"] = 2, + ["items"] = new List> + { + new() + { + ["skuCode"] = "SKU001", + ["skuName"] = "T-Shirt", + ["qty"] = 2 + }, + new() + { + ["skuCode"] = "SKU002", + ["skuName"] = "Pants", + ["qty"] = 1 + } + }, + ["pageIndex"] = 1, + ["pageCount"] = 2, + ["boxIndexText"] = "1/2", + ["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = "preview" + }; + + public static string ResolveBarcode(string noticeNo, CInboundBox box) + { + if (!string.IsNullOrWhiteSpace(box.MarkBoxNo)) + { + return box.MarkBoxNo.Trim(); + } + + var seq = string.IsNullOrWhiteSpace(box.BoxSeqNo) ? "1" : box.BoxSeqNo.Trim(); + var no = string.IsNullOrWhiteSpace(noticeNo) ? "IN" : noticeNo.Trim(); + return $"{no}-{seq}"; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); +} diff --git a/src/Oms.Application/Labeling/LabelFieldDictionary.cs b/src/Oms.Application/Labeling/LabelFieldDictionary.cs index ba0f6fd..410535c 100644 --- a/src/Oms.Application/Labeling/LabelFieldDictionary.cs +++ b/src/Oms.Application/Labeling/LabelFieldDictionary.cs @@ -76,13 +76,59 @@ public static class LabelFieldDictionary ] }, new LabelFieldGroup + { + Code = "inboundBox", + Title = "入库箱唛", + Fields = + [ + Field("noticeNo", "预报单号"), + Field("markReference", "唛头参考"), + Field("markBoxNo", "箱唛号"), + Field("boxSeqNo", "箱序号"), + Field("barcode", "条码内容"), + Field("boxIndexText", "箱序页码"), + Field("skuSummary", "SKU 摘要"), + Field("skuCode", "首 SKU 编码"), + Field("skuName", "首 SKU 名称"), + Field("skuQty", "首 SKU 数量"), + Field("weightG", "箱重量(g)"), + Field("lengthCm", "箱长(cm)"), + Field("widthCm", "箱宽(cm)"), + Field("heightCm", "箱高(cm)") + ] + }, + new LabelFieldGroup + { + Code = "warehouseSku", + Title = "库内 SKU", + Fields = + [ + Field("skuCode", "SKU 编码"), + Field("skuName", "SKU 名称"), + Field("skuNameEn", "SKU 英文名"), + Field("barcode", "条码内容"), + Field("unitPrice", "单价"), + Field("exHsCode", "出口海关编码"), + Field("imHsCode", "进口海关编码"), + Field("declareElement", "申报要素"), + Field("weightG", "重量(g)"), + Field("lengthCm", "长(cm)"), + Field("widthCm", "宽(cm)"), + Field("heightCm", "高(cm)"), + Field("dimensions", "尺寸摘要"), + Field("remark", "备注") + ] + }, + new LabelFieldGroup { Code = "system", Title = "系统", Fields = [ Field("printTime", "打印时间"), - Field("operatorName", "操作人") + Field("operatorName", "操作人"), + Field("pageIndex", "页码"), + Field("pageCount", "总页数") ] } ]; diff --git a/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs b/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs new file mode 100644 index 0000000..30b459c --- /dev/null +++ b/src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Labeling; + +/// +/// 库内 SKU 标签 PrintData(camelCase,与 hiprint 字段对齐)。 +/// +public static class SkuLabelPrintDataBuilder +{ + public static IReadOnlyDictionary Build( + CSku sku, + string? customerName = null, + string? customerCode = null, + string? warehouseName = null, + string? operatorName = null, + DateTime? printTime = null) + { + var barcode = string.IsNullOrWhiteSpace(sku.SkuCode) ? $"SKU-{sku.Id}" : sku.SkuCode.Trim(); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skuId"] = sku.Id, + ["skuCode"] = sku.SkuCode, + ["skuName"] = sku.SkuName, + ["skuNameEn"] = sku.SkuNameEn, + ["barcode"] = barcode, + ["skuImageUrl"] = sku.SkuImageUrl, + ["unitPrice"] = sku.UnitPrice, + ["exHsCode"] = sku.ExHsCode, + ["imHsCode"] = sku.ImHsCode, + ["declareElement"] = sku.DeclareElement, + ["weightG"] = sku.WeightG, + ["lengthCm"] = sku.LengthCm, + ["widthCm"] = sku.WidthCm, + ["heightCm"] = sku.HeightCm, + ["dimensions"] = FormatDimensions(sku.LengthCm, sku.WidthCm, sku.HeightCm), + ["remark"] = sku.Remark, + ["weightUnit"] = "G", + ["dimUnit"] = "CM", + ["customerCode"] = customerCode, + ["customerName"] = customerName, + ["warehouseName"] = warehouseName, + ["printTime"] = (printTime ?? DateTime.Now).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = operatorName + }; + } + + /// + /// 设计预览用 Mock PrintData。 + /// + public static IReadOnlyDictionary BuildMock() => + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["skuId"] = 1, + ["skuCode"] = "SKU001", + ["skuName"] = "T-Shirt", + ["skuNameEn"] = "T-Shirt", + ["barcode"] = "SKU001", + ["skuImageUrl"] = null, + ["unitPrice"] = 9.9m, + ["exHsCode"] = "610910", + ["imHsCode"] = "610910", + ["declareElement"] = "棉质 T 恤", + ["weightG"] = 200m, + ["lengthCm"] = 30m, + ["widthCm"] = 20m, + ["heightCm"] = 2m, + ["dimensions"] = "30×20×2", + ["remark"] = null, + ["weightUnit"] = "G", + ["dimUnit"] = "CM", + ["customerCode"] = "AF0A1", + ["customerName"] = "Demo Customer", + ["warehouseName"] = "SZ-01", + ["printTime"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + ["operatorName"] = "preview" + }; + + private static string? FormatDimensions(decimal? length, decimal? width, decimal? height) + { + if (length is null && width is null && height is null) + { + return null; + } + + return $"{length ?? 0}×{width ?? 0}×{height ?? 0}"; + } +} diff --git a/src/Oms.Application/Services/Channels/LabelTemplateService.cs b/src/Oms.Application/Services/Channels/LabelTemplateService.cs index b1ce432..93a1566 100644 --- a/src/Oms.Application/Services/Channels/LabelTemplateService.cs +++ b/src/Oms.Application/Services/Channels/LabelTemplateService.cs @@ -4,6 +4,7 @@ using Oms.Application.Dtos.Channels; using Oms.Application.Dtos.Shared; using Oms.Application.Labeling; using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Entities; using Oms.Domain.ChannelsAggregate.Repositories; @@ -81,10 +82,12 @@ public class LabelTemplateService : ApplicationService public async Task PreviewPdfAsync([FromBody] LabelTemplatePreviewInput input) { string? templateJson = null; + string? templateType = null; if (input.TemplateId is > 0) { var entity = await GetEntityOrThrowAsync(input.TemplateId.Value); templateJson = entity.TemplateJson; + templateType = entity.TemplateType; } else { @@ -104,6 +107,14 @@ public class LabelTemplateService : ApplicationService x => x.Value, StringComparer.OrdinalIgnoreCase); } + else if (string.Equals(templateType, LabelTemplateTypeCodes.Box, StringComparison.OrdinalIgnoreCase)) + { + printData = InboundBoxLabelPrintDataBuilder.BuildMock(); + } + else if (string.Equals(templateType, LabelTemplateTypeCodes.Sku, StringComparison.OrdinalIgnoreCase)) + { + printData = SkuLabelPrintDataBuilder.BuildMock(); + } var bytes = await LabelPdfGenerationService.PreviewAsync(templateJson, printData); return new FileContentResult(bytes, "application/pdf") diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs index 2fad81d..b840f5a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -24,6 +24,7 @@ public partial class OrderService /// /// 编辑订单详情(基本信息、发件人、收件人)。 + /// 跟踪号变更时同步调用轨迹 Upsert(不走 Outbox)。 /// [HttpPost("/order/update")] public async Task UpdateOrderDetailAsync(OrderUpdateInput input) @@ -158,12 +159,145 @@ public partial class OrderService if (input.TrackNo != null) { - await EnqueueSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo); + await SyncSmallBagTrackUpsertWhenTrackNoChangedAsync(order, previousTrackNo); } return (await MapOrderOutputsAsync([order])).First(); } + /// + /// 修改订单销售产品:未付款且草稿/已预报/待排货/待支付可改; + /// 有计费重时按新产品排货池自动重排货并重算应收/应付。 + /// + /// 入参:订单 ID、新销售产品编码。 + /// 更新后的订单详情。 + [HttpPost("/order/change-product")] + public async Task ChangeProductAsync(OrderChangeProductInput input) + { + var newProductCode = input.ProductCode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(newProductCode)) + { + throw new BusinessException("销售产品编码不能为空"); + } + + var order = await GetOrderOrThrowAsync(input.Id); + EnsureChangeProductAllowed(order); + + var oldProductCode = order.ProductCode?.Trim() ?? string.Empty; + if (string.Equals(oldProductCode, newProductCode, StringComparison.OrdinalIgnoreCase)) + { + return (await MapOrderOutputsAsync([order])).First(); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(newProductCode); + if (product == null || product.IsActive != EnableStatus.Enabled) + { + throw new BusinessException("销售产品不存在或未启用"); + } + + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + + order.ProductCode = product.ProductCode; + order.ChannelCode = null; + order.LabelTemplateId = null; + order.AllocationPendingReason = null; + order.AllocationTraceJson = null; + order.AllocationTraceTime = null; + order.AllocationScheme = null; + order.DownstreamSupplierCode = null; + order.DownstreamSupplierName = null; + order.DownstreamAppKey = null; + order.DownstreamAppId = null; + order.ChannelForecastCode = null; + + var selectedServices = NormalizeAdditionalServiceCodes( + OmsDocHelper.DeserializeJson>(order.AdditionalServices)); + var allowed = ParseAdditionalServiceCodes(product.AdditionalServices); + var kept = selectedServices.Where(code => allowed.Contains(code)).ToList(); + order.AdditionalServices = SerializeAdditionalServices(kept); + + var snapshotBinding = await ResolveSnapshotBindingAsync(product, DateTime.Now); + order.SalesProductRulesVersion = snapshotBinding.RulesVersion; + order.SalesProductPriceVersion = snapshotBinding.PriceVersion; + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + var hasChargeableWeight = TryGetPayableChargeableWeight(order, weights, out _); + if (hasChargeableWeight) + { + var isDraft = string.Equals( + order.OrderStatus, + OrderStatusCodes.Draft, + StringComparison.OrdinalIgnoreCase); + // 草稿不在「重新排货」闸门内;有计费重时仍按新产品默认方案确认渠道并重写应付。 + await ReallocateCoreAsync( + order, + scheme: AllocationSchemeCodes.CostLowest, + serviceChannelId: null, + enforceReallocateGate: !isDraft); + order = await GetOrderOrThrowAsync(order.Id); + EnsureReceivableMutable(order); + await RecalculateReceivableCoreAsync(order); + } + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true); + var remark = string.IsNullOrWhiteSpace(oldProductCode) + ? $"销售产品 → {product.ProductCode}" + : $"销售产品 {oldProductCode} → {product.ProductCode}"; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.ChangeProduct, + beforeSnap, + afterSnap, + remark); + + return (await MapOrderOutputsAsync([order])).First(); + } + + private static void EnsureChangeProductAllowed(Order order) + { + if (order.IsPaid != 0) + { + throw new BusinessException("订单已付款,不能修改销售产品"); + } + + if (order.OrderStatus is not ( + OrderStatusCodes.Draft + or OrderStatusCodes.Predeclared + or OrderStatusCodes.PendingAllocation + or OrderStatusCodes.PendingPayment)) + { + throw new BusinessException( + $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus ?? string.Empty)}」,仅草稿/已预报/待排货/待支付可改销售产品"); + } + } + + private static bool TryGetPayableChargeableWeight( + Order order, + IReadOnlyList weights, + out decimal chargeable) + { + chargeable = 0; + try + { + var row = ResolvePayableWeightRow(order, weights); + if (!row.ChargeableWeight.HasValue || row.ChargeableWeight <= 0) + { + return false; + } + + chargeable = row.ChargeableWeight.Value; + return true; + } + catch (BusinessException) + { + return false; + } + } + /// /// 编辑订单件重尺并重算计费重。 /// diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index abaa68f..a364dbf 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -174,11 +174,11 @@ public partial class OrderService // 4. 保存草稿:不做预报计费。9610、上门揽收与订单一起提交。 if (input.SaveAsDraft) { - await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent - { - OrderId = order.Id, - PlatformCode = input.PlatformCode - }); + await RunParcelCreateSideEffectsAsync( + order, + input, + operatorId, + includeForecastAndLabel: false); var draftOutput = await BuildParcelCreateOutputAsync(order); await TryAttachPickupAfterParcelCreateAsync(input, draftOutput); return draftOutput; @@ -199,21 +199,13 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - // 6. 与订单同一事务写入 Outbox。Commit 后由 Fake 投递;任一条失败则整单回滚。 + // 6. 有事务 UoW 时写 Outbox;无事务(店铺订单创建运单等)则同步执行,避免 Outbox 拒发。 // 轨迹不在此列:要等预报消费端排货拿到 ChannelCode 再发。 - var authorization = CaptureRequestAuthorization(); - await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent - { - OrderId = order.Id, - PlatformCode = input.PlatformCode - }); - await EnqueueForecastBillingAsync(order, input.CouponInstanceId, operatorId, authorization); - await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent - { - OrderId = order.Id, - OperatorUserId = operatorId - }); - await AutoTriggerRuntimeService.EnqueueFireAsync(order.Id, AutoTriggerTiming.OrderCreate, operatorId); + await RunParcelCreateSideEffectsAsync( + order, + input, + operatorId, + includeForecastAndLabel: true); var output = await BuildParcelCreateOutputAsync(order); output.ForecastBillingQueued = string.Equals( @@ -225,6 +217,87 @@ public partial class OrderService return output; } + /// + /// 创单后置:9610 / 预报计费 / 面单 / 自动触发。 + /// 环境有事务 UoW 时走 Outbox;否则同步执行(店铺订单创建运单无 [UnitOfWork])。 + /// + private async Task RunParcelCreateSideEffectsAsync( + Order order, + OrderParcelCreateInput input, + int operatorId, + bool includeForecastAndLabel) + { + var uowManager = LazyServiceProvider.GetRequiredService(); + var useOutbox = OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager); + var authorization = CaptureRequestAuthorization(); + + if (useOutbox) + { + await EventBus.PublishAsync(new OrderParcelCustoms9610UpsertIntegrationEvent + { + OrderId = order.Id, + PlatformCode = input.PlatformCode + }); + if (includeForecastAndLabel) + { + await EnqueueForecastBillingAsync(order, input.CouponInstanceId, operatorId, authorization); + await EventBus.PublishAsync(new OrderParcelLabelGenerateIntegrationEvent + { + OrderId = order.Id, + OperatorUserId = operatorId + }); + await AutoTriggerRuntimeService.EnqueueFireAsync( + order.Id, + AutoTriggerTiming.OrderCreate, + operatorId); + } + + return; + } + + await TryUpsertCustoms9610Async(order, input.PlatformCode); + if (!includeForecastAndLabel) + { + return; + } + + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = input.CouponInstanceId, + Authorization = authorization + }); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorId, + authorization: authorization); + + try + { + var labelResult = await GenerateLabelPdfForBatchAsync(order.Id); + if (!labelResult.Success) + { + LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.ParcelCreate") + .LogWarning( + "Label generate failed OrderId={OrderId} Error={Error}", + order.Id, + labelResult.Error); + } + } + catch (Exception ex) + { + LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.ParcelCreate") + .LogWarning(ex, "Label generate failed OrderId={OrderId}", order.Id); + } + + await AutoTriggerRuntimeService.FireByOrderIdAsync( + order.Id, + AutoTriggerTiming.OrderCreate, + operatorId); + } + private async Task TryUpsertCustoms9610Async(Order order, string? platformCode = null) { try @@ -3234,6 +3307,30 @@ public partial class OrderService await EnqueueSmallBagTrackUpsertAsync(order, customer: null, receiver: null, appendInitialTrack); } + /// + /// 跟踪号变更时同步 Upsert 轨迹(编辑订单详情用,避免依赖 Outbox/事务 UoW)。 + /// 失败仅记日志,不阻断订单保存。 + /// + private async Task SyncSmallBagTrackUpsertWhenTrackNoChangedAsync( + Order order, + string? previousTrackNo, + bool appendInitialTrack = true) + { + var next = order.TrackNo?.Trim() ?? string.Empty; + var prev = previousTrackNo?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(next) + || string.Equals(next, prev, StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(order.ServiceNo)) + { + return; + } + + var request = await BuildSmallBagTrackUpsertRequestAsync(order); + request.AppendInitialTrackOnSuccess = appendInitialTrack; + ApplyTrackAuthorization(request); + await SmallBagTrackOrderClient.UpsertAsync(request); + } + private async Task EnqueueLifecycleTrackAppendAsync(Order order, string trackCode) { if (string.IsNullOrWhiteSpace(order.ServiceNo)) diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 61eaddd..d4e4090 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -14,6 +14,7 @@ using Oms.Application.Labeling; using Oms.Application.Shared; using Oms.Application.Storage; using Newtonsoft.Json; +using Oms.Application.Messaging; using Oms.Application.Messaging.ParcelCreate; using Oms.Application.Services.Orders.Batch; using Oms.Domain.ChannelsAggregate.Repositories; @@ -722,16 +723,37 @@ public partial class OrderService : ApplicationService try { - await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent + var uowManager = LazyServiceProvider.GetRequiredService(); + if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) + { + await EventBus.PublishAsync(new OrderParcelPickupAttachIntegrationEvent + { + OrderId = output.OrderId, + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId.Value, + WarehouseId = input.WarehouseId.Value, + PickupDirection = input.PickupDirection, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime.Value + }); + output.PickupQueued = true; + return; + } + + // 无事务 UoW(店铺订单创建运单)时同步挂接揽收,避免 Outbox 拒发 + var pickupService = LazyServiceProvider.GetRequiredService(); + await pickupService.CreateForParcelInternalAsync(new PickupOrderCreateInput { - OrderId = output.OrderId, CustomerId = input.CustomerId, SenderAddressId = input.SenderAddressId.Value, + Direction = PickupDirectionCodes.NormalizeOrDefault(input.PickupDirection), WarehouseId = input.WarehouseId.Value, - PickupDirection = input.PickupDirection, CarrierCode = input.CarrierCode, CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime.Value + ExpectPickupTime = input.ExpectPickupTime.Value, + OrderIds = [output.OrderId], + Remark = null }); output.PickupQueued = true; } diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs index 244ad2f..1e460e5 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Fake.Application; using Oms.Application.Dtos.Orders; +using Oms.Application.Dtos.PlatformStore; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.SystemAggregate.Entities; @@ -59,6 +60,105 @@ public static class PlatformStoreOrderParcelBuilder }; } + /// + /// 将下单页同款「运单基本信息」覆盖到组装好的小包入参。 + /// + public static void ApplyParcelBasic( + OrderParcelCreateInput parcelInput, + PlatformStoreOrderParcelBasicInput? basic, + string? clientNoOverride = null) + { + if (!string.IsNullOrWhiteSpace(clientNoOverride)) + { + parcelInput.ClientNo = clientNoOverride.Trim(); + } + else if (!string.IsNullOrWhiteSpace(basic?.ClientNo)) + { + parcelInput.ClientNo = basic.ClientNo.Trim(); + } + + if (basic == null) + { + return; + } + + if (!string.IsNullOrWhiteSpace(basic.CargoAttribute)) + { + parcelInput.CargoAttribute = basic.CargoAttribute.Trim(); + } + + if (basic.ActualWeight is > 0) + { + parcelInput.ActualWeight = basic.ActualWeight; + parcelInput.ChargeableWeight = basic.ActualWeight; + } + + if (basic.Quantity is > 0) + { + parcelInput.Quantity = basic.Quantity; + } + + if (basic.Length is > 0) + { + parcelInput.Length = basic.Length; + } + + if (basic.Width is > 0) + { + parcelInput.Width = basic.Width; + } + + if (basic.Height is > 0) + { + parcelInput.Height = basic.Height; + } + + if (!string.IsNullOrWhiteSpace(basic.ExportCustomsMode)) + { + parcelInput.ExportCustomsMode = basic.ExportCustomsMode.Trim(); + } + + if (basic.ExportQualificationId is > 0) + { + parcelInput.ExportQualificationId = basic.ExportQualificationId; + } + + if (!string.IsNullOrWhiteSpace(basic.PlatformCode)) + { + parcelInput.PlatformCode = basic.PlatformCode.Trim(); + } + + if (!string.IsNullOrWhiteSpace(basic.InboundMethod)) + { + parcelInput.InboundMethod = basic.InboundMethod.Trim(); + } + + if (basic.SenderAddressId is > 0) + { + parcelInput.SenderAddressId = basic.SenderAddressId; + } + + if (basic.ExpectPickupTime.HasValue && basic.ExpectPickupTime != default) + { + parcelInput.ExpectPickupTime = basic.ExpectPickupTime; + } + + if (!string.IsNullOrWhiteSpace(basic.PickupDirection)) + { + parcelInput.PickupDirection = basic.PickupDirection.Trim(); + } + + if (!string.IsNullOrWhiteSpace(basic.CarrierCode)) + { + parcelInput.CarrierCode = basic.CarrierCode.Trim(); + } + + if (!string.IsNullOrWhiteSpace(basic.CarrierService)) + { + parcelInput.CarrierService = basic.CarrierService.Trim(); + } + } + /// /// 按平台商品行组装小包入参(不要求库内 SKU 映射;用于创建物流 / 物流&代发)。 /// diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs index 2f99043..a6ed453 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs @@ -411,7 +411,8 @@ public class PlatformStoreOrderService : ApplicationService input.WarehouseId, input.Items, requireBind: false, - requireWmsProductGroup: false); + requireWmsProductGroup: false, + parcelBasic: input.ParcelBasic); } /// @@ -432,7 +433,8 @@ public class PlatformStoreOrderService : ApplicationService input.WarehouseId, input.Items, requireBind: true, - requireWmsProductGroup: false); + requireWmsProductGroup: false, + parcelBasic: input.ParcelBasic); } /// @@ -454,7 +456,8 @@ public class PlatformStoreOrderService : ApplicationService input.WarehouseId, input.Items, requireBind: true, - requireWmsProductGroup: true); + requireWmsProductGroup: true, + parcelBasic: input.ParcelBasic); } private async Task BatchCreateLogisticsInternalAsync( @@ -463,7 +466,8 @@ public class PlatformStoreOrderService : ApplicationService int? warehouseId, List items, bool requireBind, - bool requireWmsProductGroup = false) + bool requireWmsProductGroup = false, + PlatformStoreOrderParcelBasicInput? parcelBasic = null) { if (items == null || items.Count == 0) { @@ -512,7 +516,9 @@ public class PlatformStoreOrderService : ApplicationService warehouseId, resolvedProductCode, itemInput.Receiver, - requireBind ? itemInput.BindItems : null); + requireBind ? itemInput.BindItems : null, + parcelBasic, + itemInput.ClientNo); item.Success = true; item.Message = requireWmsProductGroup ? $"已创建代发订单 {result.ServiceNo} 并绑定" @@ -548,7 +554,9 @@ public class PlatformStoreOrderService : ApplicationService int? warehouseId, string productCode, OrderParcelReceiverInput? receiver, - List? bindItems) + List? bindItems, + PlatformStoreOrderParcelBasicInput? parcelBasic = null, + string? clientNoOverride = null) { var order = await GetOrderOrThrowAsync(customerId, storeOrderId); await EnsureCanCreateLogisticsAsync(order); @@ -572,6 +580,11 @@ public class PlatformStoreOrderService : ApplicationService throw new BusinessException("请指定发货仓库"); } + if (parcelBasic != null && resolvedWarehouseId is not > 0) + { + throw new BusinessException("请选择仓库"); + } + var parcelInput = PlatformStoreOrderParcelBuilder.BuildFromPlatformLines( order, lines, @@ -580,6 +593,7 @@ public class PlatformStoreOrderService : ApplicationService resolvedWarehouseId, customerId, receiver); + PlatformStoreOrderParcelBuilder.ApplyParcelBasic(parcelInput, parcelBasic, clientNoOverride); var parcelOutput = await OrderService.CreateParcelForBatchAsync( parcelInput, diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs index f4ce3cf..4c58871 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs @@ -3,7 +3,10 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; +using Oms.Application.Labeling; using Oms.Application.Shared; +using Oms.Application.Storage; +using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.WarehouseAggregate.Consts; using Oms.Domain.WarehouseAggregate.Entities; @@ -152,7 +155,7 @@ public partial class WarehouseService } /// - /// 保存入库预报(新增或编辑)。 + /// 保存入库预报(新增或编辑)。编辑仅允许「预报」「待入库」状态;唛头(MarkReference)选填。 /// /// 入参:入库预报保存数据。 /// 保存后的入库预报详情。 @@ -174,9 +177,10 @@ public partial class WarehouseService if (input.Id > 0) { notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id); - if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted) + if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted + && notice.ReceiptStatus != WarehouseReceiptStatusCodes.Received) { - throw new BusinessException("仅预报状态可编辑"); + throw new BusinessException("仅预报或待入库状态可编辑"); } } else @@ -346,6 +350,83 @@ public partial class WarehouseService await InboundNoticeRepository.UpdateAsync(notice); } + /// + /// 打印入库箱唛标签:按箱渲染后合并为一个多页 PDF,返回可打印 URL。 + /// + /// 入参:入库预报 ID(管理端可带 customerId)。 + /// printUrl / noticeNo / pageCount。 + [HttpPost("/warehouse/inbound/print-labels")] + public async Task PrintInboundLabelsAsync( + WarehouseInboundPrintLabelsInput input) + { + if (input.Id <= 0) + { + throw new BusinessException("入库预报 ID 无效"); + } + + var customerId = await ResolveListCustomerIdAsync(input.CustomerId); + var notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id); + var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id); + boxes = boxes.OrderBy(x => x.BoxSeqNo).ThenBy(x => x.Id).ToList(); + if (boxes.Count == 0) + { + throw new BusinessException("该预报没有箱子,无法打印箱唛"); + } + + var boxType = LabelTemplateTypeCodes.Box; + var templates = await LabelTemplateRepository.GetListAsync(x => x.TemplateType == boxType); + var template = templates + .Where(x => !string.IsNullOrWhiteSpace(x.TemplateJson)) + .OrderByDescending(x => x.UpdateTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + if (template == null) + { + throw new BusinessException("未配置箱唛标签模板(类型 box),请先在标签设计中创建"); + } + + var boxIds = boxes.Select(x => x.Id).ToArray(); + var lines = await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId)); + var skuIds = lines.Select(x => x.SkuId).Distinct().ToArray(); + var skus = skuIds.Length == 0 + ? [] + : await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id)); + var skuMap = skus.ToDictionary(x => x.Id); + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == notice.WarehouseId); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == notice.CustomerId); + var operatorName = CurrentUser?.UserName; + + var pageCount = boxes.Count; + var printPages = new List>(pageCount); + for (var i = 0; i < boxes.Count; i++) + { + var box = boxes[i]; + var boxLines = lines.Where(x => x.InboundBoxId == box.Id).ToList(); + printPages.Add(InboundBoxLabelPrintDataBuilder.Build( + notice, + box, + boxLines, + skuMap, + warehouseName: warehouse?.WarehouseName, + customerName: customer?.CustomerName, + customerCode: customer?.CustomerCode, + operatorName: operatorName, + pageIndex: i + 1, + pageCount: pageCount)); + } + + var pdfBytes = await LabelPdfRenderer.RenderManyAsync(template.TemplateJson!, printPages); + var printUrl = await SaveInboundLabelPdfAsync(notice.NoticeNo, pdfBytes); + + return new WarehouseInboundPrintLabelsOutput + { + PrintUrl = printUrl, + NoticeNo = notice.NoticeNo, + PageCount = pageCount + }; + } + /// /// 查询入库预报状态汇总。 /// @@ -384,6 +465,21 @@ public partial class WarehouseService return entity; } + private async Task SaveInboundLabelPdfAsync(string noticeNo, byte[] pdfBytes) + { + var safeNo = string.IsNullOrWhiteSpace(noticeNo) ? "inbound" : noticeNo.Trim(); + foreach (var c in Path.GetInvalidFileNameChars()) + { + safeNo = safeNo.Replace(c, '_'); + } + + var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; + var relativeKey = $"uploads/prints/inbound-labels/{fileName}"; + await using var stream = new MemoryStream(pdfBytes); + var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf"); + return FileStorage.GetPublicUrl(key); + } + private async Task ClearInboundBoxesAsync(int noticeId) { var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == noticeId); diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.ParcelArrival.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.ParcelArrival.cs index 91d24be..04cfc64 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.ParcelArrival.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.ParcelArrival.cs @@ -7,7 +7,6 @@ using Oms.Application.Shared; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; -using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.WarehouseAggregate.Consts; using Oms.Domain.WarehouseAggregate.Entities; using Oms.Domain.WarehouseAggregate.Repositories; @@ -19,9 +18,6 @@ public partial class WarehouseService private ICParcelArrivalRepository ParcelArrivalRepository => LazyServiceProvider.GetRequiredService(); - private IWarehouseAddressRepository WarehouseAddressRepository => - LazyServiceProvider.GetRequiredService(); - private OmsRbacUserDisplayNameResolver UserDisplayNameResolver => LazyServiceProvider.GetRequiredService(); diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs index c0b0eaa..991a6d0 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.Warehouse; using Fake.ObjectStorage; +using Oms.Application.Labeling; using Oms.Application.Shared; using Oms.Application.Storage; +using Oms.Domain.ChannelsAggregate.Consts; namespace Oms.Application.Services.Warehouse; @@ -174,6 +176,66 @@ public partial class WarehouseService await SkuRepository.DeleteAsync(entity); } + /// + /// 打印库内 SKU 标签:使用 TemplateType=sku 模板生成 PDF,返回可打印 URL。 + /// + /// 入参:SKU ID(管理端可带 customerId)。 + /// printUrl / skuCode。 + [HttpPost("/warehouse/sku/print-label")] + public async Task PrintSkuLabelAsync(WarehouseSkuPrintLabelInput input) + { + if (input.Id <= 0) + { + throw new BusinessException("SKU ID 无效"); + } + + var customerId = await ResolveListCustomerIdAsync(input.CustomerId); + var sku = await GetSkuOrThrowAsync(customerId, input.Id); + + var skuType = LabelTemplateTypeCodes.Sku; + var templates = await LabelTemplateRepository.GetListAsync(x => x.TemplateType == skuType); + var template = templates + .Where(x => !string.IsNullOrWhiteSpace(x.TemplateJson)) + .OrderByDescending(x => x.UpdateTime) + .ThenByDescending(x => x.Id) + .FirstOrDefault(); + if (template == null) + { + throw new BusinessException("未配置 SKU 标签模板(类型 sku),请先在标签设计中创建"); + } + + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == sku.CustomerId); + var printData = SkuLabelPrintDataBuilder.Build( + sku, + customerName: customer?.CustomerName, + customerCode: customer?.CustomerCode, + operatorName: CurrentUser?.UserName); + + var pdfBytes = await LabelPdfRenderer.RenderAsync(template.TemplateJson!, printData); + var printUrl = await SaveSkuLabelPdfAsync(sku.SkuCode, pdfBytes); + + return new WarehouseSkuPrintLabelOutput + { + PrintUrl = printUrl, + SkuCode = sku.SkuCode + }; + } + + private async Task SaveSkuLabelPdfAsync(string skuCode, byte[] pdfBytes) + { + var safeCode = string.IsNullOrWhiteSpace(skuCode) ? "sku" : skuCode.Trim(); + foreach (var c in Path.GetInvalidFileNameChars()) + { + safeCode = safeCode.Replace(c, '_'); + } + + var fileName = $"{safeCode}-{DateTime.Now:yyyyMMddHHmmss}.pdf"; + var relativeKey = $"uploads/prints/sku-labels/{fileName}"; + await using var stream = new MemoryStream(pdfBytes); + var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf"); + return FileStorage.GetPublicUrl(key); + } + /// /// 上传 SKU 图片,返回可访问相对路径。 /// diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.cs index 58096c7..f969e65 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.cs @@ -1,6 +1,9 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; +using Oms.Application.Labeling; +using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.SystemAggregate.Repositories; using Oms.Domain.WarehouseAggregate.Repositories; namespace Oms.Application.Services.Warehouse; @@ -21,4 +24,9 @@ public partial class WarehouseService : ApplicationService private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService(); private ICParcelItemRepository ParcelItemRepository => LazyServiceProvider.GetRequiredService(); private ICSkuPlatformMapRepository SkuPlatformMapRepository => LazyServiceProvider.GetRequiredService(); + private IWarehouseAddressRepository WarehouseAddressRepository => + LazyServiceProvider.GetRequiredService(); + private ILabelTemplateRepository LabelTemplateRepository => + LazyServiceProvider.GetRequiredService(); + private ILabelPdfRenderer LabelPdfRenderer => LazyServiceProvider.GetRequiredService(); } diff --git a/src/Oms.Domain/ChannelsAggregate/Consts/LabelTemplateTypeCodes.cs b/src/Oms.Domain/ChannelsAggregate/Consts/LabelTemplateTypeCodes.cs new file mode 100644 index 0000000..3c8abdf --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Consts/LabelTemplateTypeCodes.cs @@ -0,0 +1,25 @@ +namespace Oms.Domain.ChannelsAggregate.Consts; + +/// +/// 标签模板类型(t_label_template.TemplateType)。 +/// +public static class LabelTemplateTypeCodes +{ + /// 尾程面单。 + public const string LastMile = "lastMile"; + + /// 客户标签。 + public const string Customer = "customer"; + + /// 入库标签。 + public const string Inbound = "inbound"; + + /// 出库标签。 + public const string Outbound = "outbound"; + + /// SKU 标签。 + public const string Sku = "sku"; + + /// 箱唛标签(入库预报按箱打印)。 + public const string Box = "box"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index 104d465..b164f5d 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -4,6 +4,7 @@ public static class OrderOperationActionCodes { public const string EditBasic = "editBasic"; public const string EditWeight = "editWeight"; + public const string ChangeProduct = "changeProduct"; public const string RecalcReceivable = "recalcReceivable"; public const string RecalcPayable = "recalcPayable"; public const string Reallocate = "reallocate"; @@ -21,6 +22,7 @@ public static class OrderOperationActionCodes { EditBasic => "编辑订单详情", EditWeight => "修改件重尺", + ChangeProduct => "修改销售产品", RecalcReceivable => "重算应收", RecalcPayable => "重算应付", Reallocate => "重新排货", diff --git a/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs index e201f8d..27bf8f4 100644 --- a/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs +++ b/src/Oms.Tests/Labeling/QuestPdfLabelRendererTests.cs @@ -26,4 +26,34 @@ public class QuestPdfLabelRendererTests Encoding.ASCII.GetString(bytes.AsSpan(0, 4)).Should().Be("%PDF"); bytes.Length.Should().BeGreaterThan(500); } + + [Fact] + public async Task RenderManyAsync_TwoPages_ShouldProducePdfBytes() + { + var json = HiprintTemplateParserTests.LoadFixture("sample_100x150.json"); + var pages = new List> + { + new Dictionary + { + ["serviceNo"] = "S1", + ["recipientName"] = "A", + ["recipientAddressFull"] = "Addr A", + ["actualWeight"] = 1 + }, + new Dictionary + { + ["serviceNo"] = "S2", + ["recipientName"] = "B", + ["recipientAddressFull"] = "Addr B", + ["actualWeight"] = 2 + } + }; + + var renderer = new QuestPdfLabelRenderer(new HiprintTemplateParser()); + var bytes = await renderer.RenderManyAsync(json, pages); + + bytes.Should().NotBeNullOrEmpty(); + Encoding.ASCII.GetString(bytes.AsSpan(0, 4)).Should().Be("%PDF"); + bytes.Length.Should().BeGreaterThan(800); + } }