Merge pull request 'test' (#11) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 1m52s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m52s
Reviewed-on: #11
This commit is contained in:
commit
62d423ad4d
22
src/Oms.Application/Dtos/Orders/OrderChangeProductInput.cs
Normal file
22
src/Oms.Application/Dtos/Orders/OrderChangeProductInput.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 修改订单销售产品入参。
|
||||
/// </summary>
|
||||
public class OrderChangeProductInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单主键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 新销售产品编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string ProductCode { get; set; } = string.Empty;
|
||||
}
|
||||
@ -364,6 +364,60 @@ public class PlatformStoreOrderMatchInventoryOutput
|
||||
public string? ClientNo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 店铺订单创建运单时填写的运单基本信息(对齐下单页基本信息)。
|
||||
/// </summary>
|
||||
public class PlatformStoreOrderParcelBasicInput
|
||||
{
|
||||
/// <summary>客户单号;空则沿用平台订单号。</summary>
|
||||
public string? ClientNo { get; set; }
|
||||
|
||||
/// <summary>货物属性:general / electronics / sensitive。</summary>
|
||||
public string? CargoAttribute { get; set; }
|
||||
|
||||
/// <summary>实重(kg)。</summary>
|
||||
public decimal? ActualWeight { get; set; }
|
||||
|
||||
/// <summary>包裹数量。</summary>
|
||||
public int? Quantity { get; set; }
|
||||
|
||||
/// <summary>长(cm)。</summary>
|
||||
public decimal? Length { get; set; }
|
||||
|
||||
/// <summary>宽(cm)。</summary>
|
||||
public decimal? Width { get; set; }
|
||||
|
||||
/// <summary>高(cm)。</summary>
|
||||
public decimal? Height { get; set; }
|
||||
|
||||
/// <summary>出口报关方式。</summary>
|
||||
public string? ExportCustomsMode { get; set; }
|
||||
|
||||
/// <summary>出口资质备案 Id(9610Summary 时必填)。</summary>
|
||||
public int? ExportQualificationId { get; set; }
|
||||
|
||||
/// <summary>电商平台编码(9610Summary 时必填)。</summary>
|
||||
public string? PlatformCode { get; set; }
|
||||
|
||||
/// <summary>到仓方式:selfShip / doorPickup。</summary>
|
||||
public string? InboundMethod { get; set; }
|
||||
|
||||
/// <summary>上门揽收发件地址簿 Id。</summary>
|
||||
public int? SenderAddressId { get; set; }
|
||||
|
||||
/// <summary>期望揽收时间。</summary>
|
||||
public DateTime? ExpectPickupTime { get; set; }
|
||||
|
||||
/// <summary>揽收方向。</summary>
|
||||
public string? PickupDirection { get; set; }
|
||||
|
||||
/// <summary>承运商编码。</summary>
|
||||
public string? CarrierCode { get; set; }
|
||||
|
||||
/// <summary>承运服务。</summary>
|
||||
public string? CarrierService { get; set; }
|
||||
}
|
||||
|
||||
public class PlatformStoreOrderBatchCreateLogisticsInput
|
||||
{
|
||||
[Required]
|
||||
@ -373,6 +427,9 @@ public class PlatformStoreOrderBatchCreateLogisticsInput
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>运单基本信息(创建运单/代发运单必填)。</summary>
|
||||
public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; }
|
||||
|
||||
[Required]
|
||||
public List<PlatformStoreOrderBatchCreateItemInput> Items { get; set; } = [];
|
||||
}
|
||||
@ -387,6 +444,9 @@ public class PlatformStoreOrderBatchCreateFulfillInput
|
||||
|
||||
public int? CustomerId { get; set; }
|
||||
|
||||
/// <summary>运单基本信息(创建运单/代发运单必填)。</summary>
|
||||
public PlatformStoreOrderParcelBasicInput? ParcelBasic { get; set; }
|
||||
|
||||
[Required]
|
||||
public List<PlatformStoreOrderBatchCreateItemInput> Items { get; set; } = [];
|
||||
}
|
||||
@ -396,6 +456,9 @@ public class PlatformStoreOrderBatchCreateItemInput
|
||||
[Required]
|
||||
public int StoreOrderId { get; set; }
|
||||
|
||||
/// <summary>覆盖本单客户单号;空则用 ParcelBasic.ClientNo 或平台订单号。</summary>
|
||||
public string? ClientNo { get; set; }
|
||||
|
||||
public OrderParcelReceiverInput? Receiver { get; set; }
|
||||
|
||||
public List<OrderStockBindSaveItemInput> BindItems { get; set; } = [];
|
||||
|
||||
@ -258,6 +258,7 @@ public class WarehouseInboundSaveInput
|
||||
[Required]
|
||||
public int WarehouseId { get; set; }
|
||||
|
||||
/// <summary>唛头/快递单号,选填。</summary>
|
||||
public string? MarkReference { get; set; }
|
||||
|
||||
public string InboundType { get; set; } = "normal";
|
||||
@ -345,6 +346,77 @@ public class WarehouseInboundSummaryOutput
|
||||
public int Completed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印库内 SKU 标签入参。
|
||||
/// </summary>
|
||||
public class WarehouseSkuPrintLabelInput
|
||||
{
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户 ID(管理端可选)。
|
||||
/// </summary>
|
||||
public int? CustomerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印库内 SKU 标签出参。
|
||||
/// </summary>
|
||||
public class WarehouseSkuPrintLabelOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 可打印的 PDF 公网地址。
|
||||
/// </summary>
|
||||
public string PrintUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SKU 编码。
|
||||
/// </summary>
|
||||
public string SkuCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印入库箱唛标签入参。
|
||||
/// </summary>
|
||||
public class WarehouseInboundPrintLabelsInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入库预报 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户 ID(管理端可选)。
|
||||
/// </summary>
|
||||
public int? CustomerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印入库箱唛标签出参(多箱合并为一个多页 PDF)。
|
||||
/// </summary>
|
||||
public class WarehouseInboundPrintLabelsOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 可打印的 PDF 公网地址。
|
||||
/// </summary>
|
||||
public string PrintUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 预报单号。
|
||||
/// </summary>
|
||||
public string NoticeNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// PDF 页数(通常等于箱数)。
|
||||
/// </summary>
|
||||
public int PageCount { get; set; }
|
||||
}
|
||||
|
||||
public class WarehouseParcelSummaryOutput
|
||||
{
|
||||
public int InTransit { get; set; }
|
||||
|
||||
@ -14,31 +14,51 @@ public sealed class QuestPdfLabelRenderer(HiprintTemplateParser parser) : ILabel
|
||||
public Task<byte[]> RenderAsync(
|
||||
string templateJson,
|
||||
IReadOnlyDictionary<string, object?> printData,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
RenderManyAsync(templateJson, [printData], cancellationToken);
|
||||
|
||||
public Task<byte[]> RenderManyAsync(
|
||||
string templateJson,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> 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);
|
||||
|
||||
@ -5,8 +5,19 @@ namespace Oms.Application.Labeling;
|
||||
/// </summary>
|
||||
public interface ILabelPdfRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// 渲染单页 PDF。
|
||||
/// </summary>
|
||||
Task<byte[]> RenderAsync(
|
||||
string templateJson,
|
||||
IReadOnlyDictionary<string, object?> printData,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 同一模板按多组 PrintData 渲染多页,合并为单个 PDF。
|
||||
/// </summary>
|
||||
Task<byte[]> RenderManyAsync(
|
||||
string templateJson,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> printDataPages,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
157
src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs
Normal file
157
src/Oms.Application/Labeling/InboundBoxLabelPrintDataBuilder.cs
Normal file
@ -0,0 +1,157 @@
|
||||
using System.Globalization;
|
||||
using Oms.Domain.WarehouseAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Labeling;
|
||||
|
||||
/// <summary>
|
||||
/// 入库箱唛 PrintData(camelCase,与 hiprint 字段对齐;箱唛为主、SKU 为辅)。
|
||||
/// </summary>
|
||||
public static class InboundBoxLabelPrintDataBuilder
|
||||
{
|
||||
public static IReadOnlyDictionary<string, object?> Build(
|
||||
CInboundNotice notice,
|
||||
CInboundBox box,
|
||||
IReadOnlyList<CInboundBoxLine> lines,
|
||||
IReadOnlyDictionary<int, CSku> 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<string, object?>(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<string, object?>(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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设计预览用 Mock PrintData。
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, object?> BuildMock() =>
|
||||
new Dictionary<string, object?>(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<Dictionary<string, object?>>
|
||||
{
|
||||
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));
|
||||
}
|
||||
@ -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", "总页数")
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
88
src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs
Normal file
88
src/Oms.Application/Labeling/SkuLabelPrintDataBuilder.cs
Normal file
@ -0,0 +1,88 @@
|
||||
using System.Globalization;
|
||||
using Oms.Domain.WarehouseAggregate.Entities;
|
||||
|
||||
namespace Oms.Application.Labeling;
|
||||
|
||||
/// <summary>
|
||||
/// 库内 SKU 标签 PrintData(camelCase,与 hiprint 字段对齐)。
|
||||
/// </summary>
|
||||
public static class SkuLabelPrintDataBuilder
|
||||
{
|
||||
public static IReadOnlyDictionary<string, object?> 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<string, object?>(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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设计预览用 Mock PrintData。
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, object?> BuildMock() =>
|
||||
new Dictionary<string, object?>(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}";
|
||||
}
|
||||
}
|
||||
@ -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<FileContentResult> 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")
|
||||
|
||||
@ -24,6 +24,7 @@ public partial class OrderService
|
||||
|
||||
/// <summary>
|
||||
/// 编辑订单详情(基本信息、发件人、收件人)。
|
||||
/// 跟踪号变更时同步调用轨迹 Upsert(不走 Outbox)。
|
||||
/// </summary>
|
||||
[HttpPost("/order/update")]
|
||||
public async Task<OrderOutput> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改订单销售产品:未付款且草稿/已预报/待排货/待支付可改;
|
||||
/// 有计费重时按新产品排货池自动重排货并重算应收/应付。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:订单 ID、新销售产品编码。</param>
|
||||
/// <returns>更新后的订单详情。</returns>
|
||||
[HttpPost("/order/change-product")]
|
||||
public async Task<OrderOutput> 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<List<string>>(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<OrderWeight> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑订单件重尺并重算计费重。
|
||||
/// </summary>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创单后置:9610 / 预报计费 / 面单 / 自动触发。
|
||||
/// 环境有事务 UoW 时走 Outbox;否则同步执行(店铺订单创建运单无 [UnitOfWork])。
|
||||
/// </summary>
|
||||
private async Task RunParcelCreateSideEffectsAsync(
|
||||
Order order,
|
||||
OrderParcelCreateInput input,
|
||||
int operatorId,
|
||||
bool includeForecastAndLabel)
|
||||
{
|
||||
var uowManager = LazyServiceProvider.GetRequiredService<IUnitOfWorkManager>();
|
||||
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<ILoggerFactory>()
|
||||
.CreateLogger("Oms.ParcelCreate")
|
||||
.LogWarning(
|
||||
"Label generate failed OrderId={OrderId} Error={Error}",
|
||||
order.Id,
|
||||
labelResult.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LazyServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 跟踪号变更时同步 Upsert 轨迹(编辑订单详情用,避免依赖 Outbox/事务 UoW)。
|
||||
/// 失败仅记日志,不阻断订单保存。
|
||||
/// </summary>
|
||||
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))
|
||||
|
||||
@ -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<IUnitOfWorkManager>();
|
||||
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<PickupOrderService>();
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将下单页同款「运单基本信息」覆盖到组装好的小包入参。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按平台商品行组装小包入参(不要求库内 SKU 映射;用于创建物流 / 物流&代发)。
|
||||
/// </summary>
|
||||
|
||||
@ -411,7 +411,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
input.WarehouseId,
|
||||
input.Items,
|
||||
requireBind: false,
|
||||
requireWmsProductGroup: false);
|
||||
requireWmsProductGroup: false,
|
||||
parcelBasic: input.ParcelBasic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -432,7 +433,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
input.WarehouseId,
|
||||
input.Items,
|
||||
requireBind: true,
|
||||
requireWmsProductGroup: false);
|
||||
requireWmsProductGroup: false,
|
||||
parcelBasic: input.ParcelBasic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -454,7 +456,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
input.WarehouseId,
|
||||
input.Items,
|
||||
requireBind: true,
|
||||
requireWmsProductGroup: true);
|
||||
requireWmsProductGroup: true,
|
||||
parcelBasic: input.ParcelBasic);
|
||||
}
|
||||
|
||||
private async Task<PlatformStoreOrderBatchCreateLogisticsOutput> BatchCreateLogisticsInternalAsync(
|
||||
@ -463,7 +466,8 @@ public class PlatformStoreOrderService : ApplicationService
|
||||
int? warehouseId,
|
||||
List<PlatformStoreOrderBatchCreateItemInput> 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<OrderStockBindSaveItemInput>? bindItems)
|
||||
List<OrderStockBindSaveItemInput>? 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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存入库预报(新增或编辑)。
|
||||
/// 保存入库预报(新增或编辑)。编辑仅允许「预报」「待入库」状态;唛头(MarkReference)选填。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:入库预报保存数据。</param>
|
||||
/// <returns>保存后的入库预报详情。</returns>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印入库箱唛标签:按箱渲染后合并为一个多页 PDF,返回可打印 URL。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:入库预报 ID(管理端可带 customerId)。</param>
|
||||
/// <returns>printUrl / noticeNo / pageCount。</returns>
|
||||
[HttpPost("/warehouse/inbound/print-labels")]
|
||||
public async Task<WarehouseInboundPrintLabelsOutput> 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<IReadOnlyDictionary<string, object?>>(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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询入库预报状态汇总。
|
||||
/// </summary>
|
||||
@ -384,6 +465,21 @@ public partial class WarehouseService
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async Task<string> 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);
|
||||
|
||||
@ -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<ICParcelArrivalRepository>();
|
||||
|
||||
private IWarehouseAddressRepository WarehouseAddressRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
|
||||
|
||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打印库内 SKU 标签:使用 TemplateType=sku 模板生成 PDF,返回可打印 URL。
|
||||
/// </summary>
|
||||
/// <param name="input">入参:SKU ID(管理端可带 customerId)。</param>
|
||||
/// <returns>printUrl / skuCode。</returns>
|
||||
[HttpPost("/warehouse/sku/print-label")]
|
||||
public async Task<WarehouseSkuPrintLabelOutput> 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<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传 SKU 图片,返回可访问相对路径。
|
||||
/// </summary>
|
||||
|
||||
@ -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<ICParcelRepository>();
|
||||
private ICParcelItemRepository ParcelItemRepository => LazyServiceProvider.GetRequiredService<ICParcelItemRepository>();
|
||||
private ICSkuPlatformMapRepository SkuPlatformMapRepository => LazyServiceProvider.GetRequiredService<ICSkuPlatformMapRepository>();
|
||||
private IWarehouseAddressRepository WarehouseAddressRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
|
||||
private ILabelTemplateRepository LabelTemplateRepository =>
|
||||
LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
|
||||
private ILabelPdfRenderer LabelPdfRenderer => LazyServiceProvider.GetRequiredService<ILabelPdfRenderer>();
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
namespace Oms.Domain.ChannelsAggregate.Consts;
|
||||
|
||||
/// <summary>
|
||||
/// 标签模板类型(t_label_template.TemplateType)。
|
||||
/// </summary>
|
||||
public static class LabelTemplateTypeCodes
|
||||
{
|
||||
/// <summary>尾程面单。</summary>
|
||||
public const string LastMile = "lastMile";
|
||||
|
||||
/// <summary>客户标签。</summary>
|
||||
public const string Customer = "customer";
|
||||
|
||||
/// <summary>入库标签。</summary>
|
||||
public const string Inbound = "inbound";
|
||||
|
||||
/// <summary>出库标签。</summary>
|
||||
public const string Outbound = "outbound";
|
||||
|
||||
/// <summary>SKU 标签。</summary>
|
||||
public const string Sku = "sku";
|
||||
|
||||
/// <summary>箱唛标签(入库预报按箱打印)。</summary>
|
||||
public const string Box = "box";
|
||||
}
|
||||
@ -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 => "重新排货",
|
||||
|
||||
@ -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<IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["serviceNo"] = "S1",
|
||||
["recipientName"] = "A",
|
||||
["recipientAddressFull"] = "Addr A",
|
||||
["actualWeight"] = 1
|
||||
},
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user