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; /// /// 优惠券发放、锁定、核销、解锁与释放。 /// 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 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; } /// /// 注册成功后发放所有已发布新人券。第二单券发成未解锁。 /// public async Task 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) { } } } /// /// 被邀请人首单派送成功后,向邀请人发放裂变券(REFERRAL_INVITER)。 /// 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); } /// /// 核销券并返回券实例(调用方可取券码写入费用行)。 /// public async Task 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); } /// /// 释放券锁定;传 时仅释放被该订单锁定的券。 /// 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); } } /// /// 解析券抵扣参数; 为当前订单时,被该订单锁定的券视为可用。 /// public async Task 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 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); } /// /// 构建可选券列表;传 时额外包含被该订单锁定且仍在有效期内的券。 /// public async Task> 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 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 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 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(), TargetType = targetType, TargetId = targetId, BeforeJson = beforeJson, AfterJson = afterJson, Remark = remark }, cancellationToken); } }