admin/src/Oms.Application/Services/Marketing/MarketingCouponGrantService.cs
zzh 8f043599ec feat: enrich marketing admin APIs and add seed for customer 9/8
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>
2026-07-24 10:44:26 +08:00

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
};
}