feat: 打印面单扫码核对 lookup(运单/客户单/跟踪号/包裹/SKU)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-18 12:53:35 +08:00
parent fd9f57bac1
commit 9957b79b19
2 changed files with 420 additions and 9 deletions

View File

@ -0,0 +1,109 @@
namespace Oms.Application.Dtos.Operations;
/// <summary>
/// 打印标签扫码查单入参(Query)。
/// </summary>
public class PrintLabelLookupInput
{
/// <summary>扫描号码。</summary>
public string Number { get; set; } = string.Empty;
/// <summary>
/// 号码类型:auto / waybill / customerOrder / tracking / other / parcel / sku。
/// 空视为 auto。
/// </summary>
public string? NumberType { get; set; }
}
/// <summary>
/// 打印标签查单结果:订单头 + 绑定明细,供操作员核对后打印。
/// </summary>
public class PrintLabelLookupOutput
{
/// <summary>是否唯一命中订单。</summary>
public bool Matched { get; set; }
/// <summary>实际命中的号码类型。</summary>
public string? MatchedNumberType { get; set; }
/// <summary>告警:noneBind / multiMatch / alreadyPrinted 等。</summary>
public List<string> Warnings { get; set; } = [];
public int? OrderId { get; set; }
public string? ServiceNo { get; set; }
public string? ClientNo { get; set; }
public string? TrackNo { get; set; }
public string? ProductCode { get; set; }
public string? SalesProductName { get; set; }
public string? DestinationCountry { get; set; }
public string? DestinationCountryName { get; set; }
public string? OrderStatus { get; set; }
public string? OrderStatusName { get; set; }
public int? WarehouseId { get; set; }
public string? WarehouseName { get; set; }
/// <summary>none / bound / deducted / released</summary>
public string BindStatus { get; set; } = "none";
/// <summary>绑定件数合计。</summary>
public int BindQtyTotal { get; set; }
/// <summary>绑定行数。</summary>
public int BindLineCount { get; set; }
public List<PrintLabelBindItemOutput> BindItems { get; set; } = [];
/// <summary>SKU/模糊扫到多单时的候选列表。</summary>
public List<PrintLabelLookupCandidateOutput> Candidates { get; set; } = [];
}
public class PrintLabelBindItemOutput
{
public string BindType { get; set; } = string.Empty;
public string? SkuCode { get; set; }
public string? SkuName { get; set; }
public string? TrackingNo { get; set; }
public string? ProductName { get; set; }
public string? AttributeText { get; set; }
public string? ImageUrl { get; set; }
public string? BinCode { get; set; }
public int BindQty { get; set; }
public string Status { get; set; } = string.Empty;
}
public class PrintLabelLookupCandidateOutput
{
public int OrderId { get; set; }
public string? ServiceNo { get; set; }
public string? ClientNo { get; set; }
public string? TrackNo { get; set; }
public string? OrderStatusName { get; set; }
public int BindQtyTotal { get; set; }
public string? MatchedHint { get; set; }
}

View File

