admin/src/Oms.Application/Shared/MarketingCouponManager.cs
zzh d38963055b feat: 支付用券/会员折扣写入负数应收行,订单应收与实付一致
- 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级)
- 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行
- 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收
- 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名
- 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付
- 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql
2026-10-19 15:48:15 +08:00

671 lines
26 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 Fake.Security.Claims;
using Fake.Users;
using Oms.Application.Dtos.Marketing;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Consts;
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 Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
using System.Text.Json;
namespace Oms.Application.Shared;
/// <summary>
/// 优惠券发放、锁定、核销、解锁与释放。
/// </summary>
public class MarketingCouponManager : IScopedDependency
{
private const int LockMinutes = 30;
private static readonly DateTime PendingValidEnd = new(2099, 12, 31, 23, 59, 59);
private readonly ICouponTemplateRepository _templateRepository;
private readonly ICouponInstanceRepository _instanceRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IOrderRepository _orderRepository;
private readonly INoticeRepository _noticeRepository;
private readonly IMarketingAuditLogRepository _auditLogRepository;
private readonly ICurrentUser _currentUser;
public MarketingCouponManager(
ICouponTemplateRepository templateRepository,
ICouponInstanceRepository instanceRepository,
ICustomerRepository customerRepository,
IOrderRepository orderRepository,
INoticeRepository noticeRepository,
IMarketingAuditLogRepository auditLogRepository,
ICurrentUser currentUser)
{
_templateRepository = templateRepository;
_instanceRepository = instanceRepository;
_customerRepository = customerRepository;
_orderRepository = orderRepository;
_noticeRepository = noticeRepository;
_auditLogRepository = auditLogRepository;
_currentUser = currentUser;
}
public async Task<CouponInstance> GrantAsync(int templateId, int mainCustomerId, string? remark = null, CancellationToken cancellationToken = default)
{
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == templateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
if (template.Status != CouponTemplateStatus.Active)
{
throw new BusinessException("券模板未发布,无法发放");
}
if (template.TotalQuantity > 0 && template.IssuedQuantity >= template.TotalQuantity)
{
throw new BusinessException("券模板库存不足");
}
var count = await _instanceRepository.CountByTemplateAndCustomerAsync(templateId, mainCustomerId, cancellationToken);
if (count >= template.PerCustomerLimit)
{
throw new BusinessException("该客户已达领取上限");
}
var pendingUnlock = RequiresUnlock(template);
var (validStart, validEnd) = pendingUnlock
? (DateTime.Now, PendingValidEnd)
: ResolveValidRange(template);
var instance = new CouponInstance
{
TemplateId = templateId,
CustomerId = mainCustomerId,
CouponCode = await GenerateCouponCodeAsync(cancellationToken),
Status = pendingUnlock ? CouponInstanceStatus.PendingUnlock : CouponInstanceStatus.Available,
ReceiveTime = DateTime.Now,
ValidStartTime = validStart,
ValidEndTime = validEnd
};
await _instanceRepository.InsertAsync(instance, cancellationToken);
template.IssuedQuantity += 1;
await _templateRepository.UpdateAsync(template, cancellationToken);
await WriteAuditAsync(MarketingAuditActions.GrantCoupon, "customer", mainCustomerId, null,
JsonSerializer.Serialize(new { templateId, instance.Id }), remark, cancellationToken);
return instance;
}
/// <summary>
/// 注册成功后发放所有已发布新人券。第二单券发成未解锁。
/// </summary>
public async Task<int> GrantNewcomerCouponsAsync(int mainCustomerId, CancellationToken cancellationToken = default)
{
if (mainCustomerId <= 0)
{
return 0;
}
var templates = await _templateRepository.GetListAsync(
x => x.Status == CouponTemplateStatus.Active
&& x.GrantChannel == MarketingGrantChannels.Newcomer);
if (templates.Count == 0)
{
return 0;
}
var granted = 0;
foreach (var template in templates.OrderBy(x => x.Id))
{
try
{
await GrantAsync(template.Id, mainCustomerId, "newcomer_register", cancellationToken);
granted++;
}
catch (BusinessException)
{
}
}
return granted;
}
public async Task GrantInviteeCouponAsync(int mainCustomerId, CancellationToken cancellationToken = default)
{
if (mainCustomerId <= 0)
{
return;
}
var templates = await _templateRepository.GetListAsync(
x => x.Status == CouponTemplateStatus.Active
&& x.GrantChannel == MarketingGrantChannels.Referral
&& x.TemplateCode == MarketingCouponTemplateCodes.ReferralInvitee);
foreach (var template in templates)
{
try
{
await GrantAsync(template.Id, mainCustomerId, "referral_invitee_register", cancellationToken);
}
catch (BusinessException)
{
}
}
}
/// <summary>
/// 被邀请人首单派送成功后,向邀请人发放裂变券(REFERRAL_INVITER)。
/// </summary>
public async Task GrantInviterCouponAsync(
int referrerMainCustomerId,
int inviteeMainCustomerId,
CancellationToken cancellationToken = default)
{
if (referrerMainCustomerId <= 0
|| inviteeMainCustomerId <= 0
|| referrerMainCustomerId == inviteeMainCustomerId)
{
return;
}
var templates = await _templateRepository.GetListAsync(
x => x.Status == CouponTemplateStatus.Active
&& x.GrantChannel == MarketingGrantChannels.Referral
&& x.TemplateCode == MarketingCouponTemplateCodes.ReferralInviter);
if (templates.Count == 0)
{
return;
}
var remark = $"referral_inviter_first_delivery:{inviteeMainCustomerId}";
var alreadyGranted = await _auditLogRepository.AnyAsync(
x => x.Action == MarketingAuditActions.GrantCoupon
&& x.TargetType == "customer"
&& x.TargetId == referrerMainCustomerId
&& x.Remark == remark,
cancellationToken);
if (alreadyGranted)
{
return;
}
foreach (var template in templates)
{
try
{
await GrantAsync(template.Id, referrerMainCustomerId, remark, cancellationToken);
}
catch (BusinessException)
{
}
}
}
public async Task OnFirstDeliverySuccessAsync(Order order, CancellationToken cancellationToken = default)
{
if (order.CustomerId is not > 0)
{
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);
var familyIds = await ResolveFamilyCustomerIdsAsync(mainCustomer.Id, cancellationToken);
var hadOtherDelivery = await _orderRepository.AnyAsync(
x => x.Id != order.Id
&& x.CustomerId != null
&& familyIds.Contains(x.CustomerId.Value)
&& x.OrderStatus == OrderStatusCodes.DeliverySuccess,
cancellationToken);
if (hadOtherDelivery)
{
return;
}
await UnlockPendingCouponsAsync(mainCustomer.Id, cancellationToken);
await TryGrantInviterCouponOnFirstDeliveryAsync(mainCustomer, cancellationToken);
}
private async Task TryGrantInviterCouponOnFirstDeliveryAsync(
Customer inviteeMainCustomer,
CancellationToken cancellationToken)
{
if (inviteeMainCustomer.ReferrerCustomerId is not > 0
|| inviteeMainCustomer.ReferrerCustomerId.Value == inviteeMainCustomer.Id)
{
return;
}
var referrer = await _customerRepository.FirstOrDefaultAsync(
x => x.Id == inviteeMainCustomer.ReferrerCustomerId.Value, cancellationToken);
if (referrer == null)
{
return;
}
var referrerMain = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
_customerRepository, referrer, cancellationToken);
await GrantInviterCouponAsync(referrerMain.Id, inviteeMainCustomer.Id, cancellationToken);
}
public async Task LockForOrderAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
EnsureUsable(instance);
instance.Status = CouponInstanceStatus.Locked;
instance.OrderId = orderId;
instance.LockExpireTime = DateTime.Now.AddMinutes(LockMinutes);
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
/// <summary>
/// 核销券并返回券实例(调用方可取券码写入费用行)。
/// </summary>
public async Task<CouponInstance> MarkUsedAsync(int couponInstanceId, int mainCustomerId, int orderId, CancellationToken cancellationToken = default)
{
var instance = await GetOwnedInstanceAsync(couponInstanceId, mainCustomerId, cancellationToken);
instance.Status = CouponInstanceStatus.Used;
instance.OrderId = orderId;
instance.UseTime = DateTime.Now;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
return instance;
}
public async Task RestoreOnOrderRefundAsync(Order order, bool isFullRefund, CancellationToken cancellationToken = default)
{
if (!order.CouponInstanceId.HasValue)
{
return;
}
var instance = await _instanceRepository.FirstOrDefaultAsync(
x => x.Id == order.CouponInstanceId.Value, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Used)
{
return;
}
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken);
var isFirstOrderCoupon = template != null
&& string.Equals(template.TemplateCode, MarketingCouponTemplateCodes.NewcomerFirst, StringComparison.OrdinalIgnoreCase);
if (isFirstOrderCoupon || !isFullRefund)
{
return;
}
if (instance.ValidEndTime < DateTime.Now)
{
instance.Status = CouponInstanceStatus.Expired;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
return;
}
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.UseTime = null;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
/// <summary>
/// 释放券锁定;传 <paramref name="ownerOrderId"/> 时仅释放被该订单锁定的券。
/// </summary>
public async Task ReleaseLockAsync(int couponInstanceId, int? ownerOrderId = null, CancellationToken cancellationToken = default)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == couponInstanceId, cancellationToken);
if (instance == null || instance.Status != CouponInstanceStatus.Locked)
{
return;
}
if (ownerOrderId.HasValue && instance.OrderId != ownerOrderId.Value)
{
return;
}
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
public async Task ReleaseExpiredLocksAsync(int batchSize, CancellationToken cancellationToken = default)
{
var expired = await _instanceRepository.GetExpiredLocksAsync(DateTime.Now, batchSize, cancellationToken);
foreach (var instance in expired)
{
instance.Status = CouponInstanceStatus.Available;
instance.OrderId = null;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
}
public async Task ExpireOverdueAsync(int batchSize, CancellationToken cancellationToken = default)
{
var overdue = await _instanceRepository.GetExpiredAvailableAsync(DateTime.Now, batchSize, cancellationToken);
foreach (var instance in overdue)
{
instance.Status = CouponInstanceStatus.Expired;
instance.LockExpireTime = null;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
}
public async Task NotifyExpiringAsync(int batchSize, CancellationToken cancellationToken = default)
{
var now = DateTime.Now;
var expiring = await _instanceRepository.GetExpiringAvailableAsync(
now, now.AddDays(3), batchSize, cancellationToken);
if (expiring.Count == 0)
{
return;
}
var templateIds = expiring.Select(x => x.TemplateId).Distinct().ToArray();
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
var templateMap = templates.ToDictionary(x => x.Id);
foreach (var instance in expiring)
{
templateMap.TryGetValue(instance.TemplateId, out var template);
var name = template?.TemplateName ?? "优惠券";
await _noticeRepository.InsertAsync(new Notice
{
NoticeType = "message",
Title = "优惠券即将到期",
Content = $"您的「{name}」将于 {instance.ValidEndTime:yyyy-MM-dd HH:mm} 过期,请尽快下单使用。",
IsPopup = 0,
ReceiverType = "customer",
ReceiverIds = JsonSerializer.Serialize(new[] { instance.CustomerId })
}, cancellationToken);
instance.ExpiryNoticeSent = 1;
await _instanceRepository.UpdateAsync(instance, cancellationToken);
}
}
/// <summary>
/// 解析券抵扣参数;<paramref name="ownerOrderId"/> 为当前订单时,被该订单锁定的券视为可用。
/// </summary>
public async Task<CouponDiscountInput?> ResolveCouponDiscountInputAsync(
int? couponInstanceId,
int mainCustomerId,
string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
if (!couponInstanceId.HasValue)
{
return null;
}
var instance = await GetOwnedInstanceAsync(couponInstanceId.Value, mainCustomerId, cancellationToken);
EnsureUsable(instance, ownerOrderId);
var template = await _templateRepository.FirstOrDefaultAsync(x => x.Id == instance.TemplateId, cancellationToken)
?? throw new BusinessException("券模板不存在");
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
{
throw new BusinessException("该优惠券不适用于当前线路");
}
return new CouponDiscountInput
{
CouponInstanceId = instance.Id,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount,
ApplyScopeJson = template.ApplyScopeJson
};
}
public async Task<int> ResolveMainCustomerIdAsync(int customerId, CancellationToken cancellationToken = default)
{
var customer = await _customerRepository.FirstOrDefaultAsync(x => x.Id == customerId, cancellationToken)
?? throw new BusinessException("客户不存在");
return await MarketingCustomerHelper.ResolveMarketingMainCustomerIdAsync(_customerRepository, customer, cancellationToken);
}
/// <summary>
/// 构建可选券列表;传 <paramref name="ownerOrderId"/> 时额外包含被该订单锁定且仍在有效期内的券。
/// </summary>
public async Task<List<CouponAvailableOutput>> BuildAvailableCouponsAsync(
int mainCustomerId,
decimal? baseFreightAmount,
string? destinationCountry = null,
int? ownerOrderId = null,
CancellationToken cancellationToken = default)
{
var instances = await _instanceRepository.GetAvailableByCustomerAsync(mainCustomerId, cancellationToken);
if (ownerOrderId is > 0)
{
var now = DateTime.Now;
var orderId = ownerOrderId.Value;
var ownLocked = await _instanceRepository.GetListAsync(
x => x.CustomerId == mainCustomerId
&& x.Status == CouponInstanceStatus.Locked
&& x.OrderId == orderId
&& x.ValidStartTime <= now
&& x.ValidEndTime >= now);
var knownIds = instances.Select(x => x.Id).ToHashSet();
instances.AddRange(ownLocked.Where(x => !knownIds.Contains(x.Id)));
}
if (instances.Count == 0)
{
return [];
}
var templateIds = instances.Select(x => x.TemplateId).Distinct().ToArray();
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
var templateMap = templates.ToDictionary(x => x.Id);
return instances.Select(instance =>
{
templateMap.TryGetValue(instance.TemplateId, out var template);
template ??= new CouponTemplate();
var canUse = true;
string? unavailableReason = null;
decimal? estimatedDiscount = null;
if (!MarketingCouponScope.Matches(template.ApplyScopeJson, destinationCountry))
{
canUse = false;
unavailableReason = "不适用于当前线路";
}
else if (baseFreightAmount.HasValue)
{
if (baseFreightAmount.Value < template.MinOrderAmount)
{
canUse = false;
unavailableReason = $"实付基础运费未满 {template.MinOrderAmount:0.##} 元";
}
else
{
var discountInput = new CouponDiscountInput
{
CouponInstanceId = instance.Id,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount,
ApplyScopeJson = template.ApplyScopeJson
};
estimatedDiscount = MarketingBillingCalculator.CalculateCouponDiscount(
discountInput,
baseFreightAmount.Value);
if (estimatedDiscount <= 0)
{
canUse = false;
unavailableReason = "当前基础运费不可用";
}
}
}
return new CouponAvailableOutput
{
CouponInstanceId = instance.Id,
CouponCode = instance.CouponCode,
TemplateName = template.TemplateName,
CouponType = template.CouponType,
MinOrderAmount = template.MinOrderAmount,
FaceValue = template.FaceValue,
DiscountRate = template.DiscountRate,
MaxDiscountAmount = template.MaxDiscountAmount,
ValidEndTime = instance.ValidEndTime,
CanUse = canUse,
UnavailableReason = unavailableReason,
EstimatedDiscount = estimatedDiscount
};
}).ToList();
}
private async Task UnlockPendingCouponsAsync(int mainCustomerId, CancellationToken cancellationToken)
{
var pending = await _instanceRepository.GetPendingUnlockByCustomerAsync(mainCustomerId, cancellationToken);
if (pending.Count == 0)
{
return;
}
var templateIds = pending.Select(x => x.TemplateId).Distinct().ToArray();
var templates = await _templateRepository.GetListAsync(x => templateIds.Contains(x.Id));
var templateMap = templates.ToDictionary(x => x.Id);
var now = DateTime.Now;
foreach (var instance in pending)
{
templateMap.TryGetValue(instance.TemplateId, out var template);
var days = template?.ValidDaysAfterReceive ?? 30;
instance.Status = CouponInstanceStatus.Available;
instance.ValidStartTime = now;
instance.ValidEndTime = now.AddDays(days);
await _instanceRepository.UpdateAsync(instance, 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 async Task<CouponInstance> GetOwnedInstanceAsync(int id, int mainCustomerId, CancellationToken cancellationToken)
{
var instance = await _instanceRepository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
?? throw new BusinessException("优惠券不存在");
if (instance.CustomerId != mainCustomerId)
{
throw new BusinessException("优惠券不属于当前客户");
}
return instance;
}
private static void EnsureUsable(CouponInstance instance, int? ownerOrderId = null)
{
var now = DateTime.Now;
if (instance.Status == CouponInstanceStatus.PendingUnlock)
{
throw new BusinessException("优惠券未解锁,完成首单派送后可用");
}
if (instance.Status == CouponInstanceStatus.Used)
{
throw new BusinessException("优惠券已使用");
}
if (instance.Status == CouponInstanceStatus.Expired || instance.ValidEndTime < now)
{
throw new BusinessException("优惠券已过期");
}
if (instance.ValidStartTime > now)
{
throw new BusinessException("优惠券尚未生效");
}
if (instance.Status == CouponInstanceStatus.Locked
&& instance.LockExpireTime.HasValue
&& instance.LockExpireTime.Value >= now
&& !(ownerOrderId.HasValue && instance.OrderId == ownerOrderId.Value))
{
throw new BusinessException("优惠券已被其他订单占用");
}
}
private static bool RequiresUnlock(CouponTemplate template)
{
if (template.ValidType == CouponValidType.DaysAfterUnlock)
{
return true;
}
return string.Equals(
template.TemplateCode,
MarketingCouponTemplateCodes.NewcomerSecond,
StringComparison.OrdinalIgnoreCase);
}
private static (DateTime Start, DateTime End) ResolveValidRange(CouponTemplate template)
{
if ((template.ValidType == CouponValidType.DaysAfterReceive
|| template.ValidType == CouponValidType.DaysAfterUnlock)
&& template.ValidDaysAfterReceive.HasValue)
{
var start = DateTime.Now;
return (start, start.AddDays(template.ValidDaysAfterReceive.Value));
}
return (template.ValidStartTime ?? DateTime.Now, template.ValidEndTime ?? DateTime.Now.AddDays(30));
}
private async Task<string> GenerateCouponCodeAsync(CancellationToken cancellationToken)
{
for (var i = 0; i < 5; i++)
{
var code = $"CPN{DateTime.Now:yyyyMMdd}{Random.Shared.Next(100000, 999999)}";
if (!await _instanceRepository.AnyAsync(x => x.CouponCode == code, cancellationToken))
{
return code;
}
}
throw new BusinessException("券码生成失败");
}
private async Task WriteAuditAsync(
string action,
string targetType,
int? targetId,
string? beforeJson,
string? afterJson,
string? remark,
CancellationToken cancellationToken)
{
await _auditLogRepository.InsertAsync(new MarketingAuditLog
{
Action = action,
OperatorId = _currentUser.GetId<int>(),
TargetType = targetType,
TargetId = targetId,
BeforeJson = beforeJson,
AfterJson = afterJson,
Remark = remark
}, cancellationToken);
}
}