Add referrer-list aggregation, customer-code filters, and idempotent marketing seed data with customer 9 as referrer and 8 as referee. Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
8.5 KiB
C#
223 lines
8.5 KiB
C#
using System.Linq.Expressions;
|
|
using Fake.Application;
|
|
using Fake.Domain.Exceptions;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Oms.Application.Dtos.Marketing;
|
|
using Oms.Application.Dtos.Shared;
|
|
using Oms.Application.Shared;
|
|
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;
|
|
|
|
namespace Oms.Application.Services.Marketing;
|
|
|
|
/// <summary>
|
|
/// 优惠券发放与实例查询 API。
|
|
/// </summary>
|
|
[ApiExplorerSettings(GroupName = "OMS-Marketing")]
|
|
public class MarketingCouponGrantService : ApplicationService
|
|
{
|
|
private ICouponTemplateRepository TemplateRepository =>
|
|
LazyServiceProvider.GetRequiredService<ICouponTemplateRepository>();
|
|
|
|
private ICouponInstanceRepository InstanceRepository =>
|
|
LazyServiceProvider.GetRequiredService<ICouponInstanceRepository>();
|
|
|
|
private ICustomerRepository CustomerRepository =>
|
|
LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
|
|
|
private MarketingCouponManager CouponManager =>
|
|
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
|
|
|
|
/// <summary>
|
|
/// 批量发券(子账号编号自动归主客户)。
|
|
/// </summary>
|
|
[HttpPost("/marketing/coupon/grant")]
|
|
public async Task GrantAsync(CouponGrantInput input)
|
|
{
|
|
if (input.CustomerCodes.Count == 0)
|
|
{
|
|
throw new BusinessException("客户编号不能为空");
|
|
}
|
|
|
|
var template = await TemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId)
|
|
?? throw new BusinessException("券模板不存在");
|
|
var quantity = Math.Clamp(input.QuantityPerCustomer, 1, 10);
|
|
var grantedMainIds = await ResolveGrantMainCustomerIdsAsync(input.CustomerCodes);
|
|
foreach (var mainCustomerId in grantedMainIds)
|
|
{
|
|
for (var i = 0; i < quantity; i++)
|
|
{
|
|
await CouponManager.GrantAsync(template.Id, mainCustomerId, input.Remark);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 批量发券预览(不落库)。
|
|
/// </summary>
|
|
[HttpPost("/marketing/coupon/grant/preview")]
|
|
public async Task<CouponGrantPreviewOutput> PreviewGrantAsync(CouponGrantInput input)
|
|
{
|
|
if (input.CustomerCodes.Count == 0)
|
|
{
|
|
throw new BusinessException("客户编号不能为空");
|
|
}
|
|
|
|
var template = await TemplateRepository.FirstOrDefaultAsync(x => x.Id == input.TemplateId)
|
|
?? throw new BusinessException("券模板不存在");
|
|
var quantity = Math.Clamp(input.QuantityPerCustomer, 1, 10);
|
|
var grantedMainIds = await ResolveGrantMainCustomerIdsAsync(input.CustomerCodes);
|
|
var customerCount = grantedMainIds.Count;
|
|
var totalCoupons = customerCount * quantity;
|
|
var maxPerCoupon = EstimateTemplateMaxDiscount(template);
|
|
|
|
return new CouponGrantPreviewOutput
|
|
{
|
|
CustomerCount = customerCount,
|
|
TotalCoupons = totalCoupons,
|
|
EstimatedMaxDiscount = maxPerCoupon * totalCoupons,
|
|
TemplateName = template.TemplateName
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 分页查询券实例(发放记录)。
|
|
/// </summary>
|
|
[HttpPost("/marketing/coupon-instance/list")]
|
|
public async Task<OmsPageResult<CouponInstanceOutput>> GetInstanceListAsync(CouponInstanceListInput input)
|
|
{
|
|
await ResolveInstanceCustomerIdAsync(input);
|
|
var predicate = BuildInstancePredicate(input);
|
|
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
|
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
|
var entities = await InstanceRepository.GetPagedListAsync(
|
|
predicate,
|
|
pageIndex,
|
|
pageSize,
|
|
new Dictionary<string, bool>
|
|
{
|
|
[nameof(CouponInstance.ReceiveTime)] = false,
|
|
[nameof(CouponInstance.Id)] = false
|
|
});
|
|
var total = await InstanceRepository.CountAsync(predicate);
|
|
return new OmsPageResult<CouponInstanceOutput>
|
|
{
|
|
Items = await MapInstanceOutputsAsync(entities),
|
|
Total = total
|
|
};
|
|
}
|
|
|
|
private async Task ResolveInstanceCustomerIdAsync(CouponInstanceListInput input)
|
|
{
|
|
if (input.CustomerId is > 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var code = input.CustomerCode?.Trim();
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var customer = await CustomerRepository.GetByCustomerCodeAsync(code)
|
|
?? throw new BusinessException($"客户编号 {code} 不存在");
|
|
input.CustomerId = await CouponManager.ResolveMainCustomerIdAsync(customer.Id);
|
|
}
|
|
|
|
private async Task<HashSet<int>> ResolveGrantMainCustomerIdsAsync(IEnumerable<string> customerCodes)
|
|
{
|
|
var grantedMainIds = new HashSet<int>();
|
|
foreach (var rawCode in customerCodes)
|
|
{
|
|
var code = rawCode?.Trim();
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var customer = await CustomerRepository.GetByCustomerCodeAsync(code)
|
|
?? throw new BusinessException($"客户编号 {code} 不存在");
|
|
var mainCustomerId = await CouponManager.ResolveMainCustomerIdAsync(customer.Id);
|
|
grantedMainIds.Add(mainCustomerId);
|
|
}
|
|
|
|
return grantedMainIds;
|
|
}
|
|
|
|
private static decimal EstimateTemplateMaxDiscount(CouponTemplate template)
|
|
{
|
|
if (string.Equals(template.CouponType, MarketingCouponTypes.FixedOff, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(template.CouponType, MarketingCouponTypes.DirectOff, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return template.FaceValue ?? 0M;
|
|
}
|
|
|
|
return template.MaxDiscountAmount ?? template.FaceValue ?? 0M;
|
|
}
|
|
|
|
private static Expression<Func<CouponInstance, bool>> BuildInstancePredicate(CouponInstanceListInput input)
|
|
{
|
|
var keyword = input.Keyword?.Trim();
|
|
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
|
|
var hasTemplate = input.TemplateId is > 0;
|
|
var templateId = input.TemplateId.GetValueOrDefault();
|
|
var hasCustomer = input.CustomerId is > 0;
|
|
var customerId = input.CustomerId.GetValueOrDefault();
|
|
var status = input.Status;
|
|
var hasStatus = status.HasValue;
|
|
|
|
return x =>
|
|
(!hasTemplate || x.TemplateId == templateId) &&
|
|
(!hasCustomer || x.CustomerId == customerId) &&
|
|
(!hasStatus || x.Status == status!.Value) &&
|
|
(!hasKeyword || x.CouponCode.Contains(keyword!));
|
|
}
|
|
|
|
private async Task<List<CouponInstanceOutput>> MapInstanceOutputsAsync(List<CouponInstance> entities)
|
|
{
|
|
var templateIds = entities.Select(x => x.TemplateId).Distinct().ToArray();
|
|
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
|
|
var templates = templateIds.Length == 0
|
|
? []
|
|
: await TemplateRepository.GetListAsync(x => templateIds.Contains(x.Id));
|
|
var customers = customerIds.Length == 0
|
|
? []
|
|
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
|
|
var templateMap = templates.ToDictionary(x => x.Id);
|
|
var customerMap = customers.ToDictionary(x => x.Id);
|
|
|
|
return entities.Select(entity =>
|
|
{
|
|
templateMap.TryGetValue(entity.TemplateId, out var template);
|
|
customerMap.TryGetValue(entity.CustomerId, out var customer);
|
|
return MapInstanceOutput(entity, template, customer?.CustomerCode);
|
|
}).ToList();
|
|
}
|
|
|
|
internal static CouponInstanceOutput MapInstanceOutput(
|
|
CouponInstance entity,
|
|
CouponTemplate? template,
|
|
string? customerCode) => new()
|
|
{
|
|
Id = entity.Id,
|
|
TemplateId = entity.TemplateId,
|
|
TemplateName = template?.TemplateName,
|
|
CouponType = template?.CouponType,
|
|
CustomerId = entity.CustomerId,
|
|
CustomerCode = customerCode,
|
|
CouponCode = entity.CouponCode,
|
|
Status = entity.Status,
|
|
StatusName = MarketingEnumNames.Of(entity.Status),
|
|
ReceiveTime = entity.ReceiveTime,
|
|
ValidStartTime = entity.ValidStartTime,
|
|
ValidEndTime = entity.ValidEndTime,
|
|
UseTime = entity.UseTime,
|
|
OrderId = entity.OrderId,
|
|
LockExpireTime = entity.LockExpireTime
|
|
};
|
|
}
|