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/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs index ed1c63d..64968b0 100644 --- a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs +++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs @@ -345,6 +345,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 88a1ad6..b840f5a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -165,6 +165,139 @@ public partial class OrderService 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/Warehouse/WarehouseService.Inbound.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs index f4ce3cf..b1c1ced 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; @@ -346,6 +349,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 +464,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); + } }