- 支付成功后写入 coupon_discount / member_discount 负数应收(币种、阶段跟随运费行,记录券码/会员等级) - 营销重算的折前应收排除抵扣行,避免重复扣减;规则公式忽略抵扣行 - 预报/入库计费任务跳过已付款订单,防止重复投递冲掉应收 - 手工新增费用禁止抵扣费用项;费用输出给出抵扣行显示名 - 订单详情返回折前小计、会员折扣、优惠券抵扣、客户实付 - 新增 fee_item 字典迁移 migrate_fee_item_marketing_discount_20261019.sql
671 lines
26 KiB
C#
671 lines
26 KiB
C#
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);
|
||
}
|
||
}
|