feat: 代理返佣下单预计与 Outbox 异步落库
All checks were successful
Build and Deploy / deploy (push) Successful in 1m12s

下单写 estimated、终态锁定 pending、取消/退款 void;打款仅认 pending,写入走集成事件异步消费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-30 11:06:51 +08:00
parent 4b237c1374
commit 07f589d57b
12 changed files with 508 additions and 72 deletions

View File

@ -308,6 +308,12 @@ public class AgentWorkplaceStatsOutput
/// <summary>派送成功运单数(全量状态桶)。</summary> /// <summary>派送成功运单数(全量状态桶)。</summary>
public int DeliverySuccess { get; set; } public int DeliverySuccess { get; set; }
/// <summary>预计返佣笔数(未锁定)。</summary>
public int EstimatedCommissionCount { get; set; }
/// <summary>预计返佣金额(未锁定,不计入应打款)。</summary>
public decimal EstimatedCommissionAmount { get; set; }
/// <summary>待结算返佣笔数。</summary> /// <summary>待结算返佣笔数。</summary>
public int PendingCommissionCount { get; set; } public int PendingCommissionCount { get; set; }

View File

@ -0,0 +1,14 @@
namespace Oms.Application.Messaging.Agents;
/// <summary>代理返佣异步任务动作。</summary>
public static class AgentCommissionJobActionCodes
{
/// <summary>下单/计费后写或刷新预计返佣。</summary>
public const string Estimate = "estimate";
/// <summary>派送终态锁定为待结算。</summary>
public const string Finalize = "finalize";
/// <summary>删单/全额退作废。</summary>
public const string Void = "void";
}

View File

@ -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;
/// <summary>消费代理返佣异步任务。</summary>
public sealed class AgentCommissionJobEventHandler(
IServiceScopeFactory scopeFactory,
IUnitOfWorkManager unitOfWorkManager,
ILogger<AgentCommissionJobEventHandler> logger)
: IEventHandler<AgentCommissionJobIntegrationEvent>, 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<AgentCommissionManager>();
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);
}
}

View File

@ -0,0 +1,21 @@
using Fake.EventBus.Distributed;
namespace Oms.Application.Messaging.Agents;
/// <summary>
/// 代理返佣异步任务(预计 / 锁定 / 作废),走 Fake.EventBus Outbox。
/// </summary>
[Serializable]
public class AgentCommissionJobIntegrationEvent : IntegrationEvent
{
public int OrderId { get; set; }
/// <summary><see cref="AgentCommissionJobActionCodes"/>。</summary>
public string Action { get; set; } = AgentCommissionJobActionCodes.Estimate;
/// <summary>finalize 时的触发运单状态。</summary>
public string? TriggerOrderStatus { get; set; }
/// <summary>void 时的原因备注。</summary>
public string? Reason { get; set; }
}

View File

@ -1,6 +1,8 @@
using Fake.DependencyInjection; using Fake.DependencyInjection;
using Fake.EventBus.Distributed;
using Fake.UnitOfWork; using Fake.UnitOfWork;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Oms.Application.Messaging.Agents;
using Oms.Application.Services.Orders; using Oms.Application.Services.Orders;
using Oms.Application.Shared; using Oms.Application.Shared;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
@ -16,7 +18,7 @@ public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository, IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter, OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager, ReferralCommissionManager referralCommissionManager,
AgentCommissionManager agentCommissionManager, IDistributedEventBus eventBus,
MarketingCouponManager marketingCouponManager, MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager, IUnitOfWorkManager unitOfWorkManager,
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
@ -133,17 +135,21 @@ public sealed class TrackChangeOrderStatusApplier(
// 重新加载,避免营销侧依赖最新字段 // 重新加载,避免营销侧依赖最新字段
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken); await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
order.Id, {
OrderStatusCodes.DeliverySuccess, OrderId = order.Id,
cancellationToken); Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliverySuccess
});
} }
else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase)) else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
{ {
await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync( await eventBus.PublishAsync(new AgentCommissionJobIntegrationEvent
order.Id, {
OrderStatusCodes.DeliveryFailed, OrderId = order.Id,
cancellationToken); Action = AgentCommissionJobActionCodes.Finalize,
TriggerOrderStatus = OrderStatusCodes.DeliveryFailed
});
} }
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order; order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;

View File

