diff --git a/src/Oms.Application/Dtos/Channels/SalesProductPriceListOutput.cs b/src/Oms.Application/Dtos/Channels/SalesProductPriceListOutput.cs index 4c843f9..0f8b583 100644 --- a/src/Oms.Application/Dtos/Channels/SalesProductPriceListOutput.cs +++ b/src/Oms.Application/Dtos/Channels/SalesProductPriceListOutput.cs @@ -65,8 +65,18 @@ public class SalesProductPriceListOutput /// public DateTime CreateTime { get; set; } + /// + /// 创建人。 + /// + public string? CreateBy { get; set; } + /// /// 更新时间。 /// public DateTime? UpdateTime { get; set; } + + /// + /// 修改人。 + /// + public string? UpdateBy { get; set; } } diff --git a/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs b/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs new file mode 100644 index 0000000..9dfb3ca --- /dev/null +++ b/src/Oms.Application/Dtos/Operations/InboundOrderLookupOutput.cs @@ -0,0 +1,62 @@ +namespace Oms.Application.Dtos.Operations; + +/// +/// 入库扫描按单号查订单(只读展示销售产品、目的国)。 +/// +public class InboundOrderLookupOutput +{ + /// + /// 订单主键。 + /// + public int OrderId { get; set; } + + /// + /// 服务单号。 + /// + public string ServiceNo { get; set; } = string.Empty; + + /// + /// 客户单号。 + /// + public string? ClientNo { get; set; } + + /// + /// 跟踪号。 + /// + public string? TrackNo { get; set; } + + /// + /// 产品编码。 + /// + public string? ProductCode { get; set; } + + /// + /// 销售产品主键。 + /// + public int? SalesProductId { get; set; } + + /// + /// 销售产品名称。 + /// + public string? SalesProductName { get; set; } + + /// + /// 目的国家编码。 + /// + public string? DestinationCountry { get; set; } + + /// + /// 目的国家名称。 + /// + public string? DestinationCountryName { get; set; } + + /// + /// 订单状态码。 + /// + public string OrderStatus { get; set; } = string.Empty; + + /// + /// 订单状态名称。 + /// + public string? OrderStatusName { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs new file mode 100644 index 0000000..bc6a90f --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderFeeOutput.cs @@ -0,0 +1,52 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 订单费用出参。 +/// +public class OrderFeeOutput +{ + /// + /// 主键。 + /// + public int Id { get; set; } + + /// + /// 类型:receivable / payable。 + /// + public string Type { get; set; } = string.Empty; + + /// + /// 费用项目。 + /// + public string CostItem { get; set; } = string.Empty; + + /// + /// 金额。 + /// + public decimal Amount { get; set; } + + /// + /// 币种。 + /// + public string Currency { get; set; } = string.Empty; + + /// + /// 下游服务商。 + /// + public string? DownstreamProvider { get; set; } + + /// + /// 价格版本号。 + /// + public string? PriceNo { get; set; } + + /// + /// 价格生效时间。 + /// + public DateTime? PriceEffectiveTime { get; set; } + + /// + /// 计费重量阶段。 + /// + public string? ChargingStage { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderOutput.cs b/src/Oms.Application/Dtos/Orders/OrderOutput.cs index 9e21deb..b8d347a 100644 --- a/src/Oms.Application/Dtos/Orders/OrderOutput.cs +++ b/src/Oms.Application/Dtos/Orders/OrderOutput.cs @@ -149,4 +149,19 @@ public class OrderOutput /// 重量明细集合。 /// public List WeightList { get; set; } = []; + + /// + /// 费用明细集合。 + /// + public List FeeList { get; set; } = []; + + /// + /// 计费重(优先收货行,其次预报行)。 + /// + public decimal? ChargeableWeight { get; set; } + + /// + /// 应收合计(当前有效应收费用之和)。 + /// + public decimal? ReceivableAmount { get; set; } } diff --git a/src/Oms.Application/Dtos/Orders/OrderUpdateInput.cs b/src/Oms.Application/Dtos/Orders/OrderUpdateInput.cs new file mode 100644 index 0000000..d9bc676 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderUpdateInput.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 订单详情编辑入参。 +/// +public class OrderUpdateInput +{ + /// + /// 订单主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 客户单号。 + /// + [StringLength(64)] + public string? ClientNo { get; set; } + + /// + /// 跟踪号。 + /// + [StringLength(64)] + public string? TrackNo { get; set; } + + /// + /// 渠道编码。 + /// + [StringLength(64)] + public string? ChannelCode { get; set; } + + /// + /// 货物属性。 + /// + [StringLength(32)] + public string? CargoAttribute { get; set; } + + /// + /// 仓库主键。 + /// + public int? WarehouseId { get; set; } + + /// + /// 是否 COD。 + /// + public bool? IsCod { get; set; } + + /// + /// COD 币种。 + /// + [StringLength(8)] + public string? CodCurrency { get; set; } + + /// + /// COD 金额。 + /// + public decimal? CodSum { get; set; } + + /// + /// 申报币种。 + /// + [StringLength(8)] + public string? DeclaredCurrency { get; set; } + + /// + /// 申报金额。 + /// + public decimal? DeclaredSum { get; set; } + + /// + /// 件数。 + /// + public int? Quantity { get; set; } + + /// + /// 寄件人。 + /// + public OrderParcelSenderInput? Sender { get; set; } + + /// + /// 收件人。 + /// + public OrderParcelReceiverInput? Receiver { get; set; } + + /// + /// 申报明细;传入时全量替换原申报行。 + /// + public List? Items { get; set; } +} diff --git a/src/Oms.Application/Dtos/Orders/OrderWeightUpdateInput.cs b/src/Oms.Application/Dtos/Orders/OrderWeightUpdateInput.cs new file mode 100644 index 0000000..738f0e2 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderWeightUpdateInput.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 订单件重尺编辑入参。 +/// +public class OrderWeightUpdateInput +{ + /// + /// 订单主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 件重尺行。 + /// + [Required] + public List Weights { get; set; } = []; +} + +/// +/// 单条件重尺编辑项。 +/// +public class OrderWeightItemInput +{ + /// + /// 类型:forecast/receive/ship/downstream。 + /// + [Required] + [StringLength(16)] + public string Type { get; set; } = string.Empty; + + /// + /// 长度(cm)。 + /// + public decimal? Length { get; set; } + + /// + /// 宽度(cm)。 + /// + public decimal? Width { get; set; } + + /// + /// 高度(cm)。 + /// + public decimal? Height { get; set; } + + /// + /// 实重(kg)。 + /// + public decimal? ActualWeight { get; set; } +} diff --git a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs index c2b4372..df8f7c4 100644 --- a/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs +++ b/src/Oms.Application/Services/Channels/SalesProductService.Helpers.cs @@ -88,7 +88,9 @@ public partial class SalesProductService Factors = factorMap.TryGetValue(price.Id, out var factors) ? factors.Select(MapPriceFactorOutput).ToList() : [], Details = detailMap.TryGetValue(price.Id, out var details) ? details.Select(MapPriceDetailOutput).ToList() : [], CreateTime = price.CreateTime, - UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime) + CreateBy = price.CreateUserId <= 0 ? null : price.CreateUserId.ToString(), + UpdateTime = OmsDocHelper.NullIfDefault(price.UpdateTime), + UpdateBy = price.UpdateUserId <= 0 ? null : price.UpdateUserId.ToString() }).ToList(); } diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index 524958f..b039cb3 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Mvc; using Oms.Application.Dtos.Operations; using Oms.Application.Dtos.Shared; using Oms.Application.Services.Orders; +using Oms.Application.Shared; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OperationsAggregate.Entities; using Oms.Domain.OperationsAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.Operations; @@ -17,10 +19,60 @@ namespace Oms.Application.Services.Operations; public class InboundRecordService : ApplicationService { private IInboundRecordRepository InboundRecordRepository => LazyServiceProvider.GetRequiredService(); + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); + private ICountryRepository CountryRepository => LazyServiceProvider.GetRequiredService(); private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + /// + /// 按单号查询订单(入库扫描展示销售产品、目的国,只读)。 + /// + [HttpGet("/operation/inbound/order-lookup")] + public async Task LookupOrderAsync([FromQuery] string waybillNo) + { + if (string.IsNullOrWhiteSpace(waybillNo)) + { + throw new BusinessException("请扫描或输入单号"); + } + + var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); + if (order == null) + { + throw new BusinessException("未找到对应订单,请检查单号"); + } + + var output = new InboundOrderLookupOutput + { + OrderId = order.Id, + ServiceNo = order.ServiceNo, + ClientNo = order.ClientNo, + TrackNo = order.TrackNo, + ProductCode = order.ProductCode, + DestinationCountry = order.ConsigneeCountry, + OrderStatus = order.OrderStatus, + OrderStatusName = OmsDocHelper.GetOrderStatusName(order.OrderStatus) + }; + + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product != null) + { + output.SalesProductId = product.Id; + output.SalesProductName = product.ProductName; + } + } + + if (!string.IsNullOrWhiteSpace(order.ConsigneeCountry)) + { + var country = await CountryRepository.FirstOrDefaultAsync(x => x.CountryCode == order.ConsigneeCountry); + output.DestinationCountryName = country?.ChineseName ?? country?.EnglishName ?? order.ConsigneeCountry; + } + + return output; + } + /// /// 新增入库记录。 /// @@ -35,29 +87,55 @@ public class InboundRecordService : ApplicationService [HttpPost("/operation/inbound/create")] public async Task CreateAsync(CreateInboundRecordInput input) { + if (string.IsNullOrWhiteSpace(input.WaybillNo)) + { + throw new BusinessException("运单号不能为空"); + } + + var order = await OrderRepository.GetByWaybillNoAsync(input.WaybillNo.Trim()); + if (order == null) + { + throw new BusinessException("未找到对应订单,请检查单号"); + } + + if (!input.Weight.HasValue || !input.Length.HasValue || !input.Width.HasValue || !input.Height.HasValue + || input.Weight <= 0 || input.Length <= 0 || input.Width <= 0 || input.Height <= 0) + { + throw new BusinessException("收货实重与长宽高不能为空且须大于 0"); + } + + int? salesProductId = null; + string? destinationCountry = order.ConsigneeCountry; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + salesProductId = product?.Id; + } + var now = DateTime.Now; var operatorId = CurrentUser.GetId(); var entity = new InboundRecord { - WaybillNo = input.WaybillNo, + WaybillNo = input.WaybillNo.Trim(), Weight = input.Weight, Length = input.Length, Width = input.Width, Height = input.Height, - SalesProductId = input.SalesProductId, - DestinationCountry = input.DestinationCountry, - WarehouseId = input.WarehouseId, + SalesProductId = salesProductId, + DestinationCountry = destinationCountry, + WarehouseId = input.WarehouseId ?? order.WarehouseId, OperateTime = now, OperatorId = operatorId }; - var savedEntity = await InboundRecordRepository.InsertAsync(entity); await OrderService.ApplyReceiveBillingByWaybillAsync( - input.WaybillNo, + input.WaybillNo.Trim(), input.Length, input.Width, input.Height, input.Weight); + + var savedEntity = await InboundRecordRepository.InsertAsync(entity); return ObjectMapper.Map(savedEntity); } diff --git a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs index 3b53e29..79dd976 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Helpers.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Helpers.cs @@ -27,6 +27,7 @@ public partial class OrderService 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)); @@ -35,6 +36,7 @@ public partial class OrderService 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); return orders.Select(order => @@ -43,6 +45,12 @@ public partial class OrderService 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); return new OrderOutput { Id = order.Id, @@ -75,7 +83,10 @@ public partial class OrderService Sender = MapSender(sender), Receiver = MapReceiver(recipient), Items = (items ?? []).Select(MapDeclaration).ToList(), - WeightList = (orderWeights ?? []).Select(MapWeight).ToList() + WeightList = (orderWeights ?? []).Select(MapWeight).ToList(), + FeeList = feeOutputs, + ChargeableWeight = chargeableWeight, + ReceivableAmount = feeOutputs.Count == 0 ? null : receivableAmount }; }).ToList(); } @@ -543,6 +554,37 @@ public partial class OrderService }; } + 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? 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 OrderTaskFail BuildTaskFail(int taskId, int rowNumber, string reason, object rawData) { return new OrderTaskFail diff --git a/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs new file mode 100644 index 0000000..a8c92ac --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.OrderEdit.cs @@ -0,0 +1,648 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Channels; +using Oms.Application.Dtos.Orders; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Enums; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private IServiceChannelRepository ServiceChannelRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 编辑订单详情(基本信息、发件人、收件人)。 + /// + [HttpPost("/order/update")] + public async Task UpdateOrderDetailAsync(OrderUpdateInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + + if (input.ClientNo != null) + { + order.ClientNo = string.IsNullOrWhiteSpace(input.ClientNo) ? null : input.ClientNo.Trim(); + } + + if (input.TrackNo != null) + { + order.TrackNo = string.IsNullOrWhiteSpace(input.TrackNo) ? null : input.TrackNo.Trim(); + } + + if (input.ChannelCode != null) + { + order.ChannelCode = string.IsNullOrWhiteSpace(input.ChannelCode) ? null : input.ChannelCode.Trim(); + } + + if (input.CargoAttribute != null) + { + order.CargoAttribute = input.CargoAttribute.Trim(); + } + + if (input.WarehouseId.HasValue) + { + order.WarehouseId = input.WarehouseId.Value > 0 ? input.WarehouseId : null; + } + + if (input.IsCod.HasValue) + { + order.IsCod = input.IsCod.Value ? (byte)1 : (byte)0; + } + + if (input.CodCurrency != null) + { + order.CodCurrency = input.CodCurrency; + } + + if (input.CodSum.HasValue) + { + order.CodSum = input.CodSum; + } + + if (input.DeclaredCurrency != null) + { + order.DeclaredCurrency = input.DeclaredCurrency; + } + + if (input.DeclaredSum.HasValue) + { + order.DeclaredSum = input.DeclaredSum; + } + + if (input.Quantity.HasValue) + { + order.Quantity = input.Quantity; + } + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + if (input.Sender != null && order.SenderId.HasValue) + { + var senders = await OrderRepository.GetSendersByOrderIdsAsync([order.Id]); + var sender = senders.FirstOrDefault(); + if (sender != null) + { + ApplySenderInput(sender, input.Sender); + if (input.Sender.CountryCode != null) + { + var country = await GetCountryOrThrowAsync(input.Sender.CountryCode, "发件国家不存在"); + sender.Country = country.Id; + } + + await OrderRepository.UpdateSenderAsync(sender); + } + } + + if (input.Receiver != null && order.RecipientId.HasValue) + { + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); + var recipient = recipients.FirstOrDefault(); + if (recipient != null) + { + await ApplyReceiverInputAsync(order, recipient, input.Receiver); + await OrderRepository.UpdateRecipientAsync(recipient); + } + } + + if (input.Items != null) + { + await OrderRepository.ReplaceDeclarationsAsync( + order.Id, + input.Items.Select(x => MapDeclarationInput(order.Id, x))); + } + + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 编辑订单件重尺并重算计费重。 + /// + [HttpPost("/order/weight/update")] + public async Task UpdateOrderWeightsAsync(OrderWeightUpdateInput input) + { + if (input.Weights.Count == 0) + { + throw new BusinessException("件重尺不能为空"); + } + + var order = await GetOrderOrThrowAsync(input.Id); + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法重算计费重"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) + .OrderBy(x => x.Id) + .ToList(); + if (weights.Count == 0) + { + throw new BusinessException("订单件重尺不存在"); + } + + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + var updatedTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in input.Weights) + { + var type = item.Type?.Trim(); + if (string.IsNullOrWhiteSpace(type)) + { + continue; + } + + var row = weights.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)); + if (row == null) + { + throw new BusinessException($"缺少件重尺类型 {type}"); + } + + if (item.Length.HasValue) + { + row.Length = item.Length; + } + + if (item.Width.HasValue) + { + row.Width = item.Width; + } + + if (item.Height.HasValue) + { + row.Height = item.Height; + } + + if (item.ActualWeight.HasValue) + { + row.ActualWeight = item.ActualWeight; + } + + if (row.Length.HasValue && row.Width.HasValue && row.Height.HasValue) + { + row.Cubic = ParcelBillingCalculator.CalculateCubic(row.Length.Value, row.Width.Value, row.Height.Value); + } + + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, row); + updatedTypes.Add(type); + } + + if (updatedTypes.Contains(OrderWeightTypeCodes.Forecast)) + { + var forecast = weights.First(x => x.Type == OrderWeightTypeCodes.Forecast); + SyncWeightRows(weights, forecast); + } + else if (updatedTypes.Contains(OrderWeightTypeCodes.Receive)) + { + var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); + if (receive != null) + { + SyncReceiveToShipAndDownstream(weights, receive); + } + } + else if (updatedTypes.Contains(OrderWeightTypeCodes.Ship)) + { + var ship = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Ship); + if (ship != null) + { + SyncShipToDownstream(weights, ship); + } + } + + await OrderRepository.ReplaceWeightsAsync(order.Id, weights); + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 重算订单应收费用。 + /// + [HttpPost("/order/fee/recalculate-receivable")] + public async Task RecalculateReceivableFeesAsync(OrderActionInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + await RecalculateReceivableCoreAsync(order); + return (await MapOrderOutputsAsync([order])).First(); + } + + /// + /// 重算订单应付费用。 + /// + [HttpPost("/order/fee/recalculate-payable")] + public async Task RecalculatePayableFeesAsync(OrderActionInput input) + { + var order = await GetOrderOrThrowAsync(input.Id); + await RecalculatePayableCoreAsync(order); + return (await MapOrderOutputsAsync([order])).First(); + } + + private async Task RecalculateReceivableCoreAsync(Order order) + { + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法重算应收"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + var (weightType, chargingStage) = ResolveReceivableBillingStage(order); + var weightRow = weights.FirstOrDefault(x => x.Type == weightType) + ?? throw new BusinessException($"缺少件重尺类型 {weightType}"); + if (!weightRow.ChargeableWeight.HasValue || weightRow.ChargeableWeight <= 0) + { + throw new BusinessException("计费重无效,请先完善件重尺"); + } + + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); + var recipient = recipients.FirstOrDefault(); + var zoneKeys = await ResolvePriceZoneKeysAsync( + product, + snapshotBinding.Rules, + snapshotBinding.Price, + order.ConsigneeCountry ?? string.Empty, + recipient?.Province, + recipient?.City, + recipient?.ZipCode); + if (zoneKeys == null || zoneKeys.Count == 0) + { + throw new BusinessException("价格分区未匹配(规则快照)"); + } + + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + + await UpsertReceivableFeesAsync( + order, + snapshotBinding, + weightRow.ChargeableWeight ?? 0, + zoneKeys, + order.CargoAttribute ?? CargoAttributeCodes.General, + customer?.CustomerGroup, + chargingStage, + replaceExistingReceivable: true); + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + } + + private async Task RecalculatePayableCoreAsync(Order order) + { + if (string.IsNullOrWhiteSpace(order.ChannelCode)) + { + throw new BusinessException("订单未绑定服务渠道,无法重算应付"); + } + + var channel = await ServiceChannelRepository.GetByChannelCodeAsync(order.ChannelCode.Trim()); + if (channel == null) + { + throw new BusinessException("服务渠道不存在"); + } + + var weights = await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]); + var downstream = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream) + ?? throw new BusinessException("缺少 downstream 件重尺"); + if (!downstream.ChargeableWeight.HasValue || downstream.ChargeableWeight <= 0) + { + throw new BusinessException("downstream 计费重无效,请先完善件重尺"); + } + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法匹配分区"); + } + + var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + if (product == null) + { + throw new BusinessException("销售产品不存在"); + } + + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + var recipients = await OrderRepository.GetRecipientsByOrderIdsAsync([order.Id]); + var recipient = recipients.FirstOrDefault(); + var zoneKeys = await ResolvePriceZoneKeysAsync( + product, + snapshotBinding.Rules, + snapshotBinding.Price, + order.ConsigneeCountry ?? string.Empty, + recipient?.Province, + recipient?.City, + recipient?.ZipCode); + if (zoneKeys == null || zoneKeys.Count == 0) + { + throw new BusinessException("价格分区未匹配(规则快照)"); + } + + var channelPrices = await BuildChannelPriceOutputsAsync(channel.Id); + var (_, chargingStage) = ResolveReceivableBillingStage(order); + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + + var fees = ParcelBillingCalculator.CalculatePayableFees( + channelPrices, + $"LIVE-{channel.Id}", + order.CreateTime == default ? DateTime.Now : order.CreateTime, + downstream.ChargeableWeight ?? 0, + zoneKeys, + order.CargoAttribute ?? CargoAttributeCodes.General, + customer?.CustomerGroup, + chargingStage); + + if (fees.Count == 0 && ParcelBillingCalculator.HasEnabledPayablePriceItems(channelPrices)) + { + throw new BusinessException("渠道买价未配置或未匹配到有效价格"); + } + + if (fees.Count > 0) + { + await OrderRepository.DeletePayableFeesAsync(order.Id); + await OrderRepository.UpsertFeesAsync(order.Id, fees); + } + + order.UpdateTime = DateTime.Now; + await OrderRepository.UpdateAsync(order); + } + + private async Task> BuildChannelPriceOutputsAsync(int serviceChannelId) + { + var prices = await ServiceChannelRepository.GetPricesAsync(serviceChannelId); + var priceIds = prices.Select(x => x.Id).ToArray(); + var factorMap = (await ServiceChannelRepository.GetPriceFactorsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.SerialNumber).ToList()); + var detailMap = (await ServiceChannelRepository.GetPriceDetailsAsync(priceIds)) + .GroupBy(x => x.PriceId) + .ToDictionary(x => x.Key, x => x.OrderBy(item => item.Id).ToList()); + + return prices.Select(price => new ServiceChannelPriceListOutput + { + Id = price.Id, + ServiceChannelId = price.ServiceChannelId, + SerialNumber = price.SerialNumber, + Status = (byte)price.Status, + PriceName = price.PriceName, + CostItem = price.CostItem, + CostUnit = price.CostUnit, + Currency = price.Currency, + ChargingCondition = price.ChargingCondition, + Factors = factorMap.TryGetValue(price.Id, out var factors) + ? factors.Select(MapChannelPriceFactorOutput).ToList() + : [], + Details = detailMap.TryGetValue(price.Id, out var details) + ? details.Select(MapChannelPriceDetailOutput).ToList() + : [] + }).ToList(); + } + + private static ChannelPriceFactorOutput MapChannelPriceFactorOutput(ServiceChannelPriceFactor entity) => + new() + { + Id = entity.Id, + PriceId = entity.PriceId, + SerialNumber = entity.SerialNumber, + Factor = entity.Factor, + FactorValues = OmsDocHelper.DeserializeJson>(entity.FactorValues) ?? [] + }; + + private static ChannelPriceDetailOutput MapChannelPriceDetailOutput(ServiceChannelPriceDetail entity) => + new() + { + Id = entity.Id, + PriceId = entity.PriceId, + FactorValuesJson = OmsDocHelper.DeserializeJsonObject(entity.FactorValuesJson), + CalculationFormula = entity.CalculationFormula, + CalculatedValue = entity.CalculatedValue, + CustomerGroupValues = OmsDocHelper.DeserializeJsonObject(entity.CustomerGroupValues) + }; + + private static (string WeightType, string ChargingStage) ResolveReceivableBillingStage(Order order) => + order.OrderStatus switch + { + OrderStatusCodes.Received or OrderStatusCodes.PendingPayment or OrderStatusCodes.PendingShip + => (OrderWeightTypeCodes.Receive, OrderFeeChargingStageCodes.Inbound), + OrderStatusCodes.PendingOnline or OrderStatusCodes.Shipping + or OrderStatusCodes.DeliverySuccess or OrderStatusCodes.DeliveryFailed + => (OrderWeightTypeCodes.Ship, OrderFeeChargingStageCodes.Outbound), + _ => (OrderWeightTypeCodes.Forecast, OrderFeeChargingStageCodes.Forecast) + }; + + private static OrderDeclaration MapDeclarationInput(int orderId, OrderParcelItemInput x) + { + return new OrderDeclaration + { + OrderId = orderId, + 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, + ImHsCode = x.ImHsCode, + ExHsCode = x.ExHsCode ?? x.ImHsCode, + SkuImage = x.SkuImage, + Category = x.Category, + Brand = x.Brand, + BrandType = x.BrandType, + IsMagnetic = x.IsMagnetic ? (byte)1 : (byte)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 void ApplySenderInput(OrderSender sender, OrderParcelSenderInput input) + { + if (input.SenderName != null) + { + sender.SenderName = input.SenderName; + } + + if (input.Company != null) + { + sender.Company = input.Company; + } + + if (input.Phone != null) + { + sender.Phone = input.Phone; + } + + if (input.TelePhone != null) + { + sender.TelePhone = input.TelePhone; + } + + if (input.Email != null) + { + sender.Email = input.Email; + } + + if (input.Province != null) + { + sender.Province = input.Province; + } + + if (input.City != null) + { + sender.City = input.City; + } + + if (input.ZipCode != null) + { + sender.ZipCode = input.ZipCode; + } + + if (input.StreetAddress1 != null) + { + sender.StreetAddress1 = input.StreetAddress1; + } + + if (input.StreetAddress2 != null) + { + sender.StreetAddress2 = input.StreetAddress2; + } + + if (input.StreetAddress3 != null) + { + sender.StreetAddress3 = input.StreetAddress3; + } + + if (input.HouseNumber != null) + { + sender.HouseNumber = input.HouseNumber; + } + } + + private async Task ApplyReceiverInputAsync(Order order, OrderRecipient recipient, OrderParcelReceiverInput input) + { + if (input.RecipientsName != null) + { + recipient.RecipientsName = input.RecipientsName; + } + + if (input.CompanyName != null) + { + recipient.CompanyName = input.CompanyName; + } + + if (input.Phone != null) + { + recipient.Phone = input.Phone; + } + + if (input.TelePhone != null) + { + recipient.TelePhone = input.TelePhone; + } + + if (input.Email != null) + { + recipient.Email = input.Email; + } + + if (input.Province != null) + { + recipient.Province = input.Province; + } + + if (input.City != null) + { + recipient.City = input.City; + } + + if (input.StreetNumber != null) + { + recipient.StreetNumber = input.StreetNumber; + } + + if (input.StreetAddress1 != null) + { + recipient.StreetAddress1 = input.StreetAddress1; + } + + if (input.StreetAddress2 != null) + { + recipient.StreetAddress2 = input.StreetAddress2; + } + + if (input.StreetAddress3 != null) + { + recipient.StreetAddress3 = input.StreetAddress3; + } + + if (input.ZipCode != null) + { + recipient.ZipCode = input.ZipCode; + } + + if (input.TaxType.HasValue) + { + recipient.TaxType = input.TaxType; + } + + if (input.TaxNo != null) + { + recipient.TaxNo = input.TaxNo; + } + + if (input.CertificateType.HasValue) + { + recipient.CertificateType = input.CertificateType; + } + + if (input.CertificateNo != null) + { + recipient.CertificateNo = input.CertificateNo; + } + + if (input.Eori != null) + { + recipient.Eori = input.Eori; + } + + if (input.Vat != null) + { + recipient.Vat = input.Vat; + } + + if (input.CountryCode != null) + { + var country = await GetCountryOrThrowAsync(input.CountryCode, "收件国家不存在"); + recipient.Country = country.Id; + order.ConsigneeCountry = country.CountryCode; + await OrderRepository.UpdateAsync(order); + } + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 7577f5e..b52cdf3 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -366,7 +366,8 @@ public partial class OrderService IReadOnlyList zoneKeys, string cargoAttribute, CustomerGroup? customerGroup, - string chargingStage) + string chargingStage, + bool replaceExistingReceivable = false) { var fees = ParcelBillingCalculator.CalculateReceivableFees( snapshotBinding.Price, @@ -385,6 +386,11 @@ public partial class OrderService if (fees.Count > 0) { + if (replaceExistingReceivable) + { + await OrderRepository.DeleteReceivableFeesAsync(order.Id); + } + await OrderRepository.UpsertFeesAsync(order.Id, fees); } } @@ -857,6 +863,23 @@ public partial class OrderService } } + private static void SyncShipToDownstream(List weights, OrderWeight ship) + { + var row = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Downstream); + if (row == null) + { + return; + } + + row.Length = ship.Length; + row.Width = ship.Width; + row.Height = ship.Height; + row.ActualWeight = ship.ActualWeight; + row.VolumetricWeight = ship.VolumetricWeight; + row.ChargeableWeight = ship.ChargeableWeight; + row.Cubic = ship.Cubic; + } + private static Order BuildDraftOrderEntity( OrderParcelCreateInput input, SalesProduct product, @@ -1028,7 +1051,7 @@ public partial class OrderService } /// - /// 入库收货后:覆盖 receive 件重尺、重算计费重,写入 inbound 阶段应收,并同步 ship/downstream 重量行。 + /// 入库收货后:覆盖 receive 件重尺、重算计费重,覆盖写入 inbound 阶段应收,并同步 ship/downstream 重量行。 /// internal async Task ApplyReceiveBillingByWaybillAsync( string waybillNo, @@ -1039,30 +1062,35 @@ public partial class OrderService { if (string.IsNullOrWhiteSpace(waybillNo)) { - return; + throw new BusinessException("运单号不能为空"); } if (!length.HasValue || !width.HasValue || !height.HasValue || !actualWeight.HasValue || length <= 0 || width <= 0 || height <= 0 || actualWeight <= 0) { - return; + throw new BusinessException("收货实重与长宽高不能为空且须大于 0"); } - var order = await OrderRepository.GetByServiceNoAsync(waybillNo.Trim()); - if (order == null || string.IsNullOrWhiteSpace(order.ProductCode)) + var order = await OrderRepository.GetByWaybillNoAsync(waybillNo.Trim()); + if (order == null) { - return; + throw new BusinessException("未找到对应订单,请检查单号"); + } + + if (string.IsNullOrWhiteSpace(order.ProductCode)) + { + throw new BusinessException("订单未绑定销售产品,无法入库计费"); } if (order.OrderStatus is not (OrderStatusCodes.Predeclared or OrderStatusCodes.Received)) { - return; + throw new BusinessException($"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许入库收货"); } var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); if (product == null) { - return; + throw new BusinessException("订单关联的销售产品不存在"); } var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id])) @@ -1071,7 +1099,7 @@ public partial class OrderService var receive = weights.FirstOrDefault(x => x.Type == OrderWeightTypeCodes.Receive); if (receive == null) { - return; + throw new BusinessException("订单缺少收货件重尺记录"); } receive.Length = length; @@ -1080,25 +1108,8 @@ public partial class OrderService receive.ActualWeight = actualWeight; receive.Cubic = ParcelBillingCalculator.CalculateCubic(length.Value, width.Value, height.Value); - ProductSnapshotBinding snapshotBinding; - try - { - snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); - } - catch (BusinessException) - { - return; - } - - try - { - await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); - } - catch (BusinessException) - { - return; - } - + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); + await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, receive); SyncReceiveToShipAndDownstream(weights, receive); await OrderRepository.ReplaceWeightsAsync(order.Id, weights); @@ -1114,28 +1125,22 @@ public partial class OrderService recipient?.ZipCode); if (zoneKeys == null || zoneKeys.Count == 0) { - return; + throw new BusinessException("价格分区未匹配(规则快照)"); } var customer = order.CustomerId.HasValue ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; - try - { - await UpsertReceivableFeesAsync( - order, - snapshotBinding, - receive.ChargeableWeight ?? 0, - zoneKeys, - order.CargoAttribute ?? CargoAttributeCodes.General, - customer?.CustomerGroup, - OrderFeeChargingStageCodes.Inbound); - } - catch (BusinessException) - { - return; - } + await UpsertReceivableFeesAsync( + order, + snapshotBinding, + receive.ChargeableWeight ?? 0, + zoneKeys, + order.CargoAttribute ?? CargoAttributeCodes.General, + customer?.CustomerGroup, + OrderFeeChargingStageCodes.Inbound, + replaceExistingReceivable: true); order.OrderStatus = OrderStatusCodes.Received; order.ReceiveTime = DateTime.Now; diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 7252a22..6587900 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -11,6 +11,8 @@ public interface IOrderRepository : IRepository Task GetByTrackNoAsync(string trackNo, CancellationToken cancellationToken = default); + Task GetByWaybillNoAsync(string waybillNo, CancellationToken cancellationToken = default); + Task GetByCustomerAndClientNoAsync(int customerId, string clientNo, CancellationToken cancellationToken = default); Task GetByRequestIdAsync(string requestId, CancellationToken cancellationToken = default); @@ -29,11 +31,21 @@ public interface IOrderRepository : IRepository Task InsertDeclarationsAsync(IEnumerable declarations, CancellationToken cancellationToken = default); + Task ReplaceDeclarationsAsync(int orderId, IEnumerable declarations, CancellationToken cancellationToken = default); + Task InsertWeightsAsync(IEnumerable weights, CancellationToken cancellationToken = default); Task ReplaceWeightsAsync(int orderId, IEnumerable weights, CancellationToken cancellationToken = default); Task UpsertFeesAsync(int orderId, IEnumerable fees, CancellationToken cancellationToken = default); + Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default); + + Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default); + + Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default); + + Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default); + Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b607128..8bd25c1 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1,4 +1,5 @@ using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; using Oms.SqlSugarCore.Repositories; @@ -22,6 +23,29 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I return FirstOrDefaultAsync(x => x.TrackNo == trackNo, cancellationToken); } + public async Task GetByWaybillNoAsync(string waybillNo, CancellationToken cancellationToken = default) + { + var normalized = waybillNo.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + var order = await GetByServiceNoAsync(normalized, cancellationToken); + if (order != null) + { + return order; + } + + order = await GetByTrackNoAsync(normalized, cancellationToken); + if (order != null) + { + return order; + } + + return await FirstOrDefaultAsync(x => x.ClientNo == normalized, cancellationToken); + } + public Task GetByCustomerAndClientNoAsync(int customerId, string clientNo, CancellationToken cancellationToken = default) { return FirstOrDefaultAsync(x => x.CustomerId == customerId && x.ClientNo == clientNo, cancellationToken); @@ -112,6 +136,17 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I await db.Insertable(items).ExecuteCommandAsync(cancellationToken); } + public async Task ReplaceDeclarationsAsync(int orderId, IEnumerable declarations, CancellationToken cancellationToken = default) + { + var items = declarations.ToArray(); + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable().Where(x => x.OrderId == orderId).ExecuteCommandAsync(cancellationToken); + if (items.Length > 0) + { + await db.Insertable(items).ExecuteCommandAsync(cancellationToken); + } + } + public async Task InsertWeightsAsync(IEnumerable weights, CancellationToken cancellationToken = default) { var items = weights.ToArray(); @@ -171,6 +206,36 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } } + public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Receivable) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task DeletePayableFeesAsync(int orderId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Deleteable() + .Where(x => x.OrderId == orderId && x.Type == OrderFeeTypeCodes.Payable) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateSenderAsync(OrderSender sender, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + sender.UpdateTime = DateTime.Now; + await db.Updateable(sender).ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateRecipientAsync(OrderRecipient recipient, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + recipient.UpdateTime = DateTime.Now; + await db.Updateable(recipient).ExecuteCommandAsync(cancellationToken); + } + public async Task> GetFeesByOrderIdsAsync(IEnumerable orderIds, CancellationToken cancellationToken = default) { var ids = orderIds.Distinct().ToArray();