admin/src/Oms.Application/Services/Operations/LabelPrintService.cs
zzh 03f11a276f Add order attachment files table and move system labels off Order.
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>
2026-07-28 16:32:55 +08:00

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