feat(marketing): add full reduction campaign api module
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
2026-02-28 15:46:21 +08:00
parent dda3f96d28
commit 5a6da9be0c
27 changed files with 2916 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -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; } = [];
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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