feat(marketing): add full reduction campaign api module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改满减活动状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangeFullReductionCampaignStatusCommand : IRequest<FullReductionDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "completed";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除满减活动命令。
|
||||
/// </summary>
|
||||
public sealed class DeleteFullReductionCampaignCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存满减活动命令。
|
||||
/// </summary>
|
||||
public sealed class SaveFullReductionCampaignCommand : IRequest<FullReductionDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public long? CampaignId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; init; } = "reduce";
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<FullReductionTierRuleDto> ReduceTiers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则。
|
||||
/// </summary>
|
||||
public FullReductionGiftRuleDto? GiftRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则。
|
||||
/// </summary>
|
||||
public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始时间。
|
||||
/// </summary>
|
||||
public DateTime StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束时间。
|
||||
/// </summary>
|
||||
public DateTime EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> StoreScopeStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 选品基准门店 ID。
|
||||
/// </summary>
|
||||
public long ScopeStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可叠加优惠券。
|
||||
/// </summary>
|
||||
public bool StackWithCoupon { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动说明。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FullReductionMetricsDto? Metrics { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始时间。
|
||||
/// </summary>
|
||||
public DateTime StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束时间。
|
||||
/// </summary>
|
||||
public DateTime EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public FullReductionRulesDto Rules { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionGiftRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 购买数量门槛。
|
||||
/// </summary>
|
||||
public int BuyQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠送数量。
|
||||
/// </summary>
|
||||
public int GiftQuantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠品范围类型(same_lowest/specified)。
|
||||
/// </summary>
|
||||
public string GiftScopeType { get; init; } = "same_lowest";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleDto ApplicableScope { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 指定赠品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleDto GiftScope { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始时间。
|
||||
/// </summary>
|
||||
public DateTime StartAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束时间。
|
||||
/// </summary>
|
||||
public DateTime EndAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public FullReductionRulesDto Rules { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FullReductionListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计信息。
|
||||
/// </summary>
|
||||
public FullReductionStatsDto Stats { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动指标 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionMetricsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 参与订单数。
|
||||
/// </summary>
|
||||
public int ParticipatingOrderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客单价提升。
|
||||
/// </summary>
|
||||
public decimal TicketIncreaseAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 赠出商品数量。
|
||||
/// </summary>
|
||||
public int GiftedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 带动销售额。
|
||||
/// </summary>
|
||||
public decimal DrivenSalesAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 连带率提升百分比。
|
||||
/// </summary>
|
||||
public decimal AttachRateIncreasePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月带动销售额。
|
||||
/// </summary>
|
||||
public decimal MonthlyDrivenSalesAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价提升。
|
||||
/// </summary>
|
||||
public decimal AverageTicketIncrease { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionRulesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动类型(reduce/gift/second_half)。
|
||||
/// </summary>
|
||||
public string ActivityType { get; init; } = "reduce";
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FullReductionTierRuleDto> ReduceTiers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 满赠规则。
|
||||
/// </summary>
|
||||
public FullReductionGiftRuleDto? GiftRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则。
|
||||
/// </summary>
|
||||
public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围模式(all/stores)。
|
||||
/// </summary>
|
||||
public string StoreScopeMode { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 门店范围 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> StoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 选品基准门店 ID。
|
||||
/// </summary>
|
||||
public long ScopeStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可叠加优惠券。
|
||||
/// </summary>
|
||||
public bool StackWithCoupon { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动说明。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计指标。
|
||||
/// </summary>
|
||||
public FullReductionMetricsDto Metrics { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商品范围 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionScopeRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 范围类型(all/category/product)。
|
||||
/// </summary>
|
||||
public string ScopeType { get; init; } = "all";
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> CategoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> ProductIds { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 第二份半价规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionSecondHalfRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 折扣类型(half/sixty/seventy/free)。
|
||||
/// </summary>
|
||||
public string DiscountType { get; init; } = "half";
|
||||
|
||||
/// <summary>
|
||||
/// 适用商品范围。
|
||||
/// </summary>
|
||||
public FullReductionScopeRuleDto ApplicableScope { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月带动销售额。
|
||||
/// </summary>
|
||||
public decimal MonthlyDrivenSalesAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 平均客单价提升。
|
||||
/// </summary>
|
||||
public decimal AverageTicketIncrease { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 满减阶梯 DTO。
|
||||
/// </summary>
|
||||
public sealed class FullReductionTierRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 满足金额。
|
||||
/// </summary>
|
||||
public decimal MeetAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 减免金额。
|
||||
/// </summary>
|
||||
public decimal ReduceAmount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动 DTO 映射工厂。
|
||||
/// </summary>
|
||||
internal static class FullReductionDtoFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建列表项 DTO。
|
||||
/// </summary>
|
||||
public static FullReductionListItemDto ToListItemDto(
|
||||
PromotionCampaign campaign,
|
||||
FullReductionRulesDto rules,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var displayStatus = FullReductionMapping.ResolveDisplayStatus(campaign, nowUtc);
|
||||
return new FullReductionListItemDto
|
||||
{
|
||||
Id = campaign.Id,
|
||||
Name = campaign.Name,
|
||||
StartAt = campaign.StartAt,
|
||||
EndAt = campaign.EndAt,
|
||||
DisplayStatus = displayStatus,
|
||||
IsDimmed = FullReductionMapping.IsDimmed(displayStatus),
|
||||
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
|
||||
Rules = rules
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建详情 DTO。
|
||||
/// </summary>
|
||||
public static FullReductionDetailDto ToDetailDto(
|
||||
PromotionCampaign campaign,
|
||||
FullReductionRulesDto rules,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
return new FullReductionDetailDto
|
||||
{
|
||||
Id = campaign.Id,
|
||||
Name = campaign.Name,
|
||||
StartAt = campaign.StartAt,
|
||||
EndAt = campaign.EndAt,
|
||||
Status = FullReductionMapping.ToStatusText(campaign.Status),
|
||||
DisplayStatus = FullReductionMapping.ResolveDisplayStatus(campaign, nowUtc),
|
||||
UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt,
|
||||
Rules = rules
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建统计 DTO。
|
||||
/// </summary>
|
||||
public static FullReductionStatsDto ToStatsDto(IReadOnlyCollection<FullReductionListItemDto> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new FullReductionStatsDto();
|
||||
}
|
||||
|
||||
var monthlyDrivenSalesAmount = items.Sum(item => item.Rules.Metrics.MonthlyDrivenSalesAmount);
|
||||
|
||||
var metricsForAverage = items
|
||||
.Select(item => item.Rules.Metrics.AverageTicketIncrease)
|
||||
.Where(value => value > 0)
|
||||
.ToList();
|
||||
|
||||
var averageTicketIncrease = metricsForAverage.Count == 0
|
||||
? 0m
|
||||
: decimal.Round(metricsForAverage.Average(), 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
return new FullReductionStatsDto
|
||||
{
|
||||
TotalCount = items.Count,
|
||||
OngoingCount = items.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)),
|
||||
MonthlyDrivenSalesAmount = decimal.Round(monthlyDrivenSalesAmount, 2, MidpointRounding.AwayFromZero),
|
||||
AverageTicketIncrease = averageTicketIncrease
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建默认新增活动实体。
|
||||
/// </summary>
|
||||
public static PromotionCampaign CreateNewCampaign(
|
||||
string name,
|
||||
DateTime startAt,
|
||||
DateTime endAt,
|
||||
string rulesJson,
|
||||
string? description)
|
||||
{
|
||||
return new PromotionCampaign
|
||||
{
|
||||
Name = name,
|
||||
PromotionType = PromotionType.FullReduction,
|
||||
Status = PromotionStatus.Active,
|
||||
StartAt = startAt,
|
||||
EndAt = endAt,
|
||||
RulesJson = rulesJson,
|
||||
AudienceDescription = description,
|
||||
Budget = null,
|
||||
BannerUrl = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.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.FullReduction;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动映射辅助。
|
||||
/// </summary>
|
||||
internal static class FullReductionMapping
|
||||
{
|
||||
private const string ActivityTypeGift = "gift";
|
||||
private const string ActivityTypeReduce = "reduce";
|
||||
private const string ActivityTypeSecondHalf = "second_half";
|
||||
|
||||
private const string DiscountTypeFree = "free";
|
||||
private const string DiscountTypeHalf = "half";
|
||||
private const string DiscountTypeSeventy = "seventy";
|
||||
private const string DiscountTypeSixty = "sixty";
|
||||
|
||||
private const string DisplayStatusEnded = "ended";
|
||||
private const string DisplayStatusOngoing = "ongoing";
|
||||
private const string DisplayStatusUpcoming = "upcoming";
|
||||
|
||||
private const string GiftScopeSameLowest = "same_lowest";
|
||||
private const string GiftScopeSpecified = "specified";
|
||||
|
||||
private const string ScopeTypeAll = "all";
|
||||
private const string ScopeTypeCategory = "category";
|
||||
private const string ScopeTypeProduct = "product";
|
||||
|
||||
private static readonly HashSet<string> AllowedActivityTypes =
|
||||
[
|
||||
ActivityTypeReduce,
|
||||
ActivityTypeGift,
|
||||
ActivityTypeSecondHalf
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> AllowedChannels =
|
||||
[
|
||||
"delivery",
|
||||
"pickup",
|
||||
"dine_in"
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> AllowedDiscountTypes =
|
||||
[
|
||||
DiscountTypeHalf,
|
||||
DiscountTypeSixty,
|
||||
DiscountTypeSeventy,
|
||||
DiscountTypeFree
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> AllowedDisplayStatuses =
|
||||
[
|
||||
DisplayStatusOngoing,
|
||||
DisplayStatusUpcoming,
|
||||
DisplayStatusEnded
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> AllowedStoreScopeModes =
|
||||
[
|
||||
"all",
|
||||
"stores"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 校验并标准化活动类型筛选。
|
||||
/// </summary>
|
||||
public static bool TryNormalizeActivityTypeFilter(string? value, out string? normalized)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
normalized = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AllowedActivityTypes.Contains(candidate))
|
||||
{
|
||||
normalized = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
normalized = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并标准化展示状态筛选。
|
||||
/// </summary>
|
||||
public static bool TryNormalizeDisplayStatusFilter(string? value, out string? normalized)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
normalized = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AllowedDisplayStatuses.Contains(candidate))
|
||||
{
|
||||
normalized = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
normalized = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析状态文本为领域状态。
|
||||
/// </summary>
|
||||
public static PromotionStatus ParseStatus(string? value)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return candidate switch
|
||||
{
|
||||
"active" => PromotionStatus.Active,
|
||||
"completed" => PromotionStatus.Completed,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 输出状态文本。
|
||||
/// </summary>
|
||||
public static string ToStatusText(PromotionStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
PromotionStatus.Active => "active",
|
||||
PromotionStatus.Completed => "completed",
|
||||
_ => "completed"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析活动规则。
|
||||
/// </summary>
|
||||
public static FullReductionRulesDto DeserializeRules(string? rulesJson, long campaignId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rulesJson))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<FullReductionRulesDto>(rulesJson, JsonOptions)
|
||||
?? throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则格式错误");
|
||||
|
||||
return NormalizeRulesForSave(
|
||||
payload.ActivityType,
|
||||
payload.ReduceTiers,
|
||||
payload.GiftRule,
|
||||
payload.SecondHalfRule,
|
||||
payload.Channels,
|
||||
payload.StoreScopeMode,
|
||||
payload.StoreIds,
|
||||
payload.ScopeStoreId,
|
||||
payload.StackWithCoupon,
|
||||
payload.Description,
|
||||
payload.Metrics,
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 序列化活动规则。
|
||||
/// </summary>
|
||||
public static string SerializeRules(FullReductionRulesDto rules)
|
||||
{
|
||||
return JsonSerializer.Serialize(rules, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标准化并校验保存规则。
|
||||
/// </summary>
|
||||
public static FullReductionRulesDto NormalizeRulesForSave(
|
||||
string? activityType,
|
||||
IReadOnlyCollection<FullReductionTierRuleDto>? reduceTiers,
|
||||
FullReductionGiftRuleDto? giftRule,
|
||||
FullReductionSecondHalfRuleDto? secondHalfRule,
|
||||
IReadOnlyCollection<string>? channels,
|
||||
string? storeScopeMode,
|
||||
IReadOnlyCollection<long>? storeIds,
|
||||
long scopeStoreId,
|
||||
bool stackWithCoupon,
|
||||
string? description,
|
||||
FullReductionMetricsDto? metrics,
|
||||
FullReductionMetricsDto? fallbackMetrics)
|
||||
{
|
||||
var normalizedActivityType = NormalizeActivityType(activityType);
|
||||
var normalizedChannels = NormalizeChannels(channels);
|
||||
var normalizedStoreScopeMode = NormalizeStoreScopeMode(storeScopeMode);
|
||||
var normalizedStoreIds = NormalizeStoreIds(storeIds);
|
||||
|
||||
if (scopeStoreId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 参数不合法");
|
||||
}
|
||||
|
||||
if (!normalizedStoreIds.Contains(scopeStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 必须在活动门店范围内");
|
||||
}
|
||||
|
||||
var normalizedDescription = NormalizeDescription(description);
|
||||
var normalizedMetrics = NormalizeMetrics(metrics ?? fallbackMetrics);
|
||||
|
||||
return normalizedActivityType switch
|
||||
{
|
||||
ActivityTypeReduce => new FullReductionRulesDto
|
||||
{
|
||||
ActivityType = ActivityTypeReduce,
|
||||
ReduceTiers = NormalizeReduceTiers(reduceTiers),
|
||||
GiftRule = null,
|
||||
SecondHalfRule = null,
|
||||
Channels = normalizedChannels,
|
||||
StoreScopeMode = normalizedStoreScopeMode,
|
||||
StoreIds = normalizedStoreIds,
|
||||
ScopeStoreId = scopeStoreId,
|
||||
StackWithCoupon = stackWithCoupon,
|
||||
Description = normalizedDescription,
|
||||
Metrics = normalizedMetrics
|
||||
},
|
||||
ActivityTypeGift => new FullReductionRulesDto
|
||||
{
|
||||
ActivityType = ActivityTypeGift,
|
||||
ReduceTiers = [],
|
||||
GiftRule = NormalizeGiftRule(giftRule),
|
||||
SecondHalfRule = null,
|
||||
Channels = normalizedChannels,
|
||||
StoreScopeMode = normalizedStoreScopeMode,
|
||||
StoreIds = normalizedStoreIds,
|
||||
ScopeStoreId = scopeStoreId,
|
||||
StackWithCoupon = stackWithCoupon,
|
||||
Description = normalizedDescription,
|
||||
Metrics = normalizedMetrics
|
||||
},
|
||||
ActivityTypeSecondHalf => new FullReductionRulesDto
|
||||
{
|
||||
ActivityType = ActivityTypeSecondHalf,
|
||||
ReduceTiers = [],
|
||||
GiftRule = null,
|
||||
SecondHalfRule = NormalizeSecondHalfRule(secondHalfRule),
|
||||
Channels = normalizedChannels,
|
||||
StoreScopeMode = normalizedStoreScopeMode,
|
||||
StoreIds = normalizedStoreIds,
|
||||
ScopeStoreId = scopeStoreId,
|
||||
StackWithCoupon = stackWithCoupon,
|
||||
Description = normalizedDescription,
|
||||
Metrics = normalizedMetrics
|
||||
},
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断活动是否对可见门店集合可见。
|
||||
/// </summary>
|
||||
public static bool HasVisibleStore(FullReductionRulesDto rules, IReadOnlyCollection<long> visibleStoreIds)
|
||||
{
|
||||
if (visibleStoreIds.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rules.StoreIds.Any(visibleStoreIds.Contains);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析卡片展示状态。
|
||||
/// </summary>
|
||||
public static string ResolveDisplayStatus(PromotionCampaign campaign, DateTime nowUtc)
|
||||
{
|
||||
if (campaign.Status == PromotionStatus.Completed || campaign.Status == PromotionStatus.Paused)
|
||||
{
|
||||
return DisplayStatusEnded;
|
||||
}
|
||||
|
||||
if (nowUtc < campaign.StartAt)
|
||||
{
|
||||
return DisplayStatusUpcoming;
|
||||
}
|
||||
|
||||
if (nowUtc > campaign.EndAt)
|
||||
{
|
||||
return DisplayStatusEnded;
|
||||
}
|
||||
|
||||
return DisplayStatusOngoing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否弱化展示。
|
||||
/// </summary>
|
||||
public static bool IsDimmed(string displayStatus)
|
||||
{
|
||||
return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将结束时间规范为当天 23:59:59.9999999。
|
||||
/// </summary>
|
||||
public static DateTime NormalizeEndOfDay(DateTime dateTime)
|
||||
{
|
||||
var utc = dateTime.Kind == DateTimeKind.Utc
|
||||
? dateTime
|
||||
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
|
||||
return utc.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将开始时间规范为当天 00:00:00。
|
||||
/// </summary>
|
||||
public static DateTime NormalizeStartOfDay(DateTime dateTime)
|
||||
{
|
||||
var utc = dateTime.Kind == DateTimeKind.Utc
|
||||
? dateTime
|
||||
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
|
||||
return utc.Date;
|
||||
}
|
||||
|
||||
private static string NormalizeActivityType(string? value)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!AllowedActivityTypes.Contains(candidate))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string NormalizeStoreScopeMode(string? value)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!AllowedStoreScopeModes.Contains(candidate))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<long> NormalizeStoreIds(IReadOnlyCollection<long>? storeIds)
|
||||
{
|
||||
var normalized = (storeIds ?? Array.Empty<long>())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeChannels(IReadOnlyCollection<string>? channels)
|
||||
{
|
||||
var normalized = (channels ?? Array.Empty<string>())
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeDescription(string? description)
|
||||
{
|
||||
var normalized = (description ?? string.Empty).Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized.Length > 512)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "description 长度不能超过 512");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FullReductionTierRuleDto> NormalizeReduceTiers(IReadOnlyCollection<FullReductionTierRuleDto>? tiers)
|
||||
{
|
||||
var normalized = (tiers ?? Array.Empty<FullReductionTierRuleDto>())
|
||||
.Select(item => new FullReductionTierRuleDto
|
||||
{
|
||||
MeetAmount = NormalizeMoney(item.MeetAmount, "满减门槛金额必须大于 0"),
|
||||
ReduceAmount = NormalizeMoney(item.ReduceAmount, "满减优惠金额必须大于 0")
|
||||
})
|
||||
.OrderBy(item => item.MeetAmount)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减规则不能为空");
|
||||
}
|
||||
|
||||
for (var index = 0; index < normalized.Count; index++)
|
||||
{
|
||||
var current = normalized[index];
|
||||
if (current.ReduceAmount >= current.MeetAmount)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减优惠金额必须小于门槛金额");
|
||||
}
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var previous = normalized[index - 1];
|
||||
if (current.MeetAmount <= previous.MeetAmount)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "满减门槛金额必须严格递增");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static FullReductionGiftRuleDto NormalizeGiftRule(FullReductionGiftRuleDto? giftRule)
|
||||
{
|
||||
if (giftRule is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "giftRule 不能为空");
|
||||
}
|
||||
|
||||
if (giftRule.BuyQuantity <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "购买数量门槛必须大于 0");
|
||||
}
|
||||
|
||||
if (giftRule.GiftQuantity <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "赠送数量必须大于 0");
|
||||
}
|
||||
|
||||
var normalizedGiftScopeType = (giftRule.GiftScopeType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedGiftScopeType is not (GiftScopeSameLowest or GiftScopeSpecified))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "giftScopeType 参数不合法");
|
||||
}
|
||||
|
||||
var normalizedApplicableScope = NormalizeScope(giftRule.ApplicableScope, true, "适用商品范围");
|
||||
var normalizedGiftScope = normalizedGiftScopeType == GiftScopeSpecified
|
||||
? NormalizeScope(giftRule.GiftScope, false, "指定赠品范围")
|
||||
: new FullReductionScopeRuleDto
|
||||
{
|
||||
ScopeType = ScopeTypeAll,
|
||||
CategoryIds = [],
|
||||
ProductIds = []
|
||||
};
|
||||
|
||||
return new FullReductionGiftRuleDto
|
||||
{
|
||||
BuyQuantity = giftRule.BuyQuantity,
|
||||
GiftQuantity = giftRule.GiftQuantity,
|
||||
GiftScopeType = normalizedGiftScopeType,
|
||||
ApplicableScope = normalizedApplicableScope,
|
||||
GiftScope = normalizedGiftScope
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionSecondHalfRuleDto NormalizeSecondHalfRule(FullReductionSecondHalfRuleDto? secondHalfRule)
|
||||
{
|
||||
if (secondHalfRule is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "secondHalfRule 不能为空");
|
||||
}
|
||||
|
||||
var normalizedDiscountType = (secondHalfRule.DiscountType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!AllowedDiscountTypes.Contains(normalizedDiscountType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "discountType 参数不合法");
|
||||
}
|
||||
|
||||
var normalizedApplicableScope = NormalizeScope(secondHalfRule.ApplicableScope, false, "第二份半价适用范围");
|
||||
|
||||
return new FullReductionSecondHalfRuleDto
|
||||
{
|
||||
DiscountType = normalizedDiscountType,
|
||||
ApplicableScope = normalizedApplicableScope
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionScopeRuleDto NormalizeScope(
|
||||
FullReductionScopeRuleDto? scope,
|
||||
bool allowAll,
|
||||
string scopeName)
|
||||
{
|
||||
if (scope is null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}不能为空");
|
||||
}
|
||||
|
||||
var normalizedScopeType = (scope.ScopeType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedScopeType is not (ScopeTypeAll or ScopeTypeCategory or ScopeTypeProduct))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}类型不合法");
|
||||
}
|
||||
|
||||
if (!allowAll && normalizedScopeType == ScopeTypeAll)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}不支持全部商品");
|
||||
}
|
||||
|
||||
var normalizedCategoryIds = scope.CategoryIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
var normalizedProductIds = scope.ProductIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
if (normalizedScopeType == ScopeTypeCategory && normalizedCategoryIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}缺少分类");
|
||||
}
|
||||
|
||||
if (normalizedScopeType == ScopeTypeProduct && normalizedProductIds.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{scopeName}缺少商品");
|
||||
}
|
||||
|
||||
if (normalizedScopeType == ScopeTypeAll)
|
||||
{
|
||||
normalizedCategoryIds = [];
|
||||
normalizedProductIds = [];
|
||||
}
|
||||
|
||||
return new FullReductionScopeRuleDto
|
||||
{
|
||||
ScopeType = normalizedScopeType,
|
||||
CategoryIds = normalizedCategoryIds,
|
||||
ProductIds = normalizedProductIds
|
||||
};
|
||||
}
|
||||
|
||||
private static FullReductionMetricsDto NormalizeMetrics(FullReductionMetricsDto? metrics)
|
||||
{
|
||||
if (metrics is null)
|
||||
{
|
||||
return new FullReductionMetricsDto();
|
||||
}
|
||||
|
||||
return new FullReductionMetricsDto
|
||||
{
|
||||
ParticipatingOrderCount = Math.Max(0, metrics.ParticipatingOrderCount),
|
||||
DiscountTotalAmount = NormalizeNonNegativeMoney(metrics.DiscountTotalAmount),
|
||||
TicketIncreaseAmount = NormalizeNonNegativeMoney(metrics.TicketIncreaseAmount),
|
||||
GiftedCount = Math.Max(0, metrics.GiftedCount),
|
||||
DrivenSalesAmount = NormalizeNonNegativeMoney(metrics.DrivenSalesAmount),
|
||||
AttachRateIncreasePercent = NormalizeNonNegativeMoney(metrics.AttachRateIncreasePercent),
|
||||
MonthlyDrivenSalesAmount = NormalizeNonNegativeMoney(metrics.MonthlyDrivenSalesAmount),
|
||||
AverageTicketIncrease = NormalizeNonNegativeMoney(metrics.AverageTicketIncrease)
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal NormalizeMoney(decimal value, string errorMessage)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, errorMessage);
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static decimal NormalizeNonNegativeMoney(decimal value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
return decimal.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
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.FullReduction.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 修改满减活动状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeFullReductionCampaignStatusCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangeFullReductionCampaignStatusCommand, FullReductionDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FullReductionDetailDto> Handle(ChangeFullReductionCampaignStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var campaign = await promotionCampaignRepository.FindByIdAsync(
|
||||
request.CampaignId,
|
||||
tenantId,
|
||||
PromotionType.FullReduction,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
|
||||
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
|
||||
if (!rules.StoreIds.Contains(request.OperationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
var status = FullReductionMapping.ParseStatus(request.Status);
|
||||
campaign.Status = status;
|
||||
|
||||
if (status == PromotionStatus.Completed)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (campaign.EndAt > nowUtc)
|
||||
{
|
||||
campaign.EndAt = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
|
||||
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
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.FullReduction.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除满减活动命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteFullReductionCampaignCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeleteFullReductionCampaignCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> Handle(DeleteFullReductionCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var campaign = await promotionCampaignRepository.FindByIdAsync(
|
||||
request.CampaignId,
|
||||
tenantId,
|
||||
PromotionType.FullReduction,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
|
||||
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
|
||||
if (!rules.StoreIds.Contains(request.OperationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
await promotionCampaignRepository.DeleteAsync(campaign, cancellationToken);
|
||||
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||
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.FullReduction.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFullReductionCampaignDetailQueryHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFullReductionCampaignDetailQuery, FullReductionDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FullReductionDetailDto?> Handle(GetFullReductionCampaignDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var campaign = await promotionCampaignRepository.FindByIdAsync(
|
||||
request.CampaignId,
|
||||
tenantId,
|
||||
PromotionType.FullReduction,
|
||||
cancellationToken);
|
||||
|
||||
if (campaign is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
|
||||
if (!rules.StoreIds.Contains(request.OperationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||
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.FullReduction.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 满减活动列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFullReductionCampaignListQueryHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFullReductionCampaignListQuery, FullReductionListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FullReductionListResultDto> Handle(GetFullReductionCampaignListQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 200);
|
||||
|
||||
if (!FullReductionMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法");
|
||||
}
|
||||
|
||||
if (!FullReductionMapping.TryNormalizeActivityTypeFilter(request.ActivityType, out var normalizedActivityType))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "activityType 参数不合法");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (request.VisibleStoreIds.Count == 0)
|
||||
{
|
||||
return new FullReductionListResultDto
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = new FullReductionStatsDto()
|
||||
};
|
||||
}
|
||||
|
||||
var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(
|
||||
tenantId,
|
||||
PromotionType.FullReduction,
|
||||
cancellationToken);
|
||||
|
||||
if (campaigns.Count == 0)
|
||||
{
|
||||
return new FullReductionListResultDto
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = new FullReductionStatsDto()
|
||||
};
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
var visibleItems = new List<FullReductionListItemDto>(campaigns.Count);
|
||||
foreach (var campaign in campaigns)
|
||||
{
|
||||
var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
|
||||
if (!FullReductionMapping.HasVisibleStore(rules, request.VisibleStoreIds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visibleItems.Add(FullReductionDtoFactory.ToListItemDto(campaign, rules, nowUtc));
|
||||
}
|
||||
|
||||
var stats = FullReductionDtoFactory.ToStatsDto(visibleItems);
|
||||
|
||||
IEnumerable<FullReductionListItemDto> filtered = visibleItems;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedActivityType))
|
||||
{
|
||||
filtered = filtered.Where(item =>
|
||||
string.Equals(item.Rules.ActivityType, normalizedActivityType, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStatus))
|
||||
{
|
||||
filtered = filtered.Where(item =>
|
||||
string.Equals(item.DisplayStatus, normalizedStatus, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var keyword = request.Keyword?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
filtered = filtered.Where(item =>
|
||||
item.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var ordered = filtered
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.ToList();
|
||||
|
||||
var total = ordered.Count;
|
||||
var paged = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return new FullReductionListResultDto
|
||||
{
|
||||
Items = paged,
|
||||
TotalCount = total,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Stats = stats
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.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.FullReduction.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 保存满减活动命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveFullReductionCampaignCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveFullReductionCampaignCommand, FullReductionDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FullReductionDetailDto> Handle(SaveFullReductionCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedName = request.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "活动名称不能为空");
|
||||
}
|
||||
|
||||
if (normalizedName.Length > 64)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "活动名称长度不能超过 64");
|
||||
}
|
||||
|
||||
var normalizedStartAt = FullReductionMapping.NormalizeStartOfDay(request.StartAt);
|
||||
var normalizedEndAt = FullReductionMapping.NormalizeEndOfDay(request.EndAt);
|
||||
if (normalizedStartAt > normalizedEndAt)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "活动开始时间不能晚于结束时间");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
var existingMetrics = default(FullReductionMetricsDto);
|
||||
PromotionCampaign campaign;
|
||||
if (!request.CampaignId.HasValue)
|
||||
{
|
||||
var rules = FullReductionMapping.NormalizeRulesForSave(
|
||||
request.ActivityType,
|
||||
request.ReduceTiers,
|
||||
request.GiftRule,
|
||||
request.SecondHalfRule,
|
||||
request.Channels,
|
||||
request.StoreScopeMode,
|
||||
request.StoreScopeStoreIds,
|
||||
request.ScopeStoreId,
|
||||
request.StackWithCoupon,
|
||||
request.Description,
|
||||
request.Metrics,
|
||||
null);
|
||||
|
||||
campaign = FullReductionDtoFactory.CreateNewCampaign(
|
||||
normalizedName,
|
||||
normalizedStartAt,
|
||||
normalizedEndAt,
|
||||
FullReductionMapping.SerializeRules(rules),
|
||||
rules.Description);
|
||||
|
||||
await promotionCampaignRepository.AddAsync(campaign, cancellationToken);
|
||||
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return FullReductionDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
campaign = await promotionCampaignRepository.FindByIdAsync(
|
||||
request.CampaignId.Value,
|
||||
tenantId,
|
||||
PromotionType.FullReduction,
|
||||
cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
|
||||
var existingRules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
|
||||
if (!existingRules.StoreIds.Contains(request.OperationStoreId))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
existingMetrics = existingRules.Metrics;
|
||||
|
||||
var normalizedRules = FullReductionMapping.NormalizeRulesForSave(
|
||||
request.ActivityType,
|
||||
request.ReduceTiers,
|
||||
request.GiftRule,
|
||||
request.SecondHalfRule,
|
||||
request.Channels,
|
||||
request.StoreScopeMode,
|
||||
request.StoreScopeStoreIds,
|
||||
request.ScopeStoreId,
|
||||
request.StackWithCoupon,
|
||||
request.Description,
|
||||
request.Metrics,
|
||||
existingMetrics);
|
||||
|
||||
campaign.Name = normalizedName;
|
||||
campaign.StartAt = normalizedStartAt;
|
||||
campaign.EndAt = normalizedEndAt;
|
||||
campaign.RulesJson = FullReductionMapping.SerializeRules(normalizedRules);
|
||||
campaign.AudienceDescription = normalizedRules.Description;
|
||||
|
||||
if (campaign.Status != PromotionStatus.Completed)
|
||||
{
|
||||
campaign.Status = PromotionStatus.Active;
|
||||
}
|
||||
|
||||
await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken);
|
||||
await promotionCampaignRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return FullReductionDtoFactory.ToDetailDto(campaign, normalizedRules, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询满减活动详情。
|
||||
/// </summary>
|
||||
public sealed class GetFullReductionCampaignDetailQuery : IRequest<FullReductionDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询满减活动列表。
|
||||
/// </summary>
|
||||
public sealed class GetFullReductionCampaignListQuery : IRequest<FullReductionListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动类型筛选。
|
||||
/// </summary>
|
||||
public string? ActivityType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 4;
|
||||
}
|
||||
Reference in New Issue
Block a user