All checks were successful
Build and Deploy / deploy (push) Successful in 59s
Co-authored-by: Cursor <cursoragent@cursor.com>
935 lines
39 KiB
C#
935 lines
39 KiB
C#
using System.Linq.Expressions;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Fake.Application;
|
|
using Fake.Users;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using MiniExcelLibs;
|
|
using Newtonsoft.Json;
|
|
using Oms.Application.DataPerm;
|
|
using Oms.Application.Dtos.Orders;
|
|
using Oms.Application.Dtos.Shared;
|
|
using Oms.Application.Messaging;
|
|
using Oms.Application.Services.Orders.Templates;
|
|
using Oms.Application.Shared;
|
|
using Oms.Application.Storage;
|
|
using Oms.Domain.OrdersAggregate;
|
|
using Oms.Domain.ChannelsAggregate.Consts;
|
|
using Oms.Domain.ChannelsAggregate.Entities;
|
|
using Oms.Domain.OrdersAggregate.Consts;
|
|
using Oms.Domain.OrdersAggregate.Entities;
|
|
using Oms.Domain.SystemAggregate.Entities;
|
|
using Oms.Domain.WarehouseAggregate.Consts;
|
|
|
|
namespace Oms.Application.Services.Orders;
|
|
|
|
public partial class OrderService
|
|
{
|
|
private async Task<List<OrderOutput>> MapOrderOutputsAsync(IReadOnlyCollection<Order> orders)
|
|
{
|
|
if (orders.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var orderIds = orders.Select(x => x.Id).ToArray();
|
|
var warehouseIds = orders.Where(x => x.WarehouseId.HasValue).Select(x => x.WarehouseId!.Value).Distinct().ToArray();
|
|
var senders = await OrderRepository.GetSendersByOrderIdsAsync(orderIds);
|
|
var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync(orderIds);
|
|
var declarations = await OrderRepository.GetDeclarationsByOrderIdsAsync(orderIds);
|
|
var weights = await OrderRepository.GetWeightsByOrderIdsAsync(orderIds);
|
|
var fees = await OrderRepository.GetFeesByOrderIdsAsync(orderIds);
|
|
var warehouses = warehouseIds.Length == 0
|
|
? []
|
|
: await WarehouseAddressRepository.GetListAsync(x => warehouseIds.Contains(x.Id));
|
|
|
|
var senderMap = senders.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.First());
|
|
var recipientMap = recipients.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.First());
|
|
var declarationMap = declarations.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
|
|
var weightMap = weights.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
|
|
var feeMap = fees.GroupBy(x => x.OrderId).ToDictionary(x => x.Key, x => x.ToList());
|
|
var warehouseMap = warehouses.ToDictionary(x => x.Id);
|
|
|
|
var productCodes = orders
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.ProductCode))
|
|
.Select(x => x.ProductCode!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var channelCodes = orders
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.ChannelCode))
|
|
.Select(x => x.ChannelCode!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var products = productCodes.Length == 0
|
|
? []
|
|
: await SalesProductRepository.GetListAsync(x => productCodes.Contains(x.ProductCode));
|
|
var channels = channelCodes.Length == 0
|
|
? []
|
|
: await ServiceChannelRepository.GetListAsync(x => channelCodes.Contains(x.ChannelCode));
|
|
var productMap = products.ToDictionary(x => x.ProductCode, StringComparer.OrdinalIgnoreCase);
|
|
var channelMap = channels.ToDictionary(x => x.ChannelCode, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var countryCodes = orders
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.ConsigneeCountry))
|
|
.Select(x => x.ConsigneeCountry!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var countries = countryCodes.Length == 0
|
|
? []
|
|
: await CountryRepository.GetListAsync(x => countryCodes.Contains(x.CountryCode));
|
|
var countryMap = countries.ToDictionary(x => x.CountryCode, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var addressCountryIds = senders
|
|
.Select(x => x.Country)
|
|
.Concat(recipients.Select(x => x.Country))
|
|
.Where(x => x.HasValue)
|
|
.Select(x => x!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
var addressCountries = addressCountryIds.Length == 0
|
|
? []
|
|
: await CountryRepository.GetListAsync(x => addressCountryIds.Contains(x.Id));
|
|
var countryIdCodeMap = addressCountries.ToDictionary(
|
|
x => x.Id,
|
|
x => x.CountryCode ?? string.Empty);
|
|
|
|
var auditUserNameMap = await UserDisplayNameResolver.ResolveManyAsync(
|
|
orders.SelectMany(x => new[] { x.CreateUserId, x.UpdateUserId }));
|
|
|
|
var customerIds = orders
|
|
.Where(x => x.CustomerId is > 0)
|
|
.Select(x => x.CustomerId!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
var customers = customerIds.Length == 0
|
|
? []
|
|
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
|
var customerMap = customers.ToDictionary(x => x.Id);
|
|
|
|
var pickupOrderIds = orders
|
|
.Where(x => x.PickupOrderId is > 0)
|
|
.Select(x => x.PickupOrderId!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
var pickupOrders = pickupOrderIds.Length == 0
|
|
? []
|
|
: await PickupOrderRepository.GetListAsync(x => pickupOrderIds.Contains(x.Id));
|
|
var pickupNoMap = pickupOrders.ToDictionary(x => x.Id, x => x.PickupNo);
|
|
var carrierWaybillNoMap = pickupOrders
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.CarrierWaybillNo))
|
|
.ToDictionary(x => x.Id, x => x.CarrierWaybillNo!);
|
|
|
|
var bindSummaryMap = await LoadStockBindListSummariesAsync(orderIds);
|
|
|
|
var outputs = orders.Select(order =>
|
|
{
|
|
senderMap.TryGetValue(order.Id, out var sender);
|
|
recipientMap.TryGetValue(order.Id, out var recipient);
|
|
declarationMap.TryGetValue(order.Id, out var items);
|
|
weightMap.TryGetValue(order.Id, out var orderWeights);
|
|
feeMap.TryGetValue(order.Id, out var orderFees);
|
|
var feeOutputs = (orderFees ?? []).Select(MapFee).ToList();
|
|
var chargeableWeight = ResolveDisplayChargeableWeight(orderWeights);
|
|
var receivableAmount = feeOutputs
|
|
.Where(x => x.Type == OrderFeeTypeCodes.Receivable)
|
|
.Sum(x => x.Amount);
|
|
var additionalServices = NormalizeAdditionalServiceCodes(
|
|
OmsDocHelper.DeserializeJson<List<string>>(order.AdditionalServices));
|
|
var hasInsurance = additionalServices.Any(x =>
|
|
string.Equals(x, "insurance", StringComparison.OrdinalIgnoreCase));
|
|
string? customerName = null;
|
|
string? customerCode = null;
|
|
if (order.CustomerId is > 0 && customerMap.TryGetValue(order.CustomerId.Value, out var customer))
|
|
{
|
|
customerName = customer.CustomerName;
|
|
customerCode = customer.CustomerCode;
|
|
}
|
|
|
|
SalesProduct? product = null;
|
|
if (!string.IsNullOrWhiteSpace(order.ProductCode))
|
|
{
|
|
productMap.TryGetValue(order.ProductCode.Trim(), out product);
|
|
}
|
|
|
|
bindSummaryMap.TryGetValue(order.Id, out var bindSummary);
|
|
|
|
return new OrderOutput
|
|
{
|
|
Id = order.Id,
|
|
ClientNo = order.ClientNo,
|
|
TrackNo = order.TrackNo,
|
|
DownstreamServiceNo = order.DownstreamServiceNo,
|
|
ServiceNo = order.ServiceNo,
|
|
ProductCode = order.ProductCode,
|
|
ProductName = product?.ProductName,
|
|
MaxTimeliness = product?.MaxTimeliness,
|
|
ChannelCode = order.ChannelCode,
|
|
DownstreamSupplierCode = order.DownstreamSupplierCode,
|
|
DownstreamAppKey = order.DownstreamAppKey,
|
|
DownstreamAppId = order.DownstreamAppId,
|
|
ChannelForecastCode = order.ChannelForecastCode,
|
|
ChannelName = !string.IsNullOrWhiteSpace(order.ChannelCode)
|
|
&& channelMap.TryGetValue(order.ChannelCode.Trim(), out var channel)
|
|
? channel.ChannelName
|
|
: null,
|
|
ConsigneeCountry = order.ConsigneeCountry,
|
|
ConsigneeCountryName = ResolveConsigneeCountryName(order, countryMap),
|
|
CargoAttribute = order.CargoAttribute,
|
|
WarehouseId = order.WarehouseId,
|
|
Warehouse = order.WarehouseId.HasValue && warehouseMap.TryGetValue(order.WarehouseId.Value, out var warehouse)
|
|
? warehouse.WarehouseName
|
|
: null,
|
|
CustomerId = order.CustomerId,
|
|
CustomerName = customerName,
|
|
CustomerCode = customerCode,
|
|
OrderSource = string.IsNullOrWhiteSpace(order.OrderSource)
|
|
? OrderSourceCodes.Manual
|
|
: order.OrderSource,
|
|
PickupTime = order.PickupTime,
|
|
CustomerSenderAddressId = order.CustomerSenderAddressId,
|
|
InboundMethod = order.InboundMethod,
|
|
FulfillmentType = string.IsNullOrWhiteSpace(order.FulfillmentType)
|
|
? FulfillmentTypeCodes.Direct
|
|
: order.FulfillmentType,
|
|
FulfillmentSource = order.FulfillmentSource,
|
|
StockBindStatus = bindSummary?.BindStatus ?? "none",
|
|
StockBindLineCount = bindSummary?.LineCount ?? 0,
|
|
StockBindQtyTotal = bindSummary?.QtyTotal ?? 0,
|
|
DomesticTrackingNos = bindSummary?.TrackingNosText,
|
|
FulfillProgress = FulfillmentProgressCodes.Resolve(
|
|
FulfillmentTypeCodes.IsWarehouse(order.FulfillmentType),
|
|
bindSummary?.BindStatus ?? "none",
|
|
bindSummary?.LineCount ?? 0,
|
|
bindSummary?.HasInTransitParcel ?? false),
|
|
PrimaryBinCode = bindSummary?.PrimaryBinCode,
|
|
PrimarySkuCodes = bindSummary?.PrimarySkuCodes,
|
|
PickupOrderId = order.PickupOrderId,
|
|
PickupNo = order.PickupOrderId is > 0
|
|
&& pickupNoMap.TryGetValue(order.PickupOrderId.Value, out var pickupNo)
|
|
? pickupNo
|
|
: null,
|
|
CarrierWaybillNo = order.PickupOrderId is > 0
|
|
&& carrierWaybillNoMap.TryGetValue(order.PickupOrderId.Value, out var carrierWaybillNo)
|
|
? carrierWaybillNo
|
|
: null,
|
|
RequestId = order.RequestId,
|
|
ExportCustomsMode = order.ExportCustomsMode,
|
|
ExportQualificationId = order.ExportQualificationId,
|
|
PredeclareFailReason = string.IsNullOrWhiteSpace(order.PredeclareFailReason)
|
|
? null
|
|
: order.PredeclareFailReason.Trim(),
|
|
AllocationPendingReason = string.IsNullOrWhiteSpace(order.AllocationPendingReason)
|
|
? null
|
|
: order.AllocationPendingReason.Trim(),
|
|
AllocationScheme = string.IsNullOrWhiteSpace(order.AllocationScheme)
|
|
? null
|
|
: order.AllocationScheme.Trim(),
|
|
HasAllocationTrace = !string.IsNullOrWhiteSpace(order.AllocationTraceJson),
|
|
DeclaredCurrency = order.DeclaredCurrency,
|
|
DeclaredSum = order.DeclaredSum,
|
|
Quantity = order.Quantity,
|
|
IsCod = order.IsCod > 0,
|
|
CodCurrency = order.CodCurrency,
|
|
CodSum = order.CodSum,
|
|
OrderStatus = OmsDocHelper.GetOrderStatusName(order.OrderStatus),
|
|
Status = order.OrderStatus,
|
|
HandleStatus = OrderHandleStatusCodes.IsProcessed(order.HandleStatus)
|
|
? OrderHandleStatusCodes.Processed
|
|
: OrderHandleStatusCodes.Pending,
|
|
HandleStatusName = OrderHandleStatusCodes.GetDisplayName(order.HandleStatus),
|
|
IssueType = order.IssueType,
|
|
IssueTypeName = IssueTypeCodes.GetDisplayName(order.IssueType),
|
|
IssueTitle = order.IssueTitle,
|
|
IssueContent = order.IssueContent,
|
|
IssueTemplateId = order.IssueTemplateId,
|
|
IssueRemark = order.IssueRemark,
|
|
IssueTime = order.IssueTime,
|
|
ClaimType = order.ClaimType,
|
|
ClaimTypeName = ClaimTypeCodes.GetDisplayName(order.ClaimType),
|
|
ClaimAmount = order.ClaimAmount,
|
|
ClaimCurrency = order.ClaimCurrency,
|
|
ClaimReason = order.ClaimReason,
|
|
ClaimTime = order.ClaimTime,
|
|
PreviousOrderStatus = order.PreviousOrderStatus,
|
|
ReceiveTime = order.ReceiveTime,
|
|
ShipTime = order.ShipTime,
|
|
PayTime = order.PayTime,
|
|
IsPaid = order.IsPaid > 0,
|
|
SignTime = order.SignTime,
|
|
IsIntercept = order.IsIntercept > 0,
|
|
IsRemote = order.IsRemote > 0,
|
|
AdditionalServices = additionalServices,
|
|
HasInsurance = hasInsurance,
|
|
ForecastReceivableAmount = order.ForecastReceivableAmount,
|
|
ForecastPayableAmount = order.ForecastPayableAmount,
|
|
CreateTime = order.CreateTime,
|
|
CreateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.CreateUserId, auditUserNameMap),
|
|
UpdateTime = OmsDocHelper.NullIfDefault(order.UpdateTime),
|
|
UpdateBy = OmsRbacUserDisplayNameResolver.FormatUserId(order.UpdateUserId, auditUserNameMap),
|
|
Sender = MapSender(sender, countryIdCodeMap),
|
|
Receiver = MapReceiver(recipient, countryIdCodeMap),
|
|
Items = (items ?? []).Select(MapDeclaration).ToList(),
|
|
WeightList = (orderWeights ?? []).Select(MapWeight).ToList(),
|
|
FeeList = feeOutputs,
|
|
ChargeableWeight = chargeableWeight,
|
|
ReceivableAmount = feeOutputs.Count == 0 ? null : receivableAmount
|
|
};
|
|
}).ToList();
|
|
|
|
if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
|
|
{
|
|
RedactChannelFieldsForCustomer(outputs);
|
|
}
|
|
|
|
return outputs;
|
|
}
|
|
|
|
private sealed class StockBindListSummary
|
|
{
|
|
public string BindStatus { get; init; } = "none";
|
|
|
|
public int LineCount { get; init; }
|
|
|
|
public int QtyTotal { get; init; }
|
|
|
|
public string? TrackingNosText { get; init; }
|
|
|
|
public bool HasInTransitParcel { get; init; }
|
|
|
|
public string? PrimaryBinCode { get; init; }
|
|
|
|
public string? PrimarySkuCodes { get; init; }
|
|
}
|
|
|
|
private async Task<Dictionary<int, StockBindListSummary>> LoadStockBindListSummariesAsync(int[] orderIds)
|
|
{
|
|
if (orderIds.Length == 0)
|
|
{
|
|
return new Dictionary<int, StockBindListSummary>();
|
|
}
|
|
|
|
var binds = await OrderStockBindRepository.GetListAsync(x =>
|
|
orderIds.Contains(x.OrderId)
|
|
&& (x.Status == OrderStockBindStatusCodes.Bound || x.Status == OrderStockBindStatusCodes.Deducted));
|
|
|
|
var parcelIds = binds
|
|
.Where(x => x.ParcelId is > 0)
|
|
.Select(x => x.ParcelId!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
var parcels = parcelIds.Length == 0
|
|
? []
|
|
: await StockBindParcelRepository.GetListAsync(x => parcelIds.Contains(x.Id));
|
|
var parcelLifecycleMap = parcels.ToDictionary(x => x.Id, x => x.LifecycleStatus);
|
|
|
|
var binIds = binds
|
|
.Where(x => x.BinLocationId is > 0)
|
|
.Select(x => x.BinLocationId!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
var bins = binIds.Length == 0
|
|
? []
|
|
: await StockBindBinLocationRepository.GetListAsync(x => binIds.Contains(x.Id));
|
|
var binCodeMap = bins.ToDictionary(x => x.Id, x => x.BinCode);
|
|
|
|
return binds
|
|
.GroupBy(x => x.OrderId)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g =>
|
|
{
|
|
var rows = g.ToList();
|
|
var bindStatus = rows.Any(x => x.Status == OrderStockBindStatusCodes.Deducted)
|
|
? OrderStockBindStatusCodes.Deducted
|
|
: OrderStockBindStatusCodes.Bound;
|
|
var trackingNos = rows
|
|
.Select(x => x.TrackingNo?.Trim())
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var hasInTransitParcel = rows.Any(row =>
|
|
row.ParcelId is > 0
|
|
&& parcelLifecycleMap.TryGetValue(row.ParcelId.Value, out var lifecycle)
|
|
&& string.Equals(
|
|
lifecycle,
|
|
WarehouseParcelLifecycleCodes.InTransit,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
var primaryBinCode = rows
|
|
.Where(row => row.BinLocationId is > 0
|
|
&& binCodeMap.TryGetValue(row.BinLocationId.Value, out _))
|
|
.Select(row => binCodeMap[row.BinLocationId!.Value])
|
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
|
.OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
|
|
.FirstOrDefault();
|
|
var skuCodes = rows
|
|
.Select(row => row.SkuCode?.Trim())
|
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(code => code, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
return new StockBindListSummary
|
|
{
|
|
BindStatus = bindStatus,
|
|
LineCount = rows.Count,
|
|
QtyTotal = rows.Sum(x => x.BindQty),
|
|
TrackingNosText = trackingNos.Length == 0 ? null : string.Join(", ", trackingNos),
|
|
HasInTransitParcel = hasInTransitParcel,
|
|
PrimaryBinCode = primaryBinCode,
|
|
PrimarySkuCodes = skuCodes.Length == 0 ? null : string.Join(", ", skuCodes),
|
|
};
|
|
});
|
|
}
|
|
|
|
private static void RedactChannelFieldsForCustomer(IEnumerable<OrderOutput> outputs)
|
|
{
|
|
foreach (var output in outputs)
|
|
{
|
|
output.ChannelCode = null;
|
|
output.ChannelName = null;
|
|
output.DownstreamSupplierCode = null;
|
|
output.DownstreamAppKey = null;
|
|
output.DownstreamAppId = null;
|
|
output.ChannelForecastCode = null;
|
|
}
|
|
}
|
|
|
|
private async Task<List<object>> BuildOrderExportRowsAsync(IReadOnlyCollection<Order> orders)
|
|
{
|
|
var mapped = await MapOrderOutputsAsync(orders);
|
|
return mapped.Select(x => (object)new
|
|
{
|
|
x.Id,
|
|
x.ClientNo,
|
|
x.TrackNo,
|
|
x.DownstreamServiceNo,
|
|
x.ServiceNo,
|
|
x.ProductCode,
|
|
x.ProductName,
|
|
x.ChannelCode,
|
|
x.ChannelName,
|
|
x.CargoAttribute,
|
|
x.WarehouseId,
|
|
x.Warehouse,
|
|
x.CustomerId,
|
|
x.Status,
|
|
x.OrderStatus,
|
|
x.PredeclareFailReason,
|
|
x.ReceiveTime,
|
|
x.ShipTime,
|
|
x.PayTime,
|
|
x.IsPaid,
|
|
x.SignTime,
|
|
x.IsCod,
|
|
x.CodCurrency,
|
|
x.CodSum,
|
|
x.IsIntercept,
|
|
x.IsRemote,
|
|
x.CreateTime,
|
|
x.UpdateTime
|
|
}).ToList();
|
|
}
|
|
|
|
private async Task<OmsPageResult<OrderTemplateOutput>> GetTemplateOutputsAsync(OrderTemplateListInput input)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input.Type)
|
|
|| string.Equals(input.Type, "standard", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await EnsureDedicatedSmallParcelStandardTemplateAsync();
|
|
}
|
|
|
|
var page = OmsDocHelper.NormalizePage(input.Page);
|
|
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
|
var keyword = input.Keyword?.Trim();
|
|
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
|
CustomerRepository,
|
|
CurrentUser,
|
|
null);
|
|
var hasCustomerFilter = customerId.HasValue;
|
|
var customerIdValue = customerId.GetValueOrDefault();
|
|
var predicate = (Expression<Func<OrderTemplate, bool>>)(x =>
|
|
(string.IsNullOrWhiteSpace(input.Type) || x.Type == input.Type) &&
|
|
(string.IsNullOrWhiteSpace(keyword) || x.Name.Contains(keyword) || (x.TemplateKey ?? string.Empty).Contains(keyword)) &&
|
|
(!hasCustomerFilter || x.Type == "standard" || x.CustomerId == customerIdValue));
|
|
|
|
var templates = await OrderTemplateRepository.GetPagedListAsync(
|
|
predicate,
|
|
page,
|
|
pageSize,
|
|
new Dictionary<string, bool> { [nameof(OrderTemplate.UpdateTime)] = false, [nameof(OrderTemplate.Id)] = false });
|
|
var total = await OrderTemplateRepository.CountAsync(predicate);
|
|
|
|
return new OmsPageResult<OrderTemplateOutput>
|
|
{
|
|
Items = templates.Select(x => new OrderTemplateOutput
|
|
{
|
|
Id = x.Id,
|
|
Name = x.Name,
|
|
Type = x.Type,
|
|
TemplateKey = x.TemplateKey,
|
|
CustomerId = x.CustomerId,
|
|
MappingStatus = string.Equals(x.Type, "standard", StringComparison.OrdinalIgnoreCase)
|
|
? OrderTemplateMappingStatus.Ready
|
|
: x.MappingStatus,
|
|
StandardTemplateKey = x.StandardTemplateKey ?? x.TemplateKey,
|
|
SourceFileName = x.SourceFileName,
|
|
CreateTime = x.CreateTime,
|
|
UpdateTime = OmsDocHelper.NullIfDefault(x.UpdateTime)
|
|
}).ToList(),
|
|
Total = total
|
|
};
|
|
}
|
|
|
|
private async Task<FileContentResult> BuildTemplateDownloadAsync(int templateId)
|
|
{
|
|
var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == templateId);
|
|
if (template == null)
|
|
{
|
|
throw new BusinessException("模板不存在");
|
|
}
|
|
|
|
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
|
CustomerRepository,
|
|
CurrentUser,
|
|
null);
|
|
if (customerId.HasValue)
|
|
{
|
|
EnsureTemplateAccessible(template, customerId.Value);
|
|
}
|
|
|
|
if (string.Equals(template.Type, "custom", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(template.SourceFilePath))
|
|
{
|
|
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
|
}
|
|
|
|
if (!await FileStorage.ExistsAsync(FileStoragePath.NormalizeKey(template.SourceFilePath)))
|
|
{
|
|
throw new BusinessException("自定义模板样例文件不存在,请重新上传");
|
|
}
|
|
|
|
await using var stream = await FileStorage.GetAsync(FileStoragePath.NormalizeKey(template.SourceFilePath));
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
var bytes = ms.ToArray();
|
|
var downloadName = string.IsNullOrWhiteSpace(template.SourceFileName)
|
|
? $"{template.Name}.xlsx"
|
|
: template.SourceFileName;
|
|
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
{
|
|
FileDownloadName = downloadName
|
|
};
|
|
}
|
|
|
|
var templateKey = template.TemplateKey ?? template.StandardTemplateKey ?? OrderTemplateKeys.DedicatedSmallParcel;
|
|
var exampleOverrides = await BuildTemplateExampleOverridesAsync(customerId);
|
|
var dictOptions = await LoadTemplateDictOptionsAsync(
|
|
["sales_product", "cargo_attribute", "export_customs_mode", "currency", "warehouse"]);
|
|
var fileBytes = await OrderTemplateExcelGenerator.BuildStandardTemplateAsync(
|
|
templateKey,
|
|
exampleOverrides,
|
|
dictOptions);
|
|
|
|
return new FileContentResult(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
{
|
|
FileDownloadName = $"{template.Name}-{DateTime.Now:yyyyMMddHHmmss}.xlsx"
|
|
};
|
|
}
|
|
|
|
private async Task<OrderUploadOutput> ImportOrdersAsync(OrderUploadInput input)
|
|
{
|
|
if (input.File == null || input.File.Length == 0)
|
|
{
|
|
throw new BusinessException("上传文件不能为空");
|
|
}
|
|
|
|
var batchInput = await BuildImportBatchJobInputAsync(input);
|
|
var operatorUserId = CurrentUser.GetId<int>();
|
|
|
|
if (BatchJobPublisher.IsEnabled)
|
|
{
|
|
var job = await BatchJobCoordinator.CreatePayloadJobAsync(batchInput, operatorUserId);
|
|
return new OrderUploadOutput { JobId = job.JobId, Async = true };
|
|
}
|
|
|
|
var syncJob = await BatchJobCoordinator.CreatePayloadJobAndProcessSyncAsync(batchInput, operatorUserId);
|
|
return new OrderUploadOutput { JobId = syncJob.JobId, Async = false };
|
|
}
|
|
|
|
private async Task<OrderBatchPayloadJobCreateInput> BuildImportBatchJobInputAsync(OrderUploadInput input)
|
|
{
|
|
var template = await OrderTemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId)
|
|
?? throw new BusinessException("模板不存在");
|
|
|
|
var effectiveCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
|
|
EnsureTemplateAccessible(template, effectiveCustomer.Id);
|
|
OrderTemplateImportMapping.EnsureTemplateReadyForImport(template);
|
|
|
|
var mapping = OrderTemplateImportMapping.Resolve(template);
|
|
await using var stream = input.File!.OpenReadStream();
|
|
var parsedRows = await OrderTemplateExcelReader.ReadDataRowsAsync(stream, mapping);
|
|
|
|
if (parsedRows.Count == 0)
|
|
{
|
|
throw new BusinessException("Excel 中未读取到可用数据");
|
|
}
|
|
|
|
if (parsedRows.Count > ParcelBatchMaxCount)
|
|
{
|
|
throw new BusinessException($"单次最多导入 {ParcelBatchMaxCount} 行");
|
|
}
|
|
|
|
var senderAddressId = await ResolveImportSenderAddressIdAsync(
|
|
effectiveCustomer.Id,
|
|
input.SenderAddressId,
|
|
cancellationToken: default);
|
|
|
|
var taskName = $"批量下单_{DateTime.Now:yyyyMMddHHmmss}";
|
|
var requestId = $"import-{effectiveCustomer.Id}-{template.Id}-{DateTime.Now:yyyyMMddHHmmssfff}";
|
|
|
|
var jobPayload = JsonConvert.SerializeObject(new OrderBatchImportJobPayload
|
|
{
|
|
TaskName = taskName,
|
|
TemplateId = template.Id,
|
|
CustomerId = effectiveCustomer.Id,
|
|
CustomerSenderAddressId = senderAddressId,
|
|
});
|
|
|
|
var payloadItems = parsedRows.Select(row =>
|
|
{
|
|
var parcelInput = OrderTemplateRowMapper.MapToParcelCreateInput(row.SourceValues, mapping, effectiveCustomer.Id);
|
|
return new OrderBatchPayloadItemInput
|
|
{
|
|
OrderId = 0,
|
|
ServiceNo = parcelInput.ServiceNo,
|
|
ClientNo = parcelInput.ClientNo,
|
|
ItemKey = $"import:{requestId}:{row.RowNumber}",
|
|
PayloadJson = JsonConvert.SerializeObject(new OrderBatchImportRowPayload
|
|
{
|
|
RowNumber = row.RowNumber,
|
|
CustomerId = effectiveCustomer.Id,
|
|
TemplateId = template.Id,
|
|
CustomerSenderAddressId = senderAddressId,
|
|
RowDataJson = JsonConvert.SerializeObject(row.SourceValues),
|
|
}),
|
|
};
|
|
}).ToList();
|
|
|
|
return new OrderBatchPayloadJobCreateInput
|
|
{
|
|
Action = OrderBatchActionCodes.ImportRow,
|
|
CustomerId = effectiveCustomer.Id,
|
|
JobPayloadJson = jobPayload,
|
|
RequestId = requestId,
|
|
Items = payloadItems,
|
|
};
|
|
}
|
|
|
|
private async Task<string> CreateOrderPrintFileAsync(OrderOutput order)
|
|
{
|
|
var fileName = $"{order.ServiceNo}-{DateTime.Now:yyyyMMddHHmmss}.html";
|
|
var html = $"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"><title>{order.ServiceNo}</title></head>
|
|
<body>
|
|
<h2>Order Label</h2>
|
|
<p>ServiceNo: {order.ServiceNo}</p>
|
|
<p>ClientNo: {order.ClientNo}</p>
|
|
<p>TrackNo: {order.TrackNo}</p>
|
|
<p>Warehouse: {order.Warehouse}</p>
|
|
</body>
|
|
</html>
|
|
""";
|
|
var relativeKey = $"uploads/prints/orders/{fileName}";
|
|
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(html));
|
|
var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "text/html; charset=utf-8");
|
|
return FileStorage.GetPublicUrl(key);
|
|
}
|
|
|
|
private string GetOrderExportDirectory() => Path.Combine(GetWebRootDirectory(), "uploads", "exports", "orders");
|
|
|
|
private string GetWebRootDirectory()
|
|
{
|
|
var webRoot = string.IsNullOrWhiteSpace(WebHostEnvironment.WebRootPath)
|
|
? Path.Combine(WebHostEnvironment.ContentRootPath, "wwwroot")
|
|
: WebHostEnvironment.WebRootPath;
|
|
// 只确保 uploads 子目录可写,避免在只读 ContentRoot 上强建 wwwroot 根目录。
|
|
try
|
|
{
|
|
Directory.CreateDirectory(Path.Combine(webRoot, "uploads"));
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
// 容器非 root + /app/wwwroot 未预建/权限错误时,导出清理等旁路不应炸主流程
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
|
|
return webRoot;
|
|
}
|
|
|
|
private async Task CleanupExpiredLocalExportFilesAsync()
|
|
{
|
|
// COS 侧建议用生命周期规则清理;本地仍清理 24h 前导出文件。
|
|
if (!FileStorage.IsLocalProvider())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await CleanupExpiredFilesAsync(GetOrderExportDirectory());
|
|
}
|
|
|
|
private async Task CleanupExpiredFilesAsync(string directory)
|
|
{
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Run(() =>
|
|
{
|
|
foreach (var file in Directory.GetFiles(directory))
|
|
{
|
|
if (File.GetCreationTime(file) <= DateTime.Now.AddHours(-24))
|
|
{
|
|
File.Delete(file);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private static string? ResolveConsigneeCountryName(
|
|
Order order,
|
|
IReadOnlyDictionary<string, Country> countryMap)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(order.ConsigneeCountryName))
|
|
{
|
|
return order.ConsigneeCountryName.Trim();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(order.ConsigneeCountry))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return countryMap.TryGetValue(order.ConsigneeCountry.Trim(), out var country)
|
|
? country.ChineseName
|
|
: null;
|
|
}
|
|
|
|
private static OrderSenderOutput? MapSender(
|
|
OrderSender? sender,
|
|
IReadOnlyDictionary<int, string> countryIdCodeMap)
|
|
{
|
|
return sender == null
|
|
? null
|
|
: new OrderSenderOutput
|
|
{
|
|
SenderName = sender.SenderName,
|
|
Company = sender.Company,
|
|
Phone = sender.Phone,
|
|
TelePhone = sender.TelePhone,
|
|
Email = sender.Email,
|
|
CountryCode = ResolveCountryCode(sender.Country, countryIdCodeMap),
|
|
Province = sender.Province,
|
|
City = sender.City,
|
|
ZipCode = sender.ZipCode,
|
|
StreetAddress1 = sender.StreetAddress1,
|
|
StreetAddress2 = sender.StreetAddress2,
|
|
StreetAddress3 = sender.StreetAddress3,
|
|
HouseNumber = sender.HouseNumber
|
|
};
|
|
}
|
|
|
|
private static OrderReceiverOutput? MapReceiver(
|
|
OrderRecipient? recipient,
|
|
IReadOnlyDictionary<int, string> countryIdCodeMap)
|
|
{
|
|
return recipient == null
|
|
? null
|
|
: new OrderReceiverOutput
|
|
{
|
|
RecipientsName = recipient.RecipientsName,
|
|
CompanyName = recipient.CompanyName,
|
|
Phone = recipient.Phone,
|
|
TelePhone = recipient.TelePhone,
|
|
Email = recipient.Email,
|
|
CountryCode = ResolveCountryCode(recipient.Country, countryIdCodeMap),
|
|
Province = recipient.Province,
|
|
City = recipient.City,
|
|
StreetNumber = recipient.StreetNumber,
|
|
StreetAddress1 = recipient.StreetAddress1,
|
|
StreetAddress2 = recipient.StreetAddress2,
|
|
StreetAddress3 = recipient.StreetAddress3,
|
|
ZipCode = recipient.ZipCode,
|
|
TaxType = recipient.TaxType,
|
|
TaxNo = recipient.TaxNo,
|
|
CertificateType = recipient.CertificateType,
|
|
CertificateNo = recipient.CertificateNo,
|
|
Eori = recipient.Eori,
|
|
Vat = recipient.Vat
|
|
};
|
|
}
|
|
|
|
private static string? ResolveCountryCode(
|
|
int? countryId,
|
|
IReadOnlyDictionary<int, string> countryIdCodeMap)
|
|
{
|
|
if (!countryId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return countryIdCodeMap.TryGetValue(countryId.Value, out var code) && !string.IsNullOrWhiteSpace(code)
|
|
? code
|
|
: null;
|
|
}
|
|
|
|
private OrderItemOutput MapDeclaration(OrderDeclaration x)
|
|
{
|
|
return new OrderItemOutput
|
|
{
|
|
CnName = x.CnName,
|
|
EnName = x.EnName,
|
|
Sku = x.Sku,
|
|
Unit = x.Unit,
|
|
UnitPrice = x.UnitPrice,
|
|
UnitWeight = x.UnitWeight,
|
|
Quantity = x.Quantity,
|
|
Amount = x.Amount,
|
|
Weight = x.Weight,
|
|
HsCode = x.ExHsCode ?? x.ImHsCode ?? x.HsCode,
|
|
ImHsCode = x.ImHsCode ?? x.HsCode,
|
|
ExHsCode = x.ExHsCode ?? x.HsCode,
|
|
DeclareElement = x.DeclareElement,
|
|
SkuImage = FileStorage.GetPublicUrl(x.SkuImage),
|
|
Category = x.Category,
|
|
Brand = x.Brand,
|
|
BrandType = x.BrandType,
|
|
IsMagnetic = x.IsMagnetic > 0,
|
|
Distribution = x.Distribution,
|
|
Material = x.Material,
|
|
EnMaterial = x.EnMaterial,
|
|
Purpose = x.Purpose,
|
|
Attributes = x.Attributes,
|
|
OriginCountry = x.OriginCountry,
|
|
SalesLink = x.SalesLink,
|
|
Model = x.Model,
|
|
Specifications = x.Specifications,
|
|
Comment = x.Comment
|
|
};
|
|
}
|
|
|
|
private static OrderWeightOutput MapWeight(OrderWeight x)
|
|
{
|
|
return new OrderWeightOutput
|
|
{
|
|
Type = x.Type,
|
|
Length = x.Length,
|
|
Width = x.Width,
|
|
Height = x.Height,
|
|
ActualWeight = x.ActualWeight,
|
|
VolumetricWeight = x.VolumetricWeight,
|
|
ChargeableWeight = x.ChargeableWeight,
|
|
Cubic = x.Cubic
|
|
};
|
|
}
|
|
|
|
private static OrderFeeOutput MapFee(OrderFee x) =>
|
|
new()
|
|
{
|
|
Id = x.Id,
|
|
Type = x.Type,
|
|
CostItem = x.CostItem,
|
|
Amount = x.Amount,
|
|
Currency = x.Currency,
|
|
DownstreamProvider = x.DownstreamProvider,
|
|
PriceNo = x.PriceNo,
|
|
PriceEffectiveTime = x.PriceEffectiveTime,
|
|
ChargingStage = x.ChargingStage
|
|
};
|
|
|
|
private static decimal? ResolveDisplayChargeableWeight(IReadOnlyList<OrderWeight>? weights)
|
|
{
|
|
if (weights == null || weights.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive);
|
|
if (receive?.ChargeableWeight is > 0)
|
|
{
|
|
return receive.ChargeableWeight;
|
|
}
|
|
|
|
var forecast = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Forecast);
|
|
return forecast?.ChargeableWeight;
|
|
}
|
|
|
|
private static string? GetStringCell(IDictionary<string, object> row, string key) => row.TryGetValue(key, out var value) ? value?.ToString() : null;
|
|
private static int? GetintCell(IDictionary<string, object> row, string key) => int.TryParse(GetStringCell(row, key), out var value) ? value : null;
|
|
private static int? GetIntCell(IDictionary<string, object> row, string key) => int.TryParse(GetStringCell(row, key), out var value) ? value : null;
|
|
private static decimal? GetDecimalCell(IDictionary<string, object> row, string key) => decimal.TryParse(GetStringCell(row, key), out var value) ? value : null;
|
|
private static bool GetBoolCell(IDictionary<string, object> row, string key) => GetStringCell(row, key) is "1" or "true" or "True" or "TRUE" or "是";
|
|
|
|
private static void EnsureTemplateAccessible(OrderTemplate template, int effectiveCustomerId)
|
|
{
|
|
if (string.Equals(template.Type, "standard", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (template.CustomerId != effectiveCustomerId)
|
|
{
|
|
throw new BusinessException("模板不存在");
|
|
}
|
|
}
|
|
|
|
private OrderWeight? BuildForecastWeight(int orderId, IDictionary<string, object> row)
|
|
{
|
|
var length = GetDecimalCell(row, "Length");
|
|
var width = GetDecimalCell(row, "Width");
|
|
var height = GetDecimalCell(row, "Height");
|
|
var actualWeight = GetDecimalCell(row, "ActualWeight");
|
|
var volumetricWeight = GetDecimalCell(row, "VolumetricWeight");
|
|
var chargeableWeight = GetDecimalCell(row, "ChargeableWeight");
|
|
var cubic = GetDecimalCell(row, "Cubic");
|
|
if (!length.HasValue && !width.HasValue && !height.HasValue && !actualWeight.HasValue && !volumetricWeight.HasValue && !chargeableWeight.HasValue && !cubic.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new OrderWeight
|
|
{
|
|
OrderId = orderId,
|
|
Type = OrderWeightTypeCodes.Forecast,
|
|
Length = length,
|
|
Width = width,
|
|
Height = height,
|
|
ActualWeight = actualWeight,
|
|
VolumetricWeight = volumetricWeight,
|
|
ChargeableWeight = chargeableWeight,
|
|
Cubic = cubic
|
|
};
|
|
}
|
|
|
|
private async Task ApplyInternalOrderDataScopeAsync(OrderListFilter filter)
|
|
{
|
|
if (OmsSessionHelper.IsCustomerLogin(CurrentUser))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var userGuidRaw = CurrentUser.FindClaimValueOrNull(global::System.Security.Claims.ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(userGuidRaw) || !Guid.TryParse(userGuidRaw, out var userGuid))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var context = await DataScopeBuilder.BuildAsync(userGuid, DataScopeApplier.OrderResourceCode);
|
|
DataScopeApplier.ApplyToOrderListFilter(filter, context);
|
|
}
|
|
}
|