feat(marketing): add new customer gift backend module
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsCommand : IRequest<NewCustomerSettingsDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; init; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启邀请分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ShareChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> WelcomeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviterCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto> InviteeCoupons { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordCommand : IRequest<NewCustomerGrowthRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务唯一键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordCommand : IRequest<NewCustomerInviteRecordDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订单状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; init; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; init; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源渠道。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼券规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerCouponRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 规则 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 场景(welcome/inviter/invitee)。
|
||||
/// </summary>
|
||||
public string Scene { get; init; } = "welcome";
|
||||
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置详情。
|
||||
/// </summary>
|
||||
public NewCustomerSettingsDto Settings { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public NewCustomerStatsDto Stats { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录分页结果。
|
||||
/// </summary>
|
||||
public NewCustomerInviteRecordListResultDto InviteRecords { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客成长记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerGrowthRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 顾客业务键。
|
||||
/// </summary>
|
||||
public string CustomerKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 顾客展示名。
|
||||
/// </summary>
|
||||
public string? CustomerName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包领取时间。
|
||||
/// </summary>
|
||||
public DateTime? GiftClaimedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单时间。
|
||||
/// </summary>
|
||||
public DateTime? FirstOrderAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道来源。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviterName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人展示名。
|
||||
/// </summary>
|
||||
public string InviteeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请时间。
|
||||
/// </summary>
|
||||
public DateTime InviteTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(pending_order/ordered)。
|
||||
/// </summary>
|
||||
public string OrderStatus { get; init; } = "pending_order";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放状态(pending/issued)。
|
||||
/// </summary>
|
||||
public string RewardStatus { get; init; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// 奖励发放时间。
|
||||
/// </summary>
|
||||
public DateTime? RewardIssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道来源。
|
||||
/// </summary>
|
||||
public string? SourceChannel { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客邀请记录分页结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerInviteRecordDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼券规则输入 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSaveCouponRuleInputDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 券类型(amount_off/discount/free_shipping)。
|
||||
/// </summary>
|
||||
public string CouponType { get; init; } = "amount_off";
|
||||
|
||||
/// <summary>
|
||||
/// 面值或折扣值。
|
||||
/// </summary>
|
||||
public decimal? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用门槛金额。
|
||||
/// </summary>
|
||||
public decimal? MinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期天数。
|
||||
/// </summary>
|
||||
public int ValidDays { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启新客礼包。
|
||||
/// </summary>
|
||||
public bool GiftEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼包类型(coupon/direct)。
|
||||
/// </summary>
|
||||
public string GiftType { get; init; } = "coupon";
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减金额。
|
||||
/// </summary>
|
||||
public decimal? DirectReduceAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 首单直减门槛金额。
|
||||
/// </summary>
|
||||
public decimal? DirectMinimumSpend { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否开启老带新分享。
|
||||
/// </summary>
|
||||
public bool InviteEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分享渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ShareChannels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 新客礼包券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> WelcomeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> InviterCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 被邀请人奖励券列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<NewCustomerCouponRuleDto> InviteeCoupons { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class NewCustomerStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 本月新客数。
|
||||
/// </summary>
|
||||
public int MonthlyNewCustomers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长人数。
|
||||
/// </summary>
|
||||
public int MonthlyGrowthCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 较上月增长百分比。
|
||||
/// </summary>
|
||||
public decimal MonthlyGrowthRatePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包领取率。
|
||||
/// </summary>
|
||||
public decimal GiftClaimRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月礼包已领取人数。
|
||||
/// </summary>
|
||||
public int GiftClaimedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单转化率。
|
||||
/// </summary>
|
||||
public decimal FirstOrderConversionRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月首单完成人数。
|
||||
/// </summary>
|
||||
public int FirstOrderedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客有礼详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerDetailQueryHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetNewCustomerDetailQuery, NewCustomerDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerDetailDto> Handle(GetNewCustomerDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.RecordPage);
|
||||
var normalizedPageSize = Math.Clamp(request.RecordPageSize, 1, 200);
|
||||
|
||||
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var rules = await repository.GetCouponRulesByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var settingsDto = BuildSettingsDto(request.StoreId, setting, rules);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var currentMonthStart = NewCustomerMapping.StartOfMonthUtc(nowUtc);
|
||||
var nextMonthStart = currentMonthStart.AddMonths(1);
|
||||
var previousMonthStart = currentMonthStart.AddMonths(-1);
|
||||
|
||||
var currentMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var previousMonthNewCustomerCount = await repository.CountRegisteredCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
previousMonthStart,
|
||||
currentMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var currentMonthGiftClaimedCount = await repository.CountGiftClaimedCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var currentMonthFirstOrderedCount = await repository.CountFirstOrderedCustomersAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
currentMonthStart,
|
||||
nextMonthStart,
|
||||
cancellationToken);
|
||||
|
||||
var stats = new NewCustomerStatsDto
|
||||
{
|
||||
MonthlyNewCustomers = currentMonthNewCustomerCount,
|
||||
MonthlyGrowthCount = currentMonthNewCustomerCount - previousMonthNewCustomerCount,
|
||||
MonthlyGrowthRatePercent = NewCustomerMapping.ToGrowthRatePercent(
|
||||
currentMonthNewCustomerCount,
|
||||
previousMonthNewCustomerCount),
|
||||
GiftClaimRate = NewCustomerMapping.ToRatePercent(
|
||||
currentMonthGiftClaimedCount,
|
||||
currentMonthNewCustomerCount),
|
||||
GiftClaimedCount = currentMonthGiftClaimedCount,
|
||||
FirstOrderConversionRate = NewCustomerMapping.ToRatePercent(
|
||||
currentMonthFirstOrderedCount,
|
||||
currentMonthNewCustomerCount),
|
||||
FirstOrderedCount = currentMonthFirstOrderedCount
|
||||
};
|
||||
|
||||
var (records, totalCount) = await repository.GetInviteRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new NewCustomerDetailDto
|
||||
{
|
||||
Settings = settingsDto,
|
||||
Stats = stats,
|
||||
InviteRecords = new NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
Items = records.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize,
|
||||
TotalCount = totalCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NewCustomerSettingsDto BuildSettingsDto(
|
||||
long storeId,
|
||||
NewCustomerGiftSetting? setting,
|
||||
IReadOnlyList<NewCustomerCouponRule> rules)
|
||||
{
|
||||
var welcomeCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.Welcome)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
var inviterCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.InviterReward)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
var inviteeCoupons = rules
|
||||
.Where(item => item.Scene == NewCustomerCouponScene.InviteeReward)
|
||||
.Select(NewCustomerMapping.ToCouponRuleDto)
|
||||
.ToList();
|
||||
|
||||
if (setting is null)
|
||||
{
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = true,
|
||||
GiftType = "coupon",
|
||||
InviteEnabled = true,
|
||||
ShareChannels = ["wechat_friend", "moments"],
|
||||
WelcomeCoupons = welcomeCoupons,
|
||||
InviterCoupons = inviterCoupons,
|
||||
InviteeCoupons = inviteeCoupons,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = storeId,
|
||||
GiftEnabled = setting.GiftEnabled,
|
||||
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
|
||||
DirectReduceAmount = setting.DirectReduceAmount,
|
||||
DirectMinimumSpend = setting.DirectMinimumSpend,
|
||||
InviteEnabled = setting.InviteEnabled,
|
||||
ShareChannels = NewCustomerMapping.DeserializeShareChannels(setting.ShareChannelsJson),
|
||||
WelcomeCoupons = welcomeCoupons,
|
||||
InviterCoupons = inviterCoupons,
|
||||
InviteeCoupons = inviteeCoupons,
|
||||
UpdatedAt = setting.UpdatedAt ?? setting.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客邀请记录分页处理器。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerInviteRecordListQueryHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetNewCustomerInviteRecordListQuery, NewCustomerInviteRecordListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerInviteRecordListResultDto> Handle(
|
||||
GetNewCustomerInviteRecordListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalizedPage = Math.Max(1, request.Page);
|
||||
var normalizedPageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
var (items, totalCount) = await repository.GetInviteRecordsAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
normalizedPage,
|
||||
normalizedPageSize,
|
||||
cancellationToken);
|
||||
|
||||
return new NewCustomerInviteRecordListResultDto
|
||||
{
|
||||
Items = items.Select(NewCustomerMapping.ToInviteRecordDto).ToList(),
|
||||
Page = normalizedPage,
|
||||
PageSize = normalizedPageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存新客有礼配置处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveNewCustomerSettingsCommandHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveNewCustomerSettingsCommand, NewCustomerSettingsDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerSettingsDto> Handle(
|
||||
SaveNewCustomerSettingsCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var giftType = NewCustomerMapping.ParseGiftType(request.GiftType);
|
||||
var shareChannels = NewCustomerMapping.NormalizeShareChannels(request.ShareChannels);
|
||||
|
||||
var welcomeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.Welcome,
|
||||
request.WelcomeCoupons);
|
||||
|
||||
var inviterRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.InviterReward,
|
||||
request.InviterCoupons);
|
||||
|
||||
var inviteeRules = NewCustomerMapping.NormalizeCouponRulesForSave(
|
||||
request.StoreId,
|
||||
NewCustomerCouponScene.InviteeReward,
|
||||
request.InviteeCoupons);
|
||||
|
||||
if (giftType == NewCustomerGiftType.Coupon && welcomeRules.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "优惠券包至少需要一张券");
|
||||
}
|
||||
|
||||
if (giftType == NewCustomerGiftType.Direct)
|
||||
{
|
||||
if (!request.DirectReduceAmount.HasValue || request.DirectReduceAmount.Value <= 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "directReduceAmount 必须大于 0");
|
||||
}
|
||||
|
||||
if (!request.DirectMinimumSpend.HasValue || request.DirectMinimumSpend.Value < 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "directMinimumSpend 不能小于 0");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.InviteEnabled && (inviterRules.Count == 0 || inviteeRules.Count == 0))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "开启邀请后必须配置邀请人和被邀请人奖励券");
|
||||
}
|
||||
|
||||
var setting = await repository.FindSettingByStoreIdAsync(tenantId, request.StoreId, cancellationToken);
|
||||
var isNewSetting = setting is null;
|
||||
if (setting is null)
|
||||
{
|
||||
setting = new NewCustomerGiftSetting
|
||||
{
|
||||
StoreId = request.StoreId
|
||||
};
|
||||
|
||||
await repository.AddSettingAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
setting.GiftEnabled = request.GiftEnabled;
|
||||
setting.GiftType = giftType;
|
||||
setting.DirectReduceAmount = giftType == NewCustomerGiftType.Direct
|
||||
? decimal.Round(request.DirectReduceAmount!.Value, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
setting.DirectMinimumSpend = giftType == NewCustomerGiftType.Direct
|
||||
? decimal.Round(request.DirectMinimumSpend!.Value, 2, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
setting.InviteEnabled = request.InviteEnabled;
|
||||
setting.ShareChannelsJson = NewCustomerMapping.SerializeShareChannels(shareChannels);
|
||||
|
||||
if (!isNewSetting)
|
||||
{
|
||||
await repository.UpdateSettingAsync(setting, cancellationToken);
|
||||
}
|
||||
|
||||
var allRules = new List<NewCustomerCouponRule>(welcomeRules.Count + inviterRules.Count + inviteeRules.Count);
|
||||
allRules.AddRange(welcomeRules);
|
||||
allRules.AddRange(inviterRules);
|
||||
allRules.AddRange(inviteeRules);
|
||||
|
||||
await repository.ReplaceCouponRulesAsync(
|
||||
tenantId,
|
||||
request.StoreId,
|
||||
allRules,
|
||||
cancellationToken);
|
||||
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new NewCustomerSettingsDto
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
GiftEnabled = setting.GiftEnabled,
|
||||
GiftType = NewCustomerMapping.ToGiftTypeText(setting.GiftType),
|
||||
DirectReduceAmount = setting.DirectReduceAmount,
|
||||
DirectMinimumSpend = setting.DirectMinimumSpend,
|
||||
InviteEnabled = setting.InviteEnabled,
|
||||
ShareChannels = shareChannels,
|
||||
WelcomeCoupons = welcomeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
InviterCoupons = inviterRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
InviteeCoupons = inviteeRules.Select(NewCustomerMapping.ToCouponRuleDto).ToList(),
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客成长记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerGrowthRecordCommandHandler(
|
||||
INewCustomerGiftRepository repository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<WriteNewCustomerGrowthRecordCommand, NewCustomerGrowthRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerGrowthRecordDto> Handle(
|
||||
WriteNewCustomerGrowthRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var customerKey = NewCustomerMapping.NormalizeCustomerKey(request.CustomerKey);
|
||||
var customerName = NewCustomerMapping.NormalizeOptionalText(request.CustomerName, "customerName", 64);
|
||||
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
|
||||
var registeredAt = NewCustomerMapping.NormalizeUtc(request.RegisteredAt);
|
||||
DateTime? giftClaimedAt = request.GiftClaimedAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.GiftClaimedAt.Value)
|
||||
: null;
|
||||
DateTime? firstOrderAt = request.FirstOrderAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.FirstOrderAt.Value)
|
||||
: null;
|
||||
|
||||
var entity = await repository.FindGrowthRecordByCustomerKeyAsync(
|
||||
tenantId: tenantId,
|
||||
storeId: request.StoreId,
|
||||
customerKey: customerKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new NewCustomerGrowthRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
CustomerKey = customerKey,
|
||||
CustomerName = customerName,
|
||||
RegisteredAt = registeredAt,
|
||||
GiftClaimedAt = giftClaimedAt,
|
||||
FirstOrderAt = firstOrderAt,
|
||||
SourceChannel = sourceChannel
|
||||
};
|
||||
|
||||
await repository.AddGrowthRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
return NewCustomerMapping.ToGrowthRecordDto(entity);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(customerName))
|
||||
{
|
||||
entity.CustomerName = customerName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceChannel))
|
||||
{
|
||||
entity.SourceChannel = sourceChannel;
|
||||
}
|
||||
|
||||
entity.RegisteredAt = registeredAt < entity.RegisteredAt
|
||||
? registeredAt
|
||||
: entity.RegisteredAt;
|
||||
entity.GiftClaimedAt = MergeNullableDate(entity.GiftClaimedAt, giftClaimedAt);
|
||||
entity.FirstOrderAt = MergeNullableDate(entity.FirstOrderAt, firstOrderAt);
|
||||
|
||||
await repository.UpdateGrowthRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
return NewCustomerMapping.ToGrowthRecordDto(entity);
|
||||
}
|
||||
|
||||
private static DateTime? MergeNullableDate(DateTime? existing, DateTime? incoming)
|
||||
{
|
||||
if (!existing.HasValue)
|
||||
{
|
||||
return incoming;
|
||||
}
|
||||
|
||||
if (!incoming.HasValue)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
return incoming.Value < existing.Value ? incoming : existing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 写入新客邀请记录处理器。
|
||||
/// </summary>
|
||||
public sealed class WriteNewCustomerInviteRecordCommandHandler(
|
||||
INewCustomerGiftRepository repository)
|
||||
: IRequestHandler<WriteNewCustomerInviteRecordCommand, NewCustomerInviteRecordDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<NewCustomerInviteRecordDto> Handle(
|
||||
WriteNewCustomerInviteRecordCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.StoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeId 参数不合法");
|
||||
}
|
||||
|
||||
var inviterName = NewCustomerMapping.NormalizeDisplayName(request.InviterName, "inviterName");
|
||||
var inviteeName = NewCustomerMapping.NormalizeDisplayName(request.InviteeName, "inviteeName");
|
||||
var orderStatus = NewCustomerMapping.ParseInviteOrderStatus(request.OrderStatus);
|
||||
var rewardStatus = NewCustomerMapping.ParseInviteRewardStatus(request.RewardStatus);
|
||||
var sourceChannel = NewCustomerMapping.NormalizeOptionalText(request.SourceChannel, "sourceChannel", 32);
|
||||
|
||||
var inviteTime = NewCustomerMapping.NormalizeUtc(request.InviteTime);
|
||||
DateTime? rewardIssuedAt = rewardStatus == NewCustomerInviteRewardStatus.Issued
|
||||
? request.RewardIssuedAt.HasValue
|
||||
? NewCustomerMapping.NormalizeUtc(request.RewardIssuedAt.Value)
|
||||
: DateTime.UtcNow
|
||||
: null;
|
||||
|
||||
var entity = new NewCustomerInviteRecord
|
||||
{
|
||||
StoreId = request.StoreId,
|
||||
InviterName = inviterName,
|
||||
InviteeName = inviteeName,
|
||||
InviteTime = inviteTime,
|
||||
OrderStatus = orderStatus,
|
||||
RewardStatus = rewardStatus,
|
||||
RewardIssuedAt = rewardIssuedAt,
|
||||
SourceChannel = sourceChannel
|
||||
};
|
||||
|
||||
await repository.AddInviteRecordAsync(entity, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return NewCustomerMapping.ToInviteRecordDto(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer;
|
||||
|
||||
/// <summary>
|
||||
/// 新客有礼映射与规则校验。
|
||||
/// </summary>
|
||||
internal static class NewCustomerMapping
|
||||
{
|
||||
private static readonly HashSet<string> AllowedShareChannels =
|
||||
[
|
||||
"wechat_friend",
|
||||
"moments",
|
||||
"sms"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public static NewCustomerGiftType ParseGiftType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"coupon" => NewCustomerGiftType.Coupon,
|
||||
"direct" => NewCustomerGiftType.Direct,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "giftType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToGiftTypeText(NewCustomerGiftType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerGiftType.Coupon => "coupon",
|
||||
NewCustomerGiftType.Direct => "direct",
|
||||
_ => "coupon"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerCouponScene ParseCouponScene(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"welcome" => NewCustomerCouponScene.Welcome,
|
||||
"inviter" => NewCustomerCouponScene.InviterReward,
|
||||
"invitee" => NewCustomerCouponScene.InviteeReward,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "scene 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToCouponSceneText(NewCustomerCouponScene value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerCouponScene.Welcome => "welcome",
|
||||
NewCustomerCouponScene.InviterReward => "inviter",
|
||||
NewCustomerCouponScene.InviteeReward => "invitee",
|
||||
_ => "welcome"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerCouponType ParseCouponType(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"amount_off" => NewCustomerCouponType.AmountOff,
|
||||
"discount" => NewCustomerCouponType.Discount,
|
||||
"free_shipping" => NewCustomerCouponType.FreeShipping,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToCouponTypeText(NewCustomerCouponType value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerCouponType.AmountOff => "amount_off",
|
||||
NewCustomerCouponType.Discount => "discount",
|
||||
NewCustomerCouponType.FreeShipping => "free_shipping",
|
||||
_ => "amount_off"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteOrderStatus ParseInviteOrderStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"pending_order" => NewCustomerInviteOrderStatus.PendingOrder,
|
||||
"ordered" => NewCustomerInviteOrderStatus.Ordered,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "orderStatus 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToInviteOrderStatusText(NewCustomerInviteOrderStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerInviteOrderStatus.PendingOrder => "pending_order",
|
||||
NewCustomerInviteOrderStatus.Ordered => "ordered",
|
||||
_ => "pending_order"
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteRewardStatus ParseInviteRewardStatus(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"pending" => NewCustomerInviteRewardStatus.Pending,
|
||||
"issued" => NewCustomerInviteRewardStatus.Issued,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "rewardStatus 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToInviteRewardStatusText(NewCustomerInviteRewardStatus value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
NewCustomerInviteRewardStatus.Pending => "pending",
|
||||
NewCustomerInviteRewardStatus.Issued => "issued",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime NormalizeUtc(DateTime value)
|
||||
{
|
||||
return value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime StartOfMonthUtc(DateTime nowUtc)
|
||||
{
|
||||
var utc = NormalizeUtc(nowUtc);
|
||||
return new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public static decimal ToRatePercent(int numerator, int denominator)
|
||||
{
|
||||
if (denominator <= 0 || numerator <= 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return decimal.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static decimal ToGrowthRatePercent(int currentValue, int previousValue)
|
||||
{
|
||||
if (previousValue <= 0)
|
||||
{
|
||||
return currentValue > 0 ? 100m : 0m;
|
||||
}
|
||||
|
||||
return decimal.Round(
|
||||
(currentValue - previousValue) * 100m / previousValue,
|
||||
1,
|
||||
MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static string SerializeShareChannels(IReadOnlyCollection<string> channels)
|
||||
{
|
||||
return JsonSerializer.Serialize(channels, JsonOptions);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> DeserializeShareChannels(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var values = JsonSerializer.Deserialize<List<string>>(payload, JsonOptions) ?? [];
|
||||
return NormalizeShareChannels(values);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeShareChannels(IEnumerable<string>? values)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedShareChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "shareChannels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NewCustomerCouponRule> NormalizeCouponRulesForSave(
|
||||
long storeId,
|
||||
NewCustomerCouponScene scene,
|
||||
IReadOnlyCollection<NewCustomerSaveCouponRuleInputDto>? values)
|
||||
{
|
||||
var rules = (values ?? [])
|
||||
.Select((item, index) => NormalizeCouponRuleForSave(storeId, scene, item, index + 1))
|
||||
.ToList();
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
public static NewCustomerCouponRuleDto ToCouponRuleDto(NewCustomerCouponRule source)
|
||||
{
|
||||
return new NewCustomerCouponRuleDto
|
||||
{
|
||||
Id = source.Id,
|
||||
Scene = ToCouponSceneText(source.Scene),
|
||||
CouponType = ToCouponTypeText(source.CouponType),
|
||||
Value = source.Value,
|
||||
MinimumSpend = source.MinimumSpend,
|
||||
ValidDays = source.ValidDays,
|
||||
SortOrder = source.SortOrder
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerInviteRecordDto ToInviteRecordDto(NewCustomerInviteRecord source)
|
||||
{
|
||||
return new NewCustomerInviteRecordDto
|
||||
{
|
||||
Id = source.Id,
|
||||
InviterName = source.InviterName,
|
||||
InviteeName = source.InviteeName,
|
||||
InviteTime = source.InviteTime,
|
||||
OrderStatus = ToInviteOrderStatusText(source.OrderStatus),
|
||||
RewardStatus = ToInviteRewardStatusText(source.RewardStatus),
|
||||
RewardIssuedAt = source.RewardIssuedAt,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
public static NewCustomerGrowthRecordDto ToGrowthRecordDto(NewCustomerGrowthRecord source)
|
||||
{
|
||||
return new NewCustomerGrowthRecordDto
|
||||
{
|
||||
Id = source.Id,
|
||||
CustomerKey = source.CustomerKey,
|
||||
CustomerName = source.CustomerName,
|
||||
RegisteredAt = source.RegisteredAt,
|
||||
GiftClaimedAt = source.GiftClaimedAt,
|
||||
FirstOrderAt = source.FirstOrderAt,
|
||||
SourceChannel = source.SourceChannel
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeDisplayName(string? value, string fieldName)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string NormalizeCustomerKey(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "customerKey 长度不能超过 64");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static string? NormalizeOptionalText(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 长度不能超过 {maxLength}");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static NewCustomerCouponRule NormalizeCouponRuleForSave(
|
||||
long storeId,
|
||||
NewCustomerCouponScene scene,
|
||||
NewCustomerSaveCouponRuleInputDto value,
|
||||
int sortOrder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var couponType = ParseCouponType(value.CouponType);
|
||||
var minimumSpend = NormalizeNonNegativeMoney(value.MinimumSpend, "minimumSpend");
|
||||
decimal? normalizedValue = couponType switch
|
||||
{
|
||||
NewCustomerCouponType.AmountOff => NormalizePositiveMoney(value.Value, "value"),
|
||||
NewCustomerCouponType.Discount => NormalizeDiscount(value.Value),
|
||||
NewCustomerCouponType.FreeShipping => null,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "couponType 参数不合法")
|
||||
};
|
||||
|
||||
if (couponType == NewCustomerCouponType.AmountOff &&
|
||||
minimumSpend.HasValue &&
|
||||
normalizedValue.HasValue &&
|
||||
normalizedValue.Value >= minimumSpend.Value &&
|
||||
minimumSpend.Value > 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减券 value 必须小于 minimumSpend");
|
||||
}
|
||||
|
||||
if (value.ValidDays is < 1 or > 365)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "validDays 必须在 1-365 之间");
|
||||
}
|
||||
|
||||
return new NewCustomerCouponRule
|
||||
{
|
||||
StoreId = storeId,
|
||||
Scene = scene,
|
||||
CouponType = couponType,
|
||||
Value = normalizedValue,
|
||||
MinimumSpend = minimumSpend,
|
||||
ValidDays = value.ValidDays,
|
||||
SortOrder = sortOrder
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal? NormalizeNonNegativeMoney(decimal? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value < 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 不能小于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizePositiveMoney(decimal? value, string fieldName)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 必须大于 0");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizeDiscount(decimal? value)
|
||||
{
|
||||
if (!value.HasValue || value.Value <= 0m || value.Value >= 10m)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "折扣券 value 必须在 0-10 之间");
|
||||
}
|
||||
|
||||
return decimal.Round(value.Value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客有礼详情。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerDetailQuery : IRequest<NewCustomerDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录页码。
|
||||
/// </summary>
|
||||
public int RecordPage { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 邀请记录每页条数。
|
||||
/// </summary>
|
||||
public int RecordPageSize { get; init; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.NewCustomer.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.NewCustomer.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询新客邀请记录分页。
|
||||
/// </summary>
|
||||
public sealed class GetNewCustomerInviteRecordListQuery : IRequest<NewCustomerInviteRecordListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 10;
|
||||
}
|
||||
Reference in New Issue
Block a user