From 9957b79b198835187988b1ddc20e0f30de735cae Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Sat, 18 Jul 2026 12:53:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=93=E5=8D=B0=E9=9D=A2=E5=8D=95?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E6=A0=B8=E5=AF=B9=20lookup=EF=BC=88=E8=BF=90?= =?UTF-8?q?=E5=8D=95/=E5=AE=A2=E6=88=B7=E5=8D=95/=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E5=8F=B7/=E5=8C=85=E8=A3=B9/SKU=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Operations/PrintLabelLookupOutput.cs | 109 ++++++ .../Services/Operations/LabelPrintService.cs | 320 +++++++++++++++++- 2 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs diff --git a/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs b/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs new file mode 100644 index 0000000..be30c83 --- /dev/null +++ b/src/Oms.Application/Dtos/Operations/PrintLabelLookupOutput.cs @@ -0,0 +1,109 @@ +namespace Oms.Application.Dtos.Operations; + +/// +/// 打印标签扫码查单入参(Query)。 +/// +public class PrintLabelLookupInput +{ + /// 扫描号码。 + public string Number { get; set; } = string.Empty; + + /// + /// 号码类型:auto / waybill / customerOrder / tracking / other / parcel / sku。 + /// 空视为 auto。 + /// + public string? NumberType { get; set; } +} + +/// +/// 打印标签查单结果:订单头 + 绑定明细,供操作员核对后打印。 +/// +public class PrintLabelLookupOutput +{ + /// 是否唯一命中订单。 + public bool Matched { get; set; } + + /// 实际命中的号码类型。 + public string? MatchedNumberType { get; set; } + + /// 告警:noneBind / multiMatch / alreadyPrinted 等。 + public List 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; } + + /// none / bound / deducted / released + public string BindStatus { get; set; } = "none"; + + /// 绑定件数合计。 + public int BindQtyTotal { get; set; } + + /// 绑定行数。 + public int BindLineCount { get; set; } + + public List BindItems { get; set; } = []; + + /// SKU/模糊扫到多单时的候选列表。 + public List 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; } +} diff --git a/src/Oms.Application/Services/Operations/LabelPrintService.cs b/src/Oms.Application/Services/Operations/LabelPrintService.cs index 95442ef..14ca1b8 100644 --- a/src/Oms.Application/Services/Operations/LabelPrintService.cs +++ b/src/Oms.Application/Services/Operations/LabelPrintService.cs @@ -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(); private ILabelTemplateRepository LabelTemplateRepository => LazyServiceProvider.GetRequiredService(); + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); + private IOrderStockBindRepository OrderStockBindRepository => LazyServiceProvider.GetRequiredService(); + private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService(); + private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService(); + private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); + private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); + private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); + + /// + /// 扫码查单:运单/客户单/跟踪号/其他单号/包裹快递单号/SKU → 订单 + 绑定明细。 + /// + [HttpGet("/operation/print-label/lookup")] + public async Task 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? 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); + } /// /// 执行面单打印。 /// - /// 入参:面单打印参数。 - /// 面单打印记录。 [HttpPost("/operation/print-label/print")] public async Task PrintAsync(PrintLabelInput input) { @@ -89,11 +153,10 @@ public class LabelPrintService : ApplicationService /// /// 查询最近面单打印记录列表。 /// - /// 入参:返回记录条数上限。 - /// 入参:标签模板主键,可选。 - /// 面单打印记录列表结果。 [HttpGet("/operation/print-label/recent-list")] - public async Task> GetRecentListAsync([FromQuery] int limit = 20, [FromQuery] int? labelTemplateId = null) + public async Task> 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 /// /// 查询标签模板列表。 /// - /// 入参:模板类型,可选。 - /// 标签模板列表结果。 [HttpGet("/operation/print-label/template-list")] - public async Task> GetTemplateDropdownListAsync([FromQuery] string? templateType = null) + public async Task> 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 { 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? 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? 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? 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? 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> 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 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(); + 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(); + if (binIds.Length > 0) + { + var binRepo = LazyServiceProvider.GetRequiredService(); + 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 + }; + } }