diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs index 8781ac0..23ad7d3 100644 --- a/src/Oms.Application/Dtos/Agents/AgentDtos.cs +++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs @@ -308,6 +308,12 @@ public class AgentWorkplaceStatsOutput /// 派送成功运单数(全量状态桶)。 public int DeliverySuccess { get; set; } + /// 预计返佣笔数(未锁定)。 + public int EstimatedCommissionCount { get; set; } + + /// 预计返佣金额(未锁定,不计入应打款)。 + public decimal EstimatedCommissionAmount { get; set; } + /// 待结算返佣笔数。 public int PendingCommissionCount { get; set; } diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs new file mode 100644 index 0000000..ca11e1d --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobActionCodes.cs @@ -0,0 +1,14 @@ +namespace Oms.Application.Messaging.Agents; + +/// 代理返佣异步任务动作。 +public static class AgentCommissionJobActionCodes +{ + /// 下单/计费后写或刷新预计返佣。 + public const string Estimate = "estimate"; + + /// 派送终态锁定为待结算。 + public const string Finalize = "finalize"; + + /// 删单/全额退作废。 + public const string Void = "void"; +} diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs new file mode 100644 index 0000000..8a5866c --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobEventHandler.cs @@ -0,0 +1,75 @@ +using Fake.DependencyInjection; +using Fake.EventBus; +using Fake.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Shared; + +namespace Oms.Application.Messaging.Agents; + +/// 消费代理返佣异步任务。 +public sealed class AgentCommissionJobEventHandler( + IServiceScopeFactory scopeFactory, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + : IEventHandler, ITransientDependency +{ + public async Task HandleAsync( + AgentCommissionJobIntegrationEvent @event, + CancellationToken cancellationToken) + { + if (@event.OrderId <= 0 || string.IsNullOrWhiteSpace(@event.Action)) + { + logger.LogWarning( + "AgentCommission job skipped invalid payload OrderId={OrderId} Action={Action}", + @event.OrderId, + @event.Action); + return; + } + + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + using var scope = scopeFactory.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + + try + { + var action = @event.Action.Trim(); + if (string.Equals(action, AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryEstimateOnOrderCreatedAsync(@event.OrderId, cancellationToken); + } + else if (string.Equals(action, AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryAccrueOnDeliveryTerminalAsync( + @event.OrderId, + @event.TriggerOrderStatus ?? string.Empty, + cancellationToken); + } + else if (string.Equals(action, AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + await manager.TryVoidOnOrderCancelledAsync( + @event.OrderId, + @event.Reason ?? "异步作废返佣", + cancellationToken); + } + else + { + logger.LogWarning( + "AgentCommission job unknown Action={Action} OrderId={OrderId}", + @event.Action, + @event.OrderId); + } + } + catch (Exception ex) + { + // 失败 Ack,避免打满队列;明细可后续补偿 + logger.LogWarning( + ex, + "AgentCommission job failed OrderId={OrderId} Action={Action}", + @event.OrderId, + @event.Action); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } +} diff --git a/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs new file mode 100644 index 0000000..a5b168d --- /dev/null +++ b/src/Oms.Application/Messaging/Agents/AgentCommissionJobIntegrationEvent.cs @@ -0,0 +1,21 @@ +using Fake.EventBus.Distributed; + +namespace Oms.Application.Messaging.Agents; + +/// +/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。 +/// +[Serializable] +public class AgentCommissionJobIntegrationEvent : IntegrationEvent +{ + public int OrderId { get; set; } + + /// 。 + public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate; + + /// finalize 时的触发运单状态。 + public string? TriggerOrderStatus { get; set; } + + /// void 时的原因备注。 + public string? Reason { get; set; } +} diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs index 4a31055..9a973e6 100644 --- a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs +++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs @@ -1,6 +1,8 @@ using Fake.DependencyInjection; +using Fake.EventBus.Distributed; using Fake.UnitOfWork; using Microsoft.Extensions.Logging; +using Oms.Application.Messaging.Agents; using Oms.Application.Services.Orders; using Oms.Application.Shared; using Oms.Domain.OrdersAggregate.Consts; @@ -16,7 +18,7 @@ public sealed class TrackChangeOrderStatusApplier( IOrderRepository orderRepository, OrderOperationLogWriter operationLogWriter, ReferralCommissionManager referralCommissionManager, - AgentCommissionManager agentCommissionManager, + IDistributedEventBus eventBus, MarketingCouponManager marketingCouponManager, IUnitOfWorkManager unitOfWorkManager, ILogger logger) : ITrackChangeOrderStatusApplier, IScopedDependency @@ -133,17 +135,21 @@ public sealed class TrackChangeOrderStatusApplier( // 重新加载,避免营销侧依赖最新字段 order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken); - await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( - order.Id, - OrderStatusCodes.DeliverySuccess, - cancellationToken); + await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent + { + OrderId = order.Id, + Action = AgentCommissionJobActionCodes.Finalize, + TriggerOrderStatus = OrderStatusCodes.DeliverySuccess + }); } else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase)) { - await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( - order.Id, - OrderStatusCodes.DeliveryFailed, - cancellationToken); + await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent + { + OrderId = order.Id, + Action = AgentCommissionJobActionCodes.Finalize, + TriggerOrderStatus = OrderStatusCodes.DeliveryFailed + }); } order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs index 4b1b670..f354267 100644 --- a/src/Oms.Application/Services/Agents/AgentAdminService.cs +++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs @@ -254,7 +254,8 @@ public class AgentAdminService : ApplicationService var remark = NormalizeOptional(input.Remark); foreach (var item in list) { - if (string.Equals(item.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)) + // 仅「待结算」可标记打款;预计/作废/已打款跳过 + if (!string.Equals(item.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase)) { continue; } diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs index f863acf..de09879 100644 --- a/src/Oms.Application/Services/Agents/AgentPortalService.cs +++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs @@ -202,6 +202,8 @@ public class AgentPortalService : ApplicationService && x.AgentBindTime >= weekStart && x.AgentBindTime < tomorrow); + var estimatedDetails = await DetailRepository.GetListAsync(x => + x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Estimated); var pendingDetails = await DetailRepository.GetListAsync(x => x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending); var paidDetails = await DetailRepository.GetListAsync(x => @@ -218,6 +220,8 @@ public class AgentPortalService : ApplicationService Problematic = GetCount(OrderStatusCodes.Problematic), DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess), DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed), + EstimatedCommissionCount = estimatedDetails.Count, + EstimatedCommissionAmount = estimatedDetails.Sum(x => x.CommissionAmount), PendingCommissionCount = pendingDetails.Count, PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount), PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount), @@ -315,9 +319,7 @@ public class AgentPortalService : ApplicationService 基数金额 = x.BaseAmount, 返佣比例 = x.CommissionRate, 返佣金额 = x.CommissionAmount, - 状态 = string.Equals(x.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) - ? "已打款" - : "待结算", + 状态 = AgentCommissionStatusCodes.DisplayName(x.Status), 触发状态 = x.TriggerOrderStatus, 计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"), 打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index e7bfc51..68bd43e 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -186,6 +186,9 @@ public partial class OrderService createContext); order.AgentId = orderAgentId; order = await OrderRepository.InsertAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Estimate); var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); @@ -2955,6 +2958,9 @@ public partial class OrderService order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Estimate); // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 @@ -3522,6 +3528,55 @@ public partial class OrderService agentRepository); } + /// + /// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。 + /// + private async Task EnqueueAgentCommissionJobAsync( + int orderId, + string action, + string? triggerOrderStatus = null, + string? reason = null) + { + if (orderId <= 0 || string.IsNullOrWhiteSpace(action)) + { + return; + } + + var uowManager = LazyServiceProvider.GetRequiredService(); + if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) + { + await EventBus.PublishAsync(new Messaging.Agents.AgentCommissionJobIntegrationEvent + { + OrderId = orderId, + Action = action.Trim(), + TriggerOrderStatus = triggerOrderStatus, + Reason = reason + }); + return; + } + + try + { + var mgr = LazyServiceProvider.GetRequiredService(); + if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Estimate, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryEstimateOnOrderCreatedAsync(orderId); + } + else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Finalize, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryAccrueOnDeliveryTerminalAsync(orderId, triggerOrderStatus ?? string.Empty); + } + else if (string.Equals(action, Messaging.Agents.AgentCommissionJobActionCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + await mgr.TryVoidOnOrderCancelledAsync(orderId, reason ?? "同步作废返佣"); + } + } + catch + { + // 返佣失败不阻断主业务 + } + } + private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId) { var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index b42546b..2a4e99c 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -349,11 +349,11 @@ public partial class OrderService : ApplicationService await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess); } - private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) - { - var agentCommission = LazyServiceProvider.GetRequiredService(); - await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus); - } + private Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) => + EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Finalize, + triggerOrderStatus: triggerStatus); /// /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。 @@ -376,19 +376,27 @@ public partial class OrderService : ApplicationService var commissionManager = LazyServiceProvider.GetRequiredService(); await commissionManager.ClawbackOnOrderRefundAsync(order); - if (input.IsFullRefund && order.CustomerId.HasValue) + if (input.IsFullRefund) { - var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); - if (customer != null) + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Void, + reason: "订单全额退款作废返佣"); + + if (order.CustomerId.HasValue) { - var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( - CustomerRepository, customer); - var pointsManager = LazyServiceProvider.GetRequiredService(); - await pointsManager.ClawbackOnOrderAsync( - mainCustomer.Id, - order.Id, - order.ServiceNo, - "订单退款扣回积分"); + var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); + if (customer != null) + { + var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( + CustomerRepository, customer); + var pointsManager = LazyServiceProvider.GetRequiredService(); + await pointsManager.ClawbackOnOrderAsync( + mainCustomer.Id, + order.Id, + order.ServiceNo, + "订单退款扣回积分"); + } } } } @@ -477,6 +485,10 @@ public partial class OrderService : ApplicationService } await OrderRepository.UpdateAsync(order); + await EnqueueAgentCommissionJobAsync( + order.Id, + Messaging.Agents.AgentCommissionJobActionCodes.Void, + reason: "订单删除作废返佣"); } /// diff --git a/src/Oms.Application/Shared/AgentCommissionManager.cs b/src/Oms.Application/Shared/AgentCommissionManager.cs index 6684b59..d330e67 100644 --- a/src/Oms.Application/Shared/AgentCommissionManager.cs +++ b/src/Oms.Application/Shared/AgentCommissionManager.cs @@ -4,14 +4,16 @@ using Oms.Domain.AgentsAggregate.Entities; using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.Shared.Enums; using System.Globalization; using System.Text.Json; namespace Oms.Application.Shared; /// -/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。 +/// 渠道代理返佣:下单写预计 → 派送终态锁定待结算 → 取消/退款作废;不入余额。 /// public class AgentCommissionManager : IScopedDependency { @@ -32,6 +34,27 @@ public class AgentCommissionManager : IScopedDependency _salesProductRepository = salesProductRepository; } + /// 下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。 + public async Task TryEstimateOnOrderCreatedAsync( + int orderId, + CancellationToken cancellationToken = default) + { + var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); + if (order == null) + { + return; + } + + await UpsertEstimatedAsync(order, cancellationToken); + } + + /// 预报计费完成后刷新预计金额(仍为 estimated)。 + public Task TryRefreshEstimateAfterBillingAsync( + int orderId, + CancellationToken cancellationToken = default) => + TryEstimateOnOrderCreatedAsync(orderId, cancellationToken); + + /// 派送成功/失败:重算锁定为 pending。 public async Task TryAccrueOnDeliveryTerminalAsync( int orderId, string triggerOrderStatus, @@ -42,12 +65,6 @@ public class AgentCommissionManager : IScopedDependency return; } - var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); - if (existing != null) - { - return; - } - var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) { @@ -59,44 +76,50 @@ public class AgentCommissionManager : IScopedDependency return; } + var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing != null + && (string.Equals(existing.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) + || string.Equals(existing.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase))) + { + // pending 已锁定;paid 不回写 + return; + } + + if (existing != null + && string.Equals(existing.Status, AgentCommissionStatusCodes.Void, StringComparison.OrdinalIgnoreCase)) + { + return; + } + var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken); - if (agent == null || agent.Status != Domain.Shared.Enums.EnableStatus.Enabled) + if (agent == null || agent.Status != EnableStatus.Enabled) { return; } - if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType)) + var built = await TryBuildAmountsAsync(agent, order, cancellationToken); + if (built == null) { + // 终态仍算不出金额:若已有预计行则作废,避免脏数据进应结 + if (existing != null + && string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + existing.Status = AgentCommissionStatusCodes.Void; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + existing.Remark = AppendRemark(existing.Remark, "终态基数为0,自动作废"); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + return; } - if (agent.CommissionRate <= 0) - { - return; - } - - int? salesProductId = null; - if (!string.IsNullOrWhiteSpace(order.ProductCode)) - { - var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); - salesProductId = product?.Id; - } - - if (!IsProductAllowed(agent.SalesProductIds, salesProductId)) - { - return; - } - - var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); - var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order); - if (baseAmount <= 0) - { - return; - } - - var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero); - if (amount <= 0) + if (existing != null) { + ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount); + existing.Status = AgentCommissionStatusCodes.Pending; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + existing.Remark = AppendRemark(existing.Remark, "派送终态锁定"); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); return; } @@ -104,6 +127,132 @@ public class AgentCommissionManager : IScopedDependency existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); if (existing != null) { + if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + ApplyAmounts(existing, agent, built.Value.SalesProductId, built.Value.BaseAmount, built.Value.Amount); + existing.Status = AgentCommissionStatusCodes.Pending; + existing.TriggerOrderStatus = triggerOrderStatus.Trim(); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + + return; + } + + await _detailRepository.InsertAsync(new AgentCommissionDetail + { + AgentId = agent.Id, + OrderId = order.Id, + CustomerId = order.CustomerId.Value, + SalesProductId = built.Value.SalesProductId, + BaseType = agent.BaseType.Trim().ToLowerInvariant(), + BaseAmount = built.Value.BaseAmount, + CommissionRate = agent.CommissionRate, + CommissionAmount = built.Value.Amount, + Status = AgentCommissionStatusCodes.Pending, + TriggerOrderStatus = triggerOrderStatus.Trim() + }, cancellationToken); + } + + /// 订单删除 / 全额退款:预计或待结算作废;已打款不动。 + public async Task TryVoidOnOrderCancelledAsync( + int orderId, + string reason, + CancellationToken cancellationToken = default) + { + var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); + if (existing == null || !AgentCommissionStatusCodes.IsOpen(existing.Status)) + { + return; + } + + existing.Status = AgentCommissionStatusCodes.Void; + existing.Remark = AppendRemark(existing.Remark, reason); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + + private async Task UpsertEstimatedAsync(Order order, CancellationToken cancellationToken) + { + if (!order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) + { + return; + } + + if (order.IsRefunded > 0 + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken); + if (existing != null + && !string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken); + if (agent == null || agent.Status != EnableStatus.Enabled) + { + return; + } + + var built = await TryBuildAmountsAsync(agent, order, cancellationToken); + int? salesProductId; + decimal baseAmount; + decimal amount; + if (built == null) + { + // 计费未出前也落一条预计(金额 0),方便代理看到「已关联」 + if (agent.CommissionRate <= 0 || !AgentCommissionBaseTypes.IsSupported(agent.BaseType)) + { + return; + } + + int? productId = null; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + productId = product?.Id; + } + + if (!IsProductAllowed(agent.SalesProductIds, productId)) + { + return; + } + + salesProductId = productId; + baseAmount = 0M; + amount = 0M; + } + else + { + salesProductId = built.Value.SalesProductId; + baseAmount = built.Value.BaseAmount; + amount = built.Value.Amount; + } + + if (existing != null) + { + ApplyAmounts(existing, agent, salesProductId, baseAmount, amount); + existing.Status = AgentCommissionStatusCodes.Estimated; + if (string.IsNullOrWhiteSpace(existing.TriggerOrderStatus)) + { + existing.TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty; + } + + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + return; + } + + existing = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken); + if (existing != null) + { + if (string.Equals(existing.Status, AgentCommissionStatusCodes.Estimated, StringComparison.OrdinalIgnoreCase)) + { + ApplyAmounts(existing, agent, salesProductId, baseAmount, amount); + await _detailRepository.UpdateAsync(existing, cancellationToken: cancellationToken); + } + return; } @@ -117,19 +266,92 @@ public class AgentCommissionManager : IScopedDependency BaseAmount = baseAmount, CommissionRate = agent.CommissionRate, CommissionAmount = amount, - Status = AgentCommissionStatusCodes.Pending, - TriggerOrderStatus = triggerOrderStatus.Trim() + Status = AgentCommissionStatusCodes.Estimated, + TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty }, cancellationToken); } + private async Task<(int? SalesProductId, decimal BaseAmount, decimal Amount)?> TryBuildAmountsAsync( + Agent agent, + Order order, + CancellationToken cancellationToken) + { + if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType) || agent.CommissionRate <= 0) + { + return null; + } + + int? salesProductId = null; + if (!string.IsNullOrWhiteSpace(order.ProductCode)) + { + var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim()); + salesProductId = product?.Id; + } + + if (!IsProductAllowed(agent.SalesProductIds, salesProductId)) + { + return null; + } + + var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]); + var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order); + if (baseAmount <= 0) + { + return null; + } + + var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero); + if (amount <= 0) + { + return null; + } + + return (salesProductId, baseAmount, amount); + } + + private static void ApplyAmounts( + AgentCommissionDetail detail, + Agent agent, + int? salesProductId, + decimal baseAmount, + decimal amount) + { + detail.SalesProductId = salesProductId; + detail.BaseType = agent.BaseType.Trim().ToLowerInvariant(); + detail.BaseAmount = baseAmount; + detail.CommissionRate = agent.CommissionRate; + detail.CommissionAmount = amount; + } + + private static string? AppendRemark(string? existing, string note) + { + var n = note?.Trim(); + if (string.IsNullOrWhiteSpace(n)) + { + return existing; + } + + if (string.IsNullOrWhiteSpace(existing)) + { + return n; + } + + if (existing.Contains(n, StringComparison.Ordinal)) + { + return existing; + } + + return $"{existing}; {n}"; + } + private static bool IsTerminalTrigger(string? status) => string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase) || string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase); private static decimal ResolveBaseAmount( string baseType, - List fees, - Domain.OrdersAggregate.Entities.Order order) + List fees, + Order order) { if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase)) { @@ -139,7 +361,6 @@ public class AgentCommissionManager : IScopedDependency order.CouponDiscountAmount); } - // receivable:折前应收小计(排除营销抵扣行) var sum = MarketingFeeHelper.SumReceivable(fees); if (sum > 0) { diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs index 0639032..407ed2c 100644 --- a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs +++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs @@ -1,8 +1,31 @@ namespace Oms.Domain.AgentsAggregate.Consts; -/// 代理返佣明细状态(首版)。 +/// 代理返佣明细结算状态。 public static class AgentCommissionStatusCodes { + /// 下单后预计返佣(金额可随计费刷新,不计入应打款)。 + public const string Estimated = "estimated"; + + /// 派送终态锁定,待运营线下打款。 public const string Pending = "pending"; + + /// 已打款。 public const string Paid = "paid"; + + /// 订单取消/全额退等作废。 + public const string Void = "void"; + + public static bool IsOpen(string? status) => + string.Equals(status, Estimated, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, Pending, StringComparison.OrdinalIgnoreCase); + + public static string DisplayName(string? status) => + status?.Trim().ToLowerInvariant() switch + { + Estimated => "预计返佣", + Pending => "待结算", + Paid => "已打款", + Void => "已作废", + _ => string.IsNullOrWhiteSpace(status) ? "--" : status + }; } diff --git a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs index dba8aec..1e8a65d 100644 --- a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs +++ b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs @@ -24,9 +24,9 @@ public class AgentCommissionDetail : OmsAuditedAggregateRoot public decimal CommissionAmount { get; set; } - /// pending / paid。 + /// estimated / pending / paid / void。 [SugarColumn(Length = 16)] - public string Status { get; set; } = "pending"; + public string Status { get; set; } = "estimated"; [SugarColumn(Length = 32)] public string TriggerOrderStatus { get; set; } = string.Empty;