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);
+ }
}