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,685 @@
|
|||||||
|
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动列表请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionListRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 门店 ID(可空;空表示全部门店)。
|
||||||
|
/// </summary>
|
||||||
|
public string? StoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关键字。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动类型筛选(reduce/gift/second_half)。
|
||||||
|
/// </summary>
|
||||||
|
public string? ActivityType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态筛选(ongoing/upcoming/ended)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; } = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动详情请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionDetailRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 操作上下文门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存满减活动请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveFullReductionRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 操作上下文门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID(编辑时传)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动类型(reduce/gift/second_half)。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityType { get; set; } = "reduce";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减阶梯规则。
|
||||||
|
/// </summary>
|
||||||
|
public List<FullReductionTierRuleRequest> ReduceTiers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满赠规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionGiftRuleRequest? GiftRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 第二份半价规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionSecondHalfRuleRequest? SecondHalfRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string StartDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string EndDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用渠道(delivery/pickup/dine_in)。
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? Channels { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围模式(all/stores)。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreScopeMode { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围 ID 集合(stores 模式必传)。
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? StoreIds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选品基准门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string? ScopeStoreId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可叠加优惠券。
|
||||||
|
/// </summary>
|
||||||
|
public bool StackWithCoupon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动说明。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动指标(用于列表展示)。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionMetricsRequest? Metrics { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 修改活动状态请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChangeFullReductionStatusRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 操作上下文门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态(active/completed)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除满减活动请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteFullReductionRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 操作上下文门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动列表结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionListResultResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<FullReductionListItemResponse> Items { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总条数。
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 统计信息。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionStatsResponse Stats { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动列表项。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionListItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动类型(reduce/gift/second_half)。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityType { get; set; } = "reduce";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string StartDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string EndDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示状态(ongoing/upcoming/ended)。
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayStatus { get; set; } = "ongoing";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否弱化展示。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDimmed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减阶梯规则。
|
||||||
|
/// </summary>
|
||||||
|
public List<FullReductionTierRuleResponse> ReduceTiers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满赠规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionGiftRuleResponse? GiftRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 第二份半价规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Channels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围模式(all/stores)。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreScopeMode { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> StoreIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选品基准门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ScopeStoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可叠加优惠券。
|
||||||
|
/// </summary>
|
||||||
|
public bool StackWithCoupon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动说明。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动指标。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionMetricsResponse Metrics { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动详情。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionDetailResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 活动 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动类型(reduce/gift/second_half)。
|
||||||
|
/// </summary>
|
||||||
|
public string ActivityType { get; set; } = "reduce";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减阶梯规则。
|
||||||
|
/// </summary>
|
||||||
|
public List<FullReductionTierRuleResponse> ReduceTiers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满赠规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionGiftRuleResponse? GiftRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 第二份半价规则。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动开始日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string StartDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动结束日期(yyyy-MM-dd)。
|
||||||
|
/// </summary>
|
||||||
|
public string EndDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示状态(ongoing/upcoming/ended)。
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayStatus { get; set; } = "ongoing";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编辑状态(active/completed)。
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用渠道。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Channels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围模式(all/stores)。
|
||||||
|
/// </summary>
|
||||||
|
public string StoreScopeMode { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 门店范围 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> StoreIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选品基准门店 ID。
|
||||||
|
/// </summary>
|
||||||
|
public string ScopeStoreId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可叠加优惠券。
|
||||||
|
/// </summary>
|
||||||
|
public bool StackWithCoupon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动说明。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 活动指标。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionMetricsResponse Metrics { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||||
|
/// </summary>
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减活动统计。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionStatsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 活动总数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进行中数量。
|
||||||
|
/// </summary>
|
||||||
|
public int OngoingCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月带动销售额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客单价提升。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageTicketIncrease { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减阶梯规则请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionTierRuleRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 满足金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MeetAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 减免金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ReduceAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满减阶梯规则响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionTierRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 满足金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MeetAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 减免金额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal ReduceAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满赠规则请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionGiftRuleRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 购买数量门槛。
|
||||||
|
/// </summary>
|
||||||
|
public int BuyQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠送数量。
|
||||||
|
/// </summary>
|
||||||
|
public int GiftQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠品范围类型(same_lowest/specified)。
|
||||||
|
/// </summary>
|
||||||
|
public string GiftScopeType { get; set; } = "same_lowest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用商品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指定赠品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleRequest GiftScope { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 满赠规则响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionGiftRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 购买数量门槛。
|
||||||
|
/// </summary>
|
||||||
|
public int BuyQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠送数量。
|
||||||
|
/// </summary>
|
||||||
|
public int GiftQuantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠品范围类型(same_lowest/specified)。
|
||||||
|
/// </summary>
|
||||||
|
public string GiftScopeType { get; set; } = "same_lowest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用商品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指定赠品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleResponse GiftScope { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 第二份半价规则请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionSecondHalfRuleRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣类型(half/sixty/seventy/free)。
|
||||||
|
/// </summary>
|
||||||
|
public string DiscountType { get; set; } = "half";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用商品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 第二份半价规则响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionSecondHalfRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 折扣类型(half/sixty/seventy/free)。
|
||||||
|
/// </summary>
|
||||||
|
public string DiscountType { get; set; } = "half";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 适用商品范围。
|
||||||
|
/// </summary>
|
||||||
|
public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品范围请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionScopeRuleRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 范围类型(all/category/product)。
|
||||||
|
/// </summary>
|
||||||
|
public string ScopeType { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CategoryIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ProductIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品范围响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionScopeRuleResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 范围类型(all/category/product)。
|
||||||
|
/// </summary>
|
||||||
|
public string ScopeType { get; set; } = "all";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CategoryIds { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 商品 ID。
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ProductIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionMetricsRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 参与订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int ParticipatingOrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal DiscountTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价提升。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TicketIncreaseAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠出商品数量。
|
||||||
|
/// </summary>
|
||||||
|
public int GiftedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 带动销售额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal DrivenSalesAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 连带率提升百分比。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AttachRateIncreasePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月带动销售额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客单价提升。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageTicketIncrease { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指标响应。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FullReductionMetricsResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 参与订单数。
|
||||||
|
/// </summary>
|
||||||
|
public int ParticipatingOrderCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 优惠总额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal DiscountTotalAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 客单价提升。
|
||||||
|
/// </summary>
|
||||||
|
public decimal TicketIncreaseAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 赠出商品数量。
|
||||||
|
/// </summary>
|
||||||
|
public int GiftedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 带动销售额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal DrivenSalesAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 连带率提升百分比。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AttachRateIncreasePercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本月带动销售额。
|
||||||
|
/// </summary>
|
||||||
|
public decimal MonthlyDrivenSalesAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平均客单价提升。
|
||||||
|
/// </summary>
|
||||||
|
public decimal AverageTicketIncrease { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Application.App.Coupons.FullReduction.Commands;
|
||||||
|
using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
|
||||||
|
using TakeoutSaaS.Application.App.Coupons.FullReduction.Queries;
|
||||||
|
using TakeoutSaaS.Application.App.Stores.Services;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
using TakeoutSaaS.Shared.Web.Api;
|
||||||
|
using TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营销中心满减活动管理。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/tenant/v{version:apiVersion}/marketing/full-reduction")]
|
||||||
|
public sealed class MarketingFullReductionController(
|
||||||
|
IMediator mediator,
|
||||||
|
TakeoutAppDbContext dbContext,
|
||||||
|
StoreContextService storeContextService) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取满减活动列表。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("list")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FullReductionListResultResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FullReductionListResultResponse>> List(
|
||||||
|
[FromQuery] FullReductionListRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析可见门店范围(支持全部门店)
|
||||||
|
var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询应用层列表
|
||||||
|
var result = await mediator.Send(new GetFullReductionCampaignListQuery
|
||||||
|
{
|
||||||
|
VisibleStoreIds = visibleStoreIds,
|
||||||
|
Keyword = request.Keyword,
|
||||||
|
ActivityType = request.ActivityType,
|
||||||
|
Status = request.Status,
|
||||||
|
Page = request.Page,
|
||||||
|
PageSize = request.PageSize
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
// 3. 映射响应
|
||||||
|
return ApiResponse<FullReductionListResultResponse>.Ok(new FullReductionListResultResponse
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(MapListItem).ToList(),
|
||||||
|
Total = result.TotalCount,
|
||||||
|
Page = result.Page,
|
||||||
|
PageSize = result.PageSize,
|
||||||
|
Stats = new FullReductionStatsResponse
|
||||||
|
{
|
||||||
|
TotalCount = result.Stats.TotalCount,
|
||||||
|
OngoingCount = result.Stats.OngoingCount,
|
||||||
|
MonthlyDrivenSalesAmount = result.Stats.MonthlyDrivenSalesAmount,
|
||||||
|
AverageTicketIncrease = result.Stats.AverageTicketIncrease
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取满减活动详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("detail")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FullReductionDetailResponse>> Detail(
|
||||||
|
[FromQuery] FullReductionDetailRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 解析并校验操作门店
|
||||||
|
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 查询详情
|
||||||
|
var detail = await mediator.Send(new GetFullReductionCampaignDetailQuery
|
||||||
|
{
|
||||||
|
OperationStoreId = operationStoreId,
|
||||||
|
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
// 3. 处理不存在场景
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
return ApiResponse<FullReductionDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存满减活动(新增/编辑)。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("save")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FullReductionDetailResponse>> Save(
|
||||||
|
[FromBody] SaveFullReductionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验操作门店
|
||||||
|
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 展开活动门店范围
|
||||||
|
var resolvedStoreIds = await ResolveStoreScopeStoreIdsAsync(
|
||||||
|
request.StoreScopeMode,
|
||||||
|
request.StoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// 3. 解析选品基准门店
|
||||||
|
var scopeStoreId = string.IsNullOrWhiteSpace(request.ScopeStoreId)
|
||||||
|
? operationStoreId
|
||||||
|
: StoreApiHelpers.ParseRequiredSnowflake(request.ScopeStoreId, nameof(request.ScopeStoreId));
|
||||||
|
|
||||||
|
if (!resolvedStoreIds.Contains(scopeStoreId))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "scopeStoreId 必须在活动门店范围内");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 保存活动
|
||||||
|
var result = await mediator.Send(new SaveFullReductionCampaignCommand
|
||||||
|
{
|
||||||
|
OperationStoreId = operationStoreId,
|
||||||
|
CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id),
|
||||||
|
Name = request.Name,
|
||||||
|
ActivityType = request.ActivityType,
|
||||||
|
ReduceTiers = request.ReduceTiers.Select(item => new FullReductionTierRuleDto
|
||||||
|
{
|
||||||
|
MeetAmount = item.MeetAmount,
|
||||||
|
ReduceAmount = item.ReduceAmount
|
||||||
|
}).ToList(),
|
||||||
|
GiftRule = MapGiftRuleRequest(request.GiftRule),
|
||||||
|
SecondHalfRule = MapSecondHalfRuleRequest(request.SecondHalfRule),
|
||||||
|
StartAt = StoreApiHelpers.ParseDateOnly(request.StartDate, nameof(request.StartDate)),
|
||||||
|
EndAt = StoreApiHelpers.ParseDateOnly(request.EndDate, nameof(request.EndDate)),
|
||||||
|
Channels = request.Channels ?? [],
|
||||||
|
StoreScopeMode = request.StoreScopeMode,
|
||||||
|
StoreScopeStoreIds = resolvedStoreIds,
|
||||||
|
ScopeStoreId = scopeStoreId,
|
||||||
|
StackWithCoupon = request.StackWithCoupon,
|
||||||
|
Description = request.Description,
|
||||||
|
Metrics = MapMetricsRequest(request.Metrics)
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 修改满减活动状态。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("status")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<FullReductionDetailResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<FullReductionDetailResponse>> ChangeStatus(
|
||||||
|
[FromBody] ChangeFullReductionStatusRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验操作门店
|
||||||
|
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 调用应用层修改状态
|
||||||
|
var result = await mediator.Send(new ChangeFullReductionCampaignStatusCommand
|
||||||
|
{
|
||||||
|
OperationStoreId = operationStoreId,
|
||||||
|
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)),
|
||||||
|
Status = request.Status
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<FullReductionDetailResponse>.Ok(MapDetail(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除满减活动。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("delete")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<object>> Delete(
|
||||||
|
[FromBody] DeleteFullReductionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 校验操作门店
|
||||||
|
var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
|
||||||
|
await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken);
|
||||||
|
|
||||||
|
// 2. 调用应用层删除
|
||||||
|
await mediator.Send(new DeleteFullReductionCampaignCommand
|
||||||
|
{
|
||||||
|
OperationStoreId = operationStoreId,
|
||||||
|
CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId))
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return ApiResponse<object>.Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveVisibleStoreIdsAsync(
|
||||||
|
string? storeId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
|
||||||
|
// 1. 指定门店:返回单门店
|
||||||
|
if (!string.IsNullOrWhiteSpace(storeId))
|
||||||
|
{
|
||||||
|
var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId));
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken);
|
||||||
|
return [parsedStoreId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 全部门店:返回当前商户全部可见门店
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyCollection<long>> ResolveStoreScopeStoreIdsAsync(
|
||||||
|
string? storeScopeMode,
|
||||||
|
IEnumerable<string>? storeIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
var normalizedMode = NormalizeStoreScopeMode(storeScopeMode);
|
||||||
|
|
||||||
|
// 1. all 模式展开为商户全部门店
|
||||||
|
if (string.Equals(normalizedMode, "all", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var allStoreIds = await dbContext.Stores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (allStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStoreIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. stores 模式校验传入门店
|
||||||
|
var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds);
|
||||||
|
if (parsedStoreIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync(
|
||||||
|
dbContext,
|
||||||
|
tenantId,
|
||||||
|
merchantId,
|
||||||
|
parsedStoreIds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (accessibleStoreIds.Count != parsedStoreIds.Count)
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店");
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessibleStoreIds.OrderBy(x => x).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
|
||||||
|
await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeStoreScopeMode(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (normalized is not ("all" or "stores"))
|
||||||
|
{
|
||||||
|
throw new BusinessException(ErrorCodes.BadRequest, "storeScopeMode 参数不合法");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionGiftRuleDto? MapGiftRuleRequest(FullReductionGiftRuleRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FullReductionGiftRuleDto
|
||||||
|
{
|
||||||
|
BuyQuantity = request.BuyQuantity,
|
||||||
|
GiftQuantity = request.GiftQuantity,
|
||||||
|
GiftScopeType = request.GiftScopeType,
|
||||||
|
ApplicableScope = MapScopeRequest(request.ApplicableScope),
|
||||||
|
GiftScope = MapScopeRequest(request.GiftScope)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionSecondHalfRuleDto? MapSecondHalfRuleRequest(FullReductionSecondHalfRuleRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FullReductionSecondHalfRuleDto
|
||||||
|
{
|
||||||
|
DiscountType = request.DiscountType,
|
||||||
|
ApplicableScope = MapScopeRequest(request.ApplicableScope)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionScopeRuleDto MapScopeRequest(FullReductionScopeRuleRequest request)
|
||||||
|
{
|
||||||
|
return new FullReductionScopeRuleDto
|
||||||
|
{
|
||||||
|
ScopeType = request.ScopeType,
|
||||||
|
CategoryIds = StoreApiHelpers.ParseSnowflakeList(request.CategoryIds),
|
||||||
|
ProductIds = StoreApiHelpers.ParseSnowflakeList(request.ProductIds)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionMetricsDto? MapMetricsRequest(FullReductionMetricsRequest? request)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FullReductionMetricsDto
|
||||||
|
{
|
||||||
|
ParticipatingOrderCount = request.ParticipatingOrderCount,
|
||||||
|
DiscountTotalAmount = request.DiscountTotalAmount,
|
||||||
|
TicketIncreaseAmount = request.TicketIncreaseAmount,
|
||||||
|
GiftedCount = request.GiftedCount,
|
||||||
|
DrivenSalesAmount = request.DrivenSalesAmount,
|
||||||
|
AttachRateIncreasePercent = request.AttachRateIncreasePercent,
|
||||||
|
MonthlyDrivenSalesAmount = request.MonthlyDrivenSalesAmount,
|
||||||
|
AverageTicketIncrease = request.AverageTicketIncrease
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionListItemResponse MapListItem(FullReductionListItemDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionListItemResponse
|
||||||
|
{
|
||||||
|
Id = source.Id.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
ActivityType = source.Rules.ActivityType,
|
||||||
|
StartDate = ToDateOnly(source.StartAt),
|
||||||
|
EndDate = ToDateOnly(source.EndAt),
|
||||||
|
DisplayStatus = source.DisplayStatus,
|
||||||
|
IsDimmed = source.IsDimmed,
|
||||||
|
ReduceTiers = source.Rules.ReduceTiers.Select(MapTier).ToList(),
|
||||||
|
GiftRule = source.Rules.GiftRule is null ? null : MapGiftRule(source.Rules.GiftRule),
|
||||||
|
SecondHalfRule = source.Rules.SecondHalfRule is null
|
||||||
|
? null
|
||||||
|
: MapSecondHalfRule(source.Rules.SecondHalfRule),
|
||||||
|
Channels = source.Rules.Channels.ToList(),
|
||||||
|
StoreScopeMode = source.Rules.StoreScopeMode,
|
||||||
|
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||||
|
ScopeStoreId = source.Rules.ScopeStoreId.ToString(),
|
||||||
|
StackWithCoupon = source.Rules.StackWithCoupon,
|
||||||
|
Description = source.Rules.Description,
|
||||||
|
Metrics = MapMetrics(source.Rules.Metrics),
|
||||||
|
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionDetailResponse MapDetail(FullReductionDetailDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionDetailResponse
|
||||||
|
{
|
||||||
|
Id = source.Id.ToString(),
|
||||||
|
Name = source.Name,
|
||||||
|
ActivityType = source.Rules.ActivityType,
|
||||||
|
ReduceTiers = source.Rules.ReduceTiers.Select(MapTier).ToList(),
|
||||||
|
GiftRule = source.Rules.GiftRule is null ? null : MapGiftRule(source.Rules.GiftRule),
|
||||||
|
SecondHalfRule = source.Rules.SecondHalfRule is null
|
||||||
|
? null
|
||||||
|
: MapSecondHalfRule(source.Rules.SecondHalfRule),
|
||||||
|
StartDate = ToDateOnly(source.StartAt),
|
||||||
|
EndDate = ToDateOnly(source.EndAt),
|
||||||
|
DisplayStatus = source.DisplayStatus,
|
||||||
|
Status = source.Status,
|
||||||
|
Channels = source.Rules.Channels.ToList(),
|
||||||
|
StoreScopeMode = source.Rules.StoreScopeMode,
|
||||||
|
StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(),
|
||||||
|
ScopeStoreId = source.Rules.ScopeStoreId.ToString(),
|
||||||
|
StackWithCoupon = source.Rules.StackWithCoupon,
|
||||||
|
Description = source.Rules.Description,
|
||||||
|
Metrics = MapMetrics(source.Rules.Metrics),
|
||||||
|
UpdatedAt = ToDateTime(source.UpdatedAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionTierRuleResponse MapTier(FullReductionTierRuleDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionTierRuleResponse
|
||||||
|
{
|
||||||
|
MeetAmount = source.MeetAmount,
|
||||||
|
ReduceAmount = source.ReduceAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionGiftRuleResponse MapGiftRule(FullReductionGiftRuleDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionGiftRuleResponse
|
||||||
|
{
|
||||||
|
BuyQuantity = source.BuyQuantity,
|
||||||
|
GiftQuantity = source.GiftQuantity,
|
||||||
|
GiftScopeType = source.GiftScopeType,
|
||||||
|
ApplicableScope = MapScope(source.ApplicableScope),
|
||||||
|
GiftScope = MapScope(source.GiftScope)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionSecondHalfRuleResponse MapSecondHalfRule(FullReductionSecondHalfRuleDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionSecondHalfRuleResponse
|
||||||
|
{
|
||||||
|
DiscountType = source.DiscountType,
|
||||||
|
ApplicableScope = MapScope(source.ApplicableScope)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionScopeRuleResponse MapScope(FullReductionScopeRuleDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionScopeRuleResponse
|
||||||
|
{
|
||||||
|
ScopeType = source.ScopeType,
|
||||||
|
CategoryIds = source.CategoryIds.Select(item => item.ToString()).ToList(),
|
||||||
|
ProductIds = source.ProductIds.Select(item => item.ToString()).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FullReductionMetricsResponse MapMetrics(FullReductionMetricsDto source)
|
||||||
|
{
|
||||||
|
return new FullReductionMetricsResponse
|
||||||
|
{
|
||||||
|
ParticipatingOrderCount = source.ParticipatingOrderCount,
|
||||||
|
DiscountTotalAmount = source.DiscountTotalAmount,
|
||||||
|
TicketIncreaseAmount = source.TicketIncreaseAmount,
|
||||||
|
GiftedCount = source.GiftedCount,
|
||||||
|
DrivenSalesAmount = source.DrivenSalesAmount,
|
||||||
|
AttachRateIncreasePercent = source.AttachRateIncreasePercent,
|
||||||
|
MonthlyDrivenSalesAmount = source.MonthlyDrivenSalesAmount,
|
||||||
|
AverageTicketIncrease = source.AverageTicketIncrease
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateOnly(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToDateTime(DateTime value)
|
||||||
|
{
|
||||||
|
return value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营销活动仓储契约。
|
||||||
|
/// </summary>
|
||||||
|
public interface IPromotionCampaignRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户下指定活动类型的活动列表。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PromotionCampaign>> GetByPromotionTypeAsync(
|
||||||
|
long tenantId,
|
||||||
|
PromotionType promotionType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询活动详情。
|
||||||
|
/// </summary>
|
||||||
|
Task<PromotionCampaign?> FindByIdAsync(
|
||||||
|
long campaignId,
|
||||||
|
long tenantId,
|
||||||
|
PromotionType promotionType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增活动。
|
||||||
|
/// </summary>
|
||||||
|
Task AddAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新活动。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除活动。
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提交变更。
|
||||||
|
/// </summary>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
||||||
services.AddScoped<IProductRepository, EfProductRepository>();
|
services.AddScoped<IProductRepository, EfProductRepository>();
|
||||||
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
||||||
|
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
@@ -86,3 +87,4 @@ public static class AppServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Entities;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Enums;
|
||||||
|
using TakeoutSaaS.Domain.Coupons.Repositories;
|
||||||
|
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 营销活动仓储 EF Core 实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfPromotionCampaignRepository(TakeoutAppDbContext context) : IPromotionCampaignRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<PromotionCampaign>> GetByPromotionTypeAsync(
|
||||||
|
long tenantId,
|
||||||
|
PromotionType promotionType,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await context.PromotionCampaigns
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId && x.PromotionType == promotionType)
|
||||||
|
.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt)
|
||||||
|
.ThenByDescending(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<PromotionCampaign?> FindByIdAsync(
|
||||||
|
long campaignId,
|
||||||
|
long tenantId,
|
||||||
|
PromotionType promotionType,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.PromotionCampaigns
|
||||||
|
.Where(x => x.TenantId == tenantId && x.PromotionType == promotionType && x.Id == campaignId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.PromotionCampaigns.AddAsync(campaign, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.PromotionCampaigns.Update(campaign);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DeleteAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.PromotionCampaigns.Remove(campaign);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user