@ -254,7 +254,8 @@ public class AgentAdminService : ApplicationService
var remark = NormalizeOptional(input.Remark); var remark = NormalizeOptional(input.Remark);
foreach (var item in list) foreach (var item in list)
{ {
if (string.Equals(item.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase)) // 仅「待结算」可标记打款;预计/作废/已打款跳过
if (!string.Equals(item.Status, AgentCommissionStatusCodes.Pending, StringComparison.OrdinalIgnoreCase))
{ {
continue; continue;
} }

View File

@ -202,6 +202,8 @@ public class AgentPortalService : ApplicationService
&& x.AgentBindTime >= weekStart && x.AgentBindTime >= weekStart
&& x.AgentBindTime < tomorrow); && x.AgentBindTime < tomorrow);
var estimatedDetails = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Estimated);
var pendingDetails = await DetailRepository.GetListAsync(x => var pendingDetails = await DetailRepository.GetListAsync(x =>
x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending); x.AgentId == agent.Id && x.Status == AgentCommissionStatusCodes.Pending);
var paidDetails = await DetailRepository.GetListAsync(x => var paidDetails = await DetailRepository.GetListAsync(x =>
@ -218,6 +220,8 @@ public class AgentPortalService : ApplicationService
Problematic = GetCount(OrderStatusCodes.Problematic), Problematic = GetCount(OrderStatusCodes.Problematic),
DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess), DeliverySuccess = GetCount(OrderStatusCodes.DeliverySuccess),
DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed), DeliveryFailed = GetCount(OrderStatusCodes.DeliveryFailed),
EstimatedCommissionCount = estimatedDetails.Count,
EstimatedCommissionAmount = estimatedDetails.Sum(x => x.CommissionAmount),
PendingCommissionCount = pendingDetails.Count, PendingCommissionCount = pendingDetails.Count,
PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount), PendingCommissionAmount = pendingDetails.Sum(x => x.CommissionAmount),
PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount), PaidCommissionAmount = paidDetails.Sum(x => x.CommissionAmount),
@ -315,9 +319,7 @@ public class AgentPortalService : ApplicationService
基数金额 = x.BaseAmount, 基数金额 = x.BaseAmount,
返佣比例 = x.CommissionRate, 返佣比例 = x.CommissionRate,
返佣金额 = x.CommissionAmount, 返佣金额 = x.CommissionAmount,
状态 = string.Equals(x.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase) 状态 = AgentCommissionStatusCodes.DisplayName(x.Status),
? "已打款"
: "待结算",
触发状态 = x.TriggerOrderStatus, 触发状态 = x.TriggerOrderStatus,
计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"), 计提时间 = x.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),
打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", 打款时间 = x.PaidTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "",

View File

@ -186,6 +186,9 @@ public partial class OrderService
createContext); createContext);
order.AgentId = orderAgentId; order.AgentId = orderAgentId;
order = await OrderRepository.InsertAsync(order); order = await OrderRepository.InsertAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
var weights = BuildInitialWeightRows(order.Id, input); var weights = BuildInitialWeightRows(order.Id, input);
await SaveParcelChildrenAsync(order, input, weights, country); await SaveParcelChildrenAsync(order, input, weights, country);
@ -2955,6 +2958,9 @@ public partial class OrderService
order.PredeclareFailReason = string.Empty; order.PredeclareFailReason = string.Empty;
order.UpdateTime = DateTime.Now; order.UpdateTime = DateTime.Now;
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Estimate);
// 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。
// 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。
@ -3522,6 +3528,55 @@ public partial class OrderService
agentRepository); agentRepository);
} }
/// <summary>
/// 代理返佣异步投递:有事务 Outbox 则发事件;否则同步兜底(无 UoW 场景)。
/// </summary>
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<IUnitOfWorkManager>();
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<AgentCommissionManager>();
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) private async Task ApplyMarketingSnapshotOnCreateAsync(Order order, int? couponInstanceId)
{ {
var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]); var fees = await OrderRepository.GetFeesByOrderIdsAsync([order.Id]);

View File

@ -349,11 +349,11 @@ public partial class OrderService : ApplicationService
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess); await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
} }
private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) private Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus) =>
{ EnqueueAgentCommissionJobAsync(
var agentCommission = LazyServiceProvider.GetRequiredService<AgentCommissionManager>(); order.Id,
await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus); Messaging.Agents.AgentCommissionJobActionCodes.Finalize,
} triggerOrderStatus: triggerStatus);
/// <summary> /// <summary>
/// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。 /// 订单退款:全额退且券未过期则退回(新人首单券除外);部分退款不退券。
@ -376,19 +376,27 @@ public partial class OrderService : ApplicationService
var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>(); var commissionManager = LazyServiceProvider.GetRequiredService<ReferralCommissionManager>();
await commissionManager.ClawbackOnOrderRefundAsync(order); await commissionManager.ClawbackOnOrderRefundAsync(order);
if (input.IsFullRefund && order.CustomerId.HasValue) if (input.IsFullRefund)
{ {
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value); await EnqueueAgentCommissionJobAsync(
if (customer != null) order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Void,
reason: "订单全额退款作废返佣");
if (order.CustomerId.HasValue)
{ {
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync( var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value);
CustomerRepository, customer); if (customer != null)
var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>(); {
await pointsManager.ClawbackOnOrderAsync( var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
mainCustomer.Id, CustomerRepository, customer);
order.Id, var pointsManager = LazyServiceProvider.GetRequiredService<MarketingPointsManager>();
order.ServiceNo, await pointsManager.ClawbackOnOrderAsync(
"订单退款扣回积分"); mainCustomer.Id,
order.Id,
order.ServiceNo,
"订单退款扣回积分");
}
} }
} }
} }
@ -477,6 +485,10 @@ public partial class OrderService : ApplicationService
} }
await OrderRepository.UpdateAsync(order); await OrderRepository.UpdateAsync(order);
await EnqueueAgentCommissionJobAsync(
order.Id,
Messaging.Agents.AgentCommissionJobActionCodes.Void,
reason: "订单删除作废返佣");
} }
/// <summary> /// <summary>

