Persist generated/printed/downstream labels in t_order_file, drop Order.LabelUrl generate-status fields, and expose file list/upload/delete APIs with customer read-only systemLabel access. Co-authored-by: Cursor <cursoragent@cursor.com>
475 lines
19 KiB
C#
475 lines
19 KiB
C#
using Fake.Application;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Oms.Application.Dtos.Operations;
|
|
using Oms.Application.Dtos.Orders;
|
|
using Oms.Application.Dtos.Shared;
|
|
using Oms.Application.Services.Orders;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// 面单打印 API 服务。
|
|
/// </summary>
|
|
[ApiExplorerSettings(GroupName = "OMS-Operation")]
|
|
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>();
|
|
private OrderService OrderService => LazyServiceProvider.GetRequiredService<OrderService>();
|
|
|
|
/// <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>
|
|
[HttpPost("/operation/print-label/print")]
|
|
public async Task<LabelPrintRecordOutput> PrintAsync(PrintLabelInput input)
|
|
{
|
|
LabelTemplate? labelTemplate = null;
|
|
if (input.LabelTemplateId.HasValue)
|
|
{
|
|
labelTemplate = await LabelTemplateRepository.FirstOrDefaultAsync(x => x.Id == input.LabelTemplateId.Value);
|
|
if (labelTemplate == null)
|
|
{
|
|
throw new BusinessException("标签模板不存在");
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(input.TemplateType))
|
|
{
|
|
var list = await LabelTemplateRepository.GetListAsync(
|
|
x => x.TemplateType == input.TemplateType);
|
|
labelTemplate = list.OrderByDescending(x => x.UpdateTime).ThenByDescending(x => x.Id).FirstOrDefault();
|
|
}
|
|
|
|
var raw = input.Number?.Trim() ?? string.Empty;
|
|
var type = NormalizeNumberType(input.NumberType);
|
|
Order? order;
|
|
if (type is "auto" or "")
|
|
{
|
|
(order, _, _) = await ResolveAutoAsync(raw);
|
|
}
|
|
else
|
|
{
|
|
(order, _, _) = await ResolveByTypeAsync(raw, type);
|
|
}
|
|
|
|
if (order == null)
|
|
{
|
|
throw new BusinessException("未找到对应订单,无法打印面单");
|
|
}
|
|
|
|
if (labelTemplate != null)
|
|
{
|
|
order.LabelTemplateId = labelTemplate.Id;
|
|
await OrderRepository.UpdateAsync(order);
|
|
}
|
|
|
|
var print = await OrderService.PrintAsync(new OrderActionInput { Id = order.Id });
|
|
|
|
var now = DateTime.Now;
|
|
var operatorId = CurrentUser.GetId<int>();
|
|
var waybillNo = string.Empty;
|
|
string? customerOrderNo = null;
|
|
string? trackingNo = null;
|
|
string? otherNumber = null;
|
|
switch (input.NumberType?.Trim().ToLowerInvariant())
|
|
{
|
|
case "customerorder":
|
|
case "customerorderno":
|
|
case "customer-order":
|
|
customerOrderNo = input.Number;
|
|
break;
|
|
case "tracking":
|
|
trackingNo = input.Number;
|
|
break;
|
|
case "other":
|
|
otherNumber = input.Number;
|
|
break;
|
|
default:
|
|
waybillNo = string.IsNullOrWhiteSpace(input.Number) ? order.ServiceNo : input.Number;
|
|
break;
|
|
}
|
|
|
|
var entity = new LabelPrintRecord
|
|
{
|
|
WaybillNo = string.IsNullOrWhiteSpace(waybillNo) ? order.ServiceNo : waybillNo,
|
|
CustomerOrderNo = customerOrderNo ?? order.ClientNo,
|
|
TrackingNo = trackingNo ?? order.TrackNo,
|
|
OtherNumber = otherNumber,
|
|
LabelTemplateId = labelTemplate?.Id ?? order.LabelTemplateId ?? input.LabelTemplateId,
|
|
PrintSuccess = EnableStatus.Enabled,
|
|
PrintTime = now,
|
|
OperatorId = operatorId
|
|
};
|
|
|
|
var savedEntity = await LabelPrintRecordRepository.InsertAsync(entity);
|
|
var output = ObjectMapper.Map<LabelPrintRecord, LabelPrintRecordOutput>(savedEntity);
|
|
output.LabelTemplateName = labelTemplate?.Name;
|
|
output.OperatorName = CurrentUser.UserName;
|
|
output.PrintUrl = print.PrintUrl;
|
|
return output;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 查询最近面单打印记录列表。
|
|
/// </summary>
|
|
[HttpGet("/operation/print-label/recent-list")]
|
|
public async Task<OmsItemsResult<LabelPrintRecordOutput>> GetRecentListAsync(
|
|
[FromQuery] int limit = 20,
|
|
[FromQuery] int? labelTemplateId = null)
|
|
{
|
|
var hasTemplateFilter = labelTemplateId.HasValue;
|
|
var labelTemplateIdValue = labelTemplateId.GetValueOrDefault();
|
|
var entities = await LabelPrintRecordRepository.GetPagedListAsync(
|
|
x => !hasTemplateFilter || x.LabelTemplateId == labelTemplateIdValue,
|
|
1,
|
|
limit <= 0 ? 20 : limit,
|
|
new Dictionary<string, bool> { [nameof(LabelPrintRecord.PrintTime)] = false, [nameof(LabelPrintRecord.Id)] = false });
|
|
|
|
var templateIds = entities.Where(x => x.LabelTemplateId.HasValue).Select(x => x.LabelTemplateId!.Value).Distinct().ToArray();
|
|
var templates = templateIds.Length == 0
|
|
? []
|
|
: await LabelTemplateRepository.GetListAsync(x => templateIds.Contains(x.Id));
|
|
var templateMap = templates.ToDictionary(x => x.Id);
|
|
var items = new List<LabelPrintRecordOutput>(entities.Count);
|
|
foreach (var entity in entities)
|
|
{
|
|
var output = ObjectMapper.Map<LabelPrintRecord, LabelPrintRecordOutput>(entity);
|
|
if (entity.LabelTemplateId.HasValue && templateMap.TryGetValue(entity.LabelTemplateId.Value, out var template))
|
|
{
|
|
output.LabelTemplateName = template.Name;
|
|
}
|
|
|
|
output.OperatorName = entity.OperatorId?.ToString();
|
|
items.Add(output);
|
|
}
|
|
|
|
return new OmsItemsResult<LabelPrintRecordOutput> { Items = items };
|
|
}
|
|
|
|
/// <summary>
|
|
/// 查询标签模板列表。
|
|
/// </summary>
|
|
[HttpGet("/operation/print-label/template-list")]
|
|
public async Task<OmsItemsResult<LabelTemplateDropdownOutput>> GetTemplateDropdownListAsync(
|
|
[FromQuery] string? templateType = null)
|
|
{
|
|
var entities = await LabelTemplateRepository.GetListAsync(
|
|
x => string.IsNullOrWhiteSpace(templateType) || x.TemplateType == templateType);
|
|
|
|
var items = entities
|
|
.Select(entity => ObjectMapper.Map<LabelTemplate, LabelTemplateDropdownOutput>(entity))
|
|
.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
|
|
};
|
|
}
|
|
}
|