@ -2,11 +2,17 @@ using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Operations;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OperationsAggregate.Entities;
using Oms.Domain.OperationsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.Domain.WarehouseAggregate.Repositories;
namespace Oms.Application.Services.Operations;
@ -18,12 +24,70 @@ public class LabelPrintService : ApplicationService
{
private ILabelPrintRecordRepository LabelPrintRecordRepository => LazyServiceProvider.GetRequiredService<ILabelPrintRecordRepository>();
private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService<ILabelTemplateRepository>();
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
private IOrderStockBindRepository OrderStockBindRepository => LazyServiceProvider.GetRequiredService<IOrderStockBindRepository>();
private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService<ICParcelRepository>();
private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService<ICSkuRepository>();
private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService<ISalesProductRepository>();
private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService<ICountryRepository>();
private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService<IWarehouseAddressRepository>();
/// <summary>
/// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/SKU → 订单 + 绑定明细。
/// </summary>
[HttpGet("/operation/print-label/lookup")]
public async Task<PrintLabelLookupOutput> LookupAsync(
[FromQuery] string number,
[FromQuery] string? numberType = null)
{
var raw = number?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(raw))
{
throw new BusinessException("请扫描或输入单号");
}
var type = NormalizeNumberType(numberType);
Order? order = null;
string? matchedType = null;
List<PrintLabelLookupCandidateOutput>? candidates = null;
if (type is "auto" or "")
{
(order, matchedType, candidates) = await ResolveAutoAsync(raw);
}
else
{
(order, matchedType, candidates) = await ResolveByTypeAsync(raw, type);
}
if (candidates is { Count: > 1 })
{
return new PrintLabelLookupOutput
{
Matched = false,
MatchedNumberType = matchedType,
Warnings = ["multiMatch"],
Candidates = candidates
};
}
if (order == null && candidates is { Count: 1 })
{
order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == candidates[0].OrderId);
matchedType ??= "sku";
}
if (order == null)
{
throw new BusinessException("未找到对应订单,请检查号码或类型");
}
return await BuildLookupOutputAsync(order, matchedType ?? type);
}
/// <summary>
/// 执行面单打印。
/// </summary>
/// <param name="input">入参:面单打印参数。</param>
/// <returns>面单打印记录。</returns>
[HttpPost("/operation/print-label/print")]
public async Task<LabelPrintRecordOutput> PrintAsync(PrintLabelInput input)
{
@ -89,11 +153,10 @@ public class LabelPrintService : ApplicationService
/// <summary>
/// 查询最近面单打印记录列表。
/// </summary>
/// <param name="limit">入参:返回记录条数上限。</param>
/// <param name="labelTemplateId">入参:标签模板主键,可选。</param>
/// <returns>面单打印记录列表结果。</returns>
[HttpGet("/operation/print-label/recent-list")]
public async Task<OmsItemsResult<LabelPrintRecordOutput>> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? labelTemplateId = null)
public async Task<OmsItemsResult<LabelPrintRecordOutput>> GetRecentListAsync(
[FromQuery] int limit = 20,
[FromQuery] int? labelTemplateId = null)
{
var hasTemplateFilter = labelTemplateId.HasValue;
var labelTemplateIdValue = labelTemplateId.GetValueOrDefault();
@ -127,10 +190,9 @@ public class LabelPrintService : ApplicationService
/// <summary>
/// 查询标签模板列表。
/// </summary>
/// <param name="templateType">入参:模板类型,可选。</param>
/// <returns>标签模板列表结果。</returns>
[HttpGet("/operation/print-label/template-list")]
public async Task<OmsItemsResult<LabelTemplateDropdownOutput>> GetTemplateDropdownListAsync([FromQuery] string? templateType = null)
public async Task<OmsItemsResult<LabelTemplateDropdownOutput>> GetTemplateDropdownListAsync(
[FromQuery] string? templateType = null)
{
var entities = await LabelTemplateRepository.GetListAsync(
x => string.IsNullOrWhiteSpace(templateType) || x.TemplateType == templateType);
@ -140,4 +202,244 @@ public class LabelPrintService : ApplicationService
.ToList();
return new OmsItemsResult<LabelTemplateDropdownOutput> { Items = items };
}
private static string NormalizeNumberType(string? numberType)
{
var value = numberType?.Trim().ToLowerInvariant() ?? "auto";
return value switch
{
"customerorderno" or "customer-order" or "client" or "clientno" => "customerorder",
"track" or "trackno" => "tracking",
"parceltracking" or "parcelno" or "express" => "parcel",
"skucode" => "sku",
"" or "all" => "auto",
_ => value
};
}
private async Task<(Order? Order, string? MatchedType, List<PrintLabelLookupCandidateOutput>? Candidates)>
ResolveAutoAsync(string number)
{
var order = await OrderRepository.GetByWaybillNoAsync(number);
if (order != null)
{
var matched =
string.Equals(order.ServiceNo, number, StringComparison.OrdinalIgnoreCase) ? "waybill"
: string.Equals(order.TrackNo, number, StringComparison.OrdinalIgnoreCase) ? "tracking"
: string.Equals(order.ClientNo, number, StringComparison.OrdinalIgnoreCase) ? "customerorder"
: "waybill";
return (order, matched, null);
}
var byParcel = await ResolveByParcelTrackingAsync(number);
if (byParcel.Order != null || byParcel.Candidates is { Count: > 0 })
{
return byParcel;
}
return await ResolveBySkuAsync(number);
}
private async Task<(Order? Order, string? MatchedType, List<PrintLabelLookupCandidateOutput>? Candidates)>
ResolveByTypeAsync(string number, string type)
{
return type switch
{
"waybill" => (await OrderRepository.GetByServiceNoAsync(number)
?? await OrderRepository.GetByWaybillNoAsync(number), "waybill", null),
"customerorder" => (
await OrderRepository.FirstOrDefaultAsync(x => x.ClientNo == number),
"customerorder",
null),
"tracking" => (
await OrderRepository.GetByTrackNoAsync(number),
"tracking",
null),
"other" => (
await OrderRepository.FirstOrDefaultAsync(x => x.RequestId == number || x.ClientNo == number)
?? await OrderRepository.GetByWaybillNoAsync(number),
"other",
null),
"parcel" => await ResolveByParcelTrackingAsync(number),
"sku" => await ResolveBySkuAsync(number),
_ => await ResolveAutoAsync(number)
};
}
private async Task<(Order? Order, string? MatchedType, List<PrintLabelLookupCandidateOutput>? Candidates)>
ResolveByParcelTrackingAsync(string trackingNo)
{
var parcels = await ParcelRepository.GetListAsync(x => x.TrackingNo == trackingNo);
if (parcels.Count == 0)
{
return (null, "parcel", null);
}
var parcelIds = parcels.Select(x => x.Id).ToArray();
var binds = await OrderStockBindRepository.GetListAsync(x =>
x.ParcelId.HasValue
&& parcelIds.Contains(x.ParcelId.Value)
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
var orderIds = binds.Select(x => x.OrderId).Distinct().ToArray();
if (orderIds.Length == 0)
{
throw new BusinessException("找到包裹但未绑定到任何订单,请先绑定包裹/库存");
}
if (orderIds.Length == 1)
{
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderIds[0]);
return (order, "parcel", null);
}
var candidates = await BuildCandidatesAsync(orderIds, "包裹快递单号命中多笔订单");
return (null, "parcel", candidates);
}
private async Task<(Order? Order, string? MatchedType, List<PrintLabelLookupCandidateOutput>? Candidates)>
ResolveBySkuAsync(string skuCode)
{
var skus = await SkuRepository.GetListAsync(x => x.SkuCode == skuCode);
if (skus.Count == 0)
{
return (null, "sku", null);
}
var skuIds = skus.Select(x => x.Id).ToArray();
var binds = await OrderStockBindRepository.GetListAsync(x =>
x.SkuId.HasValue
&& skuIds.Contains(x.SkuId.Value)
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
var orderIds = binds.Select(x => x.OrderId).Distinct().ToArray();
if (orderIds.Length == 0)
{
throw new BusinessException("找到 SKU 但没有绑定中的订单,请确认是否已绑定");
}
if (orderIds.Length == 1)
{
var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderIds[0]);
return (order, "sku", null);
}
var candidates = await BuildCandidatesAsync(orderIds, $"SKU {skuCode} 命中多笔订单,请选择");
return (null, "sku", candidates);
}
private async Task<List<PrintLabelLookupCandidateOutput>> BuildCandidatesAsync(int[] orderIds, string hint)
{
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
var binds = await OrderStockBindRepository.GetListAsync(x =>
orderIds.Contains(x.OrderId)
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
var qtyMap = binds.GroupBy(x => x.OrderId).ToDictionary(g => g.Key, g => g.Sum(x => x.BindQty));
return orders
.OrderByDescending(x => x.UpdateTime)
.Select(o => new PrintLabelLookupCandidateOutput
{
OrderId = o.Id,
ServiceNo = o.ServiceNo,
ClientNo = o.ClientNo,
TrackNo = o.TrackNo,
OrderStatusName = OmsDocHelper.GetOrderStatusName(o.OrderStatus),
BindQtyTotal = qtyMap.GetValueOrDefault(o.Id),
MatchedHint = hint
})
.ToList();
}
private async Task<PrintLabelLookupOutput> BuildLookupOutputAsync(Order order, string matchedType)
{
var binds = await OrderStockBindRepository.GetListAsync(x =>
x.OrderId == order.Id
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
binds = binds.OrderBy(x => x.Id).ToList();
var warehouseId = order.WarehouseId ?? binds.FirstOrDefault()?.WarehouseId;
string? warehouseName = null;
if (warehouseId is > 0)
{
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == warehouseId.Value);
warehouseName = warehouse?.WarehouseName;
}
string? salesProductName = null;
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
salesProductName = product?.ProductName;
}
string? countryName = order.ConsigneeCountry;
if (!string.IsNullOrWhiteSpace(order.ConsigneeCountry))
{
var country = await CountryRepository.FirstOrDefaultAsync(x => x.CountryCode == order.ConsigneeCountry);
countryName = country?.ChineseName ?? country?.EnglishName ?? order.ConsigneeCountry;
}
var bindStatus = "none";
if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Deducted))
{
bindStatus = OrderStockBindStatusCodes.Deducted;
}
else if (binds.Any(x => x.Status == OrderStockBindStatusCodes.Bound))
{
bindStatus = OrderStockBindStatusCodes.Bound;
}
var warnings = new List<string>();
if (binds.Count == 0)
{
warnings.Add("noneBind");
}
var binIds = binds.Where(x => x.BinLocationId.HasValue).Select(x => x.BinLocationId!.Value).Distinct().ToArray();
var binMap = new Dictionary<int, string>();
if (binIds.Length > 0)
{
var binRepo = LazyServiceProvider.GetRequiredService<ICBinLocationRepository>();
var bins = await binRepo.GetListAsync(x => binIds.Contains(x.Id));
binMap = bins.ToDictionary(x => x.Id, x => x.BinCode);
}
var bindItems = binds.Select(b => new PrintLabelBindItemOutput
{
BindType = b.BindType,
SkuCode = b.SkuCode,
SkuName = b.SkuName,
TrackingNo = b.TrackingNo,
ProductName = b.ProductName,
AttributeText = b.AttributeText,
ImageUrl = b.ImageUrl,
BinCode = b.BinLocationId is > 0 && binMap.TryGetValue(b.BinLocationId.Value, out var code) ? code : null,
BindQty = b.BindQty,
Status = b.Status
}).ToList();
return new PrintLabelLookupOutput
{
Matched = true,
MatchedNumberType = matchedType,
Warnings = warnings,
OrderId = order.Id,
ServiceNo = order.ServiceNo,
ClientNo = order.ClientNo,
TrackNo = order.TrackNo,
ProductCode = order.ProductCode,
SalesProductName = salesProductName,
DestinationCountry = order.ConsigneeCountry,
DestinationCountryName = countryName,
OrderStatus = order.OrderStatus,
OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus),
WarehouseId = warehouseId,
WarehouseName = warehouseName,
BindStatus = bindStatus,
BindQtyTotal = binds.Sum(x => x.BindQty),
BindLineCount = binds.Count,
BindItems = bindItems
};
}
}