View File

@ -4,14 +4,16 @@ using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories; using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Oms.Application.Shared; namespace Oms.Application.Shared;
/// <summary> /// <summary>
/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。 /// 渠道代理返佣:下单写预计 → 派送终态锁定待结算 → 取消/退款作废;不入余额。
/// </summary> /// </summary>
public class AgentCommissionManager : IScopedDependency public class AgentCommissionManager : IScopedDependency
{ {
@ -32,6 +34,27 @@ public class AgentCommissionManager : IScopedDependency
_salesProductRepository = salesProductRepository; _salesProductRepository = salesProductRepository;
} }
/// <summary>下单后写入/刷新预计返佣(仅 estimated;已锁定/已打款/已作废不改)。</summary>
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);
}
/// <summary>预报计费完成后刷新预计金额(仍为 estimated)。</summary>
public Task TryRefreshEstimateAfterBillingAsync(
int orderId,
CancellationToken cancellationToken = default) =>
TryEstimateOnOrderCreatedAsync(orderId, cancellationToken);
/// <summary>派送成功/失败:重算锁定为 pending。</summary>
public async Task TryAccrueOnDeliveryTerminalAsync( public async Task TryAccrueOnDeliveryTerminalAsync(
int orderId, int orderId,
string triggerOrderStatus, string triggerOrderStatus,
@ -42,12 +65,6 @@ public class AgentCommissionManager : IScopedDependency
return; return;
} }
var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null)
{
return;
}
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue) if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue)
{ {
@ -59,44 +76,50 @@ public class AgentCommissionManager : IScopedDependency
return; 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); 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; 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; return;
} }
if (agent.CommissionRate <= 0) if (existing != null)
{
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)
{ {
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; return;
} }
@ -104,6 +127,132 @@ public class AgentCommissionManager : IScopedDependency
existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken); existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null) 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);
}
/// <summary>订单删除 / 全额退款:预计或待结算作废;已打款不动。</summary>
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; return;
} }
@ -117,19 +266,92 @@ public class AgentCommissionManager : IScopedDependency
BaseAmount = baseAmount, BaseAmount = baseAmount,
CommissionRate = agent.CommissionRate, CommissionRate = agent.CommissionRate,
CommissionAmount = amount, CommissionAmount = amount,
Status = AgentCommissionStatusCodes.Pending, Status = AgentCommissionStatusCodes.Estimated,
TriggerOrderStatus = triggerOrderStatus.Trim() TriggerOrderStatus = order.OrderStatus?.Trim() ?? string.Empty
}, cancellationToken); }, 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) => private static bool IsTerminalTrigger(string? status) =>
string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase) string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase); || string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase);
private static decimal ResolveBaseAmount( private static decimal ResolveBaseAmount(
string baseType, string baseType,
List<Domain.OrdersAggregate.Entities.OrderFee> fees, List<OrderFee> fees,
Domain.OrdersAggregate.Entities.Order order) Order order)
{ {
if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase)) if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase))
{ {
@ -139,7 +361,6 @@ public class AgentCommissionManager : IScopedDependency
order.CouponDiscountAmount); order.CouponDiscountAmount);
} }
// receivable:折前应收小计(排除营销抵扣行)
var sum = MarketingFeeHelper.SumReceivable(fees); var sum = MarketingFeeHelper.SumReceivable(fees);
if (sum > 0) if (sum > 0)
{ {

View File

@ -1,8 +1,31 @@
namespace Oms.Domain.AgentsAggregate.Consts; namespace Oms.Domain.AgentsAggregate.Consts;
/// <summary>代理返佣明细状态(首版)。</summary> /// <summary>代理返佣明细结算状态。</summary>
public static class AgentCommissionStatusCodes public static class AgentCommissionStatusCodes
{ {
/// <summary>下单后预计返佣(金额可随计费刷新,不计入应打款)。</summary>
public const string Estimated = "estimated";
/// <summary>派送终态锁定,待运营线下打款。</summary>
public const string Pending = "pending"; public const string Pending = "pending";
/// <summary>已打款。</summary>
public const string Paid = "paid"; public const string Paid = "paid";
/// <summary>订单取消/全额退等作废。</summary>
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
};
} }

View File

@ -24,9 +24,9 @@ public class AgentCommissionDetail : OmsAuditedAggregateRoot
public decimal CommissionAmount { get; set; } public decimal CommissionAmount { get; set; }
/// <summary>pending / paid。</summary> /// <summary>estimated / pending / paid / void。</summary>
[SugarColumn(Length = 16)] [SugarColumn(Length = 16)]
public string Status { get; set; } = "pending"; public string Status { get; set; } = "estimated";
[SugarColumn(Length = 32)] [SugarColumn(Length = 32)]
public string TriggerOrderStatus { get; set; } = string.Empty; public string TriggerOrderStatus { get; set; } = string.Empty;