admin/src/Oms.Application/Shared/ReferralCommissionManager.cs
zzh f972a28d79
All checks were successful
Build and Deploy / deploy (push) Successful in 3m50s
feat: 落地滚动会员、券发放与邀请分成奖励
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 17:40:24 +08:00

271 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Fake.DependencyInjection;
using Fake.Application;
using Fake.Domain.Exceptions;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Entities;
using Oms.Domain.MarketingAggregate.Enums;
using Oms.Domain.MarketingAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
using System.Text.Json;
namespace Oms.Application.Shared;
/// <summary>
/// 引荐分成:派送成功按实付基础运费百分比计奖,T+N 工作日入运费余额。
/// </summary>
public class ReferralCommissionManager : IScopedDependency
{
private readonly IReferralCommissionRuleRepository _ruleRepository;
private readonly IReferralCommissionDetailRepository _detailRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IMemberLevelRepository _memberLevelRepository;
private readonly IOrderRepository _orderRepository;
private readonly CustomerBalanceMutator _balanceMutator;
public ReferralCommissionManager(
IReferralCommissionRuleRepository ruleRepository,
IReferralCommissionDetailRepository detailRepository,
ICustomerRepository customerRepository,
IMemberLevelRepository memberLevelRepository,
IOrderRepository orderRepository,
CustomerBalanceMutator balanceMutator)
{
_ruleRepository = ruleRepository;
_detailRepository = detailRepository;
_customerRepository = customerRepository;
_memberLevelRepository = memberLevelRepository;
_orderRepository = orderRepository;
_balanceMutator = balanceMutator;
}
public async Task OnOrderDeliverySuccessAsync(int orderId, CancellationToken cancellationToken = default)
{
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken)
?? throw new BusinessException("订单不存在");
if (!string.Equals(order.OrderStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("订单未达到派送成功状态");
}
if (order.IsRefunded > 0 || !order.CustomerId.HasValue)
{
return;
}
var sessionCustomer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value, cancellationToken);
if (sessionCustomer == null)
{
return;
}
var mainCustomer = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
_customerRepository, sessionCustomer, cancellationToken);
if (!mainCustomer.ReferrerCustomerId.HasValue || mainCustomer.ReferrerCustomerId.Value <= 0
|| mainCustomer.ReferrerCustomerId.Value == mainCustomer.Id)
{
return;
}
var referrerId = mainCustomer.ReferrerCustomerId.Value;
var existing = await _detailRepository.GetByOrderAndReferrerAsync(orderId, referrerId, cancellationToken);
if (existing != null)
{
return;
}
var rule = await _ruleRepository.GetActiveRuleAsync(cancellationToken);
if (rule == null)
{
return;
}
var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == referrerId, cancellationToken);
if (referrer == null)
{
return;
}
var referrerLevel = referrer.MemberLevelId.HasValue
? await _memberLevelRepository.FirstOrDefaultAsync(x => x.Id == referrer.MemberLevelId.Value, cancellationToken)
: null;
if (!MarketingInviteRewardCalculator.CanInvite(referrerLevel?.LevelCode, rule.MinInviteLevelCode))
{
return;
}
var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken);
var completedOrders = await _orderRepository.GetListAsync(
x => x.CustomerId != null
&& familyIds.Contains(x.CustomerId.Value)
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess
&& x.IsRefunded == 0);
var ordered = completedOrders
.OrderBy(x => x.SignTime ?? x.PayTime ?? x.CreateTime)
.ThenBy(x => x.Id)
.ToList();
var orderIndex = ordered.FindIndex(x => x.Id == order.Id) + 1;
if (orderIndex <= 0)
{
orderIndex = ordered.Count + 1;
}
var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]);
var paidBaseFreight = MarketingFeeHelper.ResolvePaidBaseFreight(
MarketingFeeHelper.SumReceivableBaseFreight(fees),
order.LevelDiscountAmount,
order.CouponDiscountAmount);
var snapshot = ToRewardSnapshot(rule);
var completeTime = order.SignTime ?? DateTime.Now;
var decision = MarketingInviteRewardCalculator.Resolve(
paidBaseFreight,
orderIndex,
mainCustomer.CreateTime,
completeTime,
snapshot);
if (!decision.Eligible || decision.Amount <= 0)
{
return;
}
var settleDue = MarketingInviteRewardCalculator.AddBusinessDays(completeTime, snapshot.SettleBusinessDays);
await _detailRepository.InsertAsync(new ReferralCommissionDetail
{
ReferrerCustomerId = referrerId,
RefereeCustomerId = mainCustomer.Id,
OrderId = order.Id,
OrderNo = order.ServiceNo,
BasePayableAmount = paidBaseFreight,
CommissionAmount = decision.Amount,
Status = CommissionStatus.Pending,
EligibleTime = completeTime,
SettleDueTime = settleDue,
RewardOrderIndex = decision.OrderIndex,
RuleSnapshotJson = JsonSerializer.Serialize(snapshot)
}, cancellationToken);
}
public async Task SettlePendingAsync(int batchSize, CancellationToken cancellationToken = default)
{
var pendingList = await _detailRepository.GetPendingSettleAsync(DateTime.Now, batchSize, cancellationToken);
foreach (var detail in pendingList)
{
if (!IsDue(detail, DateTime.Now))
{
continue;
}
var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == detail.ReferrerCustomerId, cancellationToken);
if (referrer == null)
{
continue;
}
await _balanceMutator.CreditReferralRewardAsync(
referrer,
detail.CommissionAmount,
detail.Id,
detail.OrderNo,
$"邀请奖励 {detail.OrderNo}",
cancellationToken);
detail.Status = CommissionStatus.Settled;
detail.SettleTime = DateTime.Now;
await _detailRepository.UpdateAsync(detail, cancellationToken);
}
}
public async Task ClawbackOnOrderRefundAsync(Order order, CancellationToken cancellationToken = default)
{
var detail = await _detailRepository.GetByOrderIdAsync(order.Id, cancellationToken);
if (detail == null || detail.Status == CommissionStatus.Cancelled)
{
return;
}
if (detail.Status == CommissionStatus.Pending)
{
detail.Status = CommissionStatus.Cancelled;
detail.CancelReason = "订单退款扣回";
await _detailRepository.UpdateAsync(detail, cancellationToken);
return;
}
if (detail.Status == CommissionStatus.Settled)
{
var referrer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == detail.ReferrerCustomerId, cancellationToken);
if (referrer != null)
{
await _balanceMutator.DebitReferralClawbackAsync(
referrer,
detail.CommissionAmount,
detail.Id,
detail.OrderNo,
$"邀请奖励退款扣回 {detail.OrderNo}",
cancellationToken);
}
detail.Status = CommissionStatus.Cancelled;
detail.CancelReason = "订单退款扣回";
await _detailRepository.UpdateAsync(detail, cancellationToken);
}
}
private async Task<int[]> ResolveFamilyCustomerIdsAsync(int mainCustomerId, CancellationToken cancellationToken)
{
var children = await _customerRepository.GetListAsync(x => x.ParentCustomerId == mainCustomerId);
return children.Select(x => x.Id).Append(mainCustomerId).ToArray();
}
private static bool IsDue(ReferralCommissionDetail detail, DateTime now)
{
if (detail.SettleDueTime.HasValue)
{
return detail.SettleDueTime.Value <= now;
}
if (detail.EligibleTime.HasValue)
{
var freezeDays = 7;
try
{
if (!string.IsNullOrWhiteSpace(detail.RuleSnapshotJson))
{
using var doc = JsonDocument.Parse(detail.RuleSnapshotJson);
if (doc.RootElement.TryGetProperty("SettleBusinessDays", out var days))
{
freezeDays = days.GetInt32();
}
else if (doc.RootElement.TryGetProperty("FreezeDays", out var oldDays))
{
freezeDays = oldDays.GetInt32();
}
}
}
catch
{
// ignore
}
return MarketingInviteRewardCalculator.AddBusinessDays(detail.EligibleTime.Value, freezeDays) <= now;
}
return false;
}
private static InviteRewardRuleSnapshot ToRewardSnapshot(ReferralCommissionRule rule) => new()
{
MinPaidBaseFreight = rule.MinPaidBaseFreight > 0 ? rule.MinPaidBaseFreight : 50M,
FirstOrderRate = rule.FirstOrderRate > 0 ? rule.FirstOrderRate : 0.05M,
FirstOrderCap = rule.FirstOrderCap > 0 ? rule.FirstOrderCap : 50M,
FollowOrderRate = rule.FollowOrderRate > 0 ? rule.FollowOrderRate : 0.03M,
FollowOrderCap = rule.FollowOrderCap > 0 ? rule.FollowOrderCap : 30M,
FollowWindowDays = rule.FollowWindowDays > 0 ? rule.FollowWindowDays : 90,
MaxRewardOrders = rule.MaxRewardOrders > 0 ? rule.MaxRewardOrders : 6,
SettleBusinessDays = rule.SettleBusinessDays > 0 ? rule.SettleBusinessDays : rule.FreezeDays,
MinInviteLevelCode = string.IsNullOrWhiteSpace(rule.MinInviteLevelCode) ? "V1" : rule.MinInviteLevelCode
};
}