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<IProductRepository, EfProductRepository>();
|
||||
services.AddScoped<ICouponRepository, EfCouponRepository>();
|
||||
services.AddScoped<IPromotionCampaignRepository, EfPromotionCampaignRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
@@ -86,3 +87,4 @@ public static class AppServiceCollectionExtensions
|
||||
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