feat: 入库箱唛/SKU 标签打印与订单改销售产品
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s

支持按 box/sku 模板生成 PDF;待支付前可改产品并自动重排货重算费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-22 18:39:07 +08:00
parent 7ed8fd8e8a
commit 8a1649ea66
16 changed files with 795 additions and 18 deletions

View 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;
}

View File

@ -345,6 +345,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; }

View File

@ -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);

View File

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

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

View File

@ -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", "总页数")
]
}
];

View 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}";
}
}

View File

@ -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")

View File

@ -165,6 +165,139 @@ public partial class OrderService
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>

View File

@ -3,7 +3,10 @@ using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Labeling;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Entities;
@ -346,6 +349,83 @@ public partial class WarehouseService
await InboundNoticeRepository.UpdateAsync(notice);
}
/// <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 +464,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);

View File

@ -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>();

View File

@ -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>

View File

@ -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>();
}

View File

@ -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";
}

View File

@ -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 => "重新排货",

View File

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