diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FullReductionContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FullReductionContracts.cs new file mode 100644 index 0000000..3fe3c1f --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FullReductionContracts.cs @@ -0,0 +1,685 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Marketing; + +/// +/// 满减活动列表请求。 +/// +public sealed class FullReductionListRequest +{ + /// + /// 门店 ID(可空;空表示全部门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 活动类型筛选(reduce/gift/second_half)。 + /// + public string? ActivityType { get; set; } + + /// + /// 状态筛选(ongoing/upcoming/ended)。 + /// + public string? Status { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } = 4; +} + +/// +/// 满减活动详情请求。 +/// +public sealed class FullReductionDetailRequest +{ + /// + /// 操作上下文门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 保存满减活动请求。 +/// +public sealed class SaveFullReductionRequest +{ + /// + /// 操作上下文门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID(编辑时传)。 + /// + public string? Id { get; set; } + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(reduce/gift/second_half)。 + /// + public string ActivityType { get; set; } = "reduce"; + + /// + /// 满减阶梯规则。 + /// + public List ReduceTiers { get; set; } = []; + + /// + /// 满赠规则。 + /// + public FullReductionGiftRuleRequest? GiftRule { get; set; } + + /// + /// 第二份半价规则。 + /// + public FullReductionSecondHalfRuleRequest? SecondHalfRule { get; set; } + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string StartDate { get; set; } = string.Empty; + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string EndDate { get; set; } = string.Empty; + + /// + /// 适用渠道(delivery/pickup/dine_in)。 + /// + public List? Channels { get; set; } + + /// + /// 门店范围模式(all/stores)。 + /// + public string StoreScopeMode { get; set; } = "all"; + + /// + /// 门店范围 ID 集合(stores 模式必传)。 + /// + public List? StoreIds { get; set; } + + /// + /// 选品基准门店 ID。 + /// + public string? ScopeStoreId { get; set; } + + /// + /// 是否可叠加优惠券。 + /// + public bool StackWithCoupon { get; set; } + + /// + /// 活动说明。 + /// + public string? Description { get; set; } + + /// + /// 活动指标(用于列表展示)。 + /// + public FullReductionMetricsRequest? Metrics { get; set; } +} + +/// +/// 修改活动状态请求。 +/// +public sealed class ChangeFullReductionStatusRequest +{ + /// + /// 操作上下文门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; + + /// + /// 状态(active/completed)。 + /// + public string Status { get; set; } = "completed"; +} + +/// +/// 删除满减活动请求。 +/// +public sealed class DeleteFullReductionRequest +{ + /// + /// 操作上下文门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 满减活动列表结果。 +/// +public sealed class FullReductionListResultResponse +{ + /// + /// 列表。 + /// + public List Items { get; set; } = []; + + /// + /// 总条数。 + /// + public int Total { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 统计信息。 + /// + public FullReductionStatsResponse Stats { get; set; } = new(); +} + +/// +/// 满减活动列表项。 +/// +public sealed class FullReductionListItemResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(reduce/gift/second_half)。 + /// + public string ActivityType { get; set; } = "reduce"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string StartDate { get; set; } = string.Empty; + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string EndDate { get; set; } = string.Empty; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; set; } + + /// + /// 满减阶梯规则。 + /// + public List ReduceTiers { get; set; } = []; + + /// + /// 满赠规则。 + /// + public FullReductionGiftRuleResponse? GiftRule { get; set; } + + /// + /// 第二份半价规则。 + /// + public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; } + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 门店范围模式(all/stores)。 + /// + public string StoreScopeMode { get; set; } = "all"; + + /// + /// 门店范围 ID。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 选品基准门店 ID。 + /// + public string ScopeStoreId { get; set; } = string.Empty; + + /// + /// 是否可叠加优惠券。 + /// + public bool StackWithCoupon { get; set; } + + /// + /// 活动说明。 + /// + public string? Description { get; set; } + + /// + /// 活动指标。 + /// + public FullReductionMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 满减活动详情。 +/// +public sealed class FullReductionDetailResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型(reduce/gift/second_half)。 + /// + public string ActivityType { get; set; } = "reduce"; + + /// + /// 满减阶梯规则。 + /// + public List ReduceTiers { get; set; } = []; + + /// + /// 满赠规则。 + /// + public FullReductionGiftRuleResponse? GiftRule { get; set; } + + /// + /// 第二份半价规则。 + /// + public FullReductionSecondHalfRuleResponse? SecondHalfRule { get; set; } + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string StartDate { get; set; } = string.Empty; + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string EndDate { get; set; } = string.Empty; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; set; } = "active"; + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 门店范围模式(all/stores)。 + /// + public string StoreScopeMode { get; set; } = "all"; + + /// + /// 门店范围 ID。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 选品基准门店 ID。 + /// + public string ScopeStoreId { get; set; } = string.Empty; + + /// + /// 是否可叠加优惠券。 + /// + public bool StackWithCoupon { get; set; } + + /// + /// 活动说明。 + /// + public string? Description { get; set; } + + /// + /// 活动指标。 + /// + public FullReductionMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 满减活动统计。 +/// +public sealed class FullReductionStatsResponse +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; set; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; set; } + + /// + /// 本月带动销售额。 + /// + public decimal MonthlyDrivenSalesAmount { get; set; } + + /// + /// 平均客单价提升。 + /// + public decimal AverageTicketIncrease { get; set; } +} + +/// +/// 满减阶梯规则请求。 +/// +public sealed class FullReductionTierRuleRequest +{ + /// + /// 满足金额。 + /// + public decimal MeetAmount { get; set; } + + /// + /// 减免金额。 + /// + public decimal ReduceAmount { get; set; } +} + +/// +/// 满减阶梯规则响应。 +/// +public sealed class FullReductionTierRuleResponse +{ + /// + /// 满足金额。 + /// + public decimal MeetAmount { get; set; } + + /// + /// 减免金额。 + /// + public decimal ReduceAmount { get; set; } +} + +/// +/// 满赠规则请求。 +/// +public sealed class FullReductionGiftRuleRequest +{ + /// + /// 购买数量门槛。 + /// + public int BuyQuantity { get; set; } + + /// + /// 赠送数量。 + /// + public int GiftQuantity { get; set; } + + /// + /// 赠品范围类型(same_lowest/specified)。 + /// + public string GiftScopeType { get; set; } = "same_lowest"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new(); + + /// + /// 指定赠品范围。 + /// + public FullReductionScopeRuleRequest GiftScope { get; set; } = new(); +} + +/// +/// 满赠规则响应。 +/// +public sealed class FullReductionGiftRuleResponse +{ + /// + /// 购买数量门槛。 + /// + public int BuyQuantity { get; set; } + + /// + /// 赠送数量。 + /// + public int GiftQuantity { get; set; } + + /// + /// 赠品范围类型(same_lowest/specified)。 + /// + public string GiftScopeType { get; set; } = "same_lowest"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new(); + + /// + /// 指定赠品范围。 + /// + public FullReductionScopeRuleResponse GiftScope { get; set; } = new(); +} + +/// +/// 第二份半价规则请求。 +/// +public sealed class FullReductionSecondHalfRuleRequest +{ + /// + /// 折扣类型(half/sixty/seventy/free)。 + /// + public string DiscountType { get; set; } = "half"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleRequest ApplicableScope { get; set; } = new(); +} + +/// +/// 第二份半价规则响应。 +/// +public sealed class FullReductionSecondHalfRuleResponse +{ + /// + /// 折扣类型(half/sixty/seventy/free)。 + /// + public string DiscountType { get; set; } = "half"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleResponse ApplicableScope { get; set; } = new(); +} + +/// +/// 商品范围请求。 +/// +public sealed class FullReductionScopeRuleRequest +{ + /// + /// 范围类型(all/category/product)。 + /// + public string ScopeType { get; set; } = "all"; + + /// + /// 分类 ID。 + /// + public List CategoryIds { get; set; } = []; + + /// + /// 商品 ID。 + /// + public List ProductIds { get; set; } = []; +} + +/// +/// 商品范围响应。 +/// +public sealed class FullReductionScopeRuleResponse +{ + /// + /// 范围类型(all/category/product)。 + /// + public string ScopeType { get; set; } = "all"; + + /// + /// 分类 ID。 + /// + public List CategoryIds { get; set; } = []; + + /// + /// 商品 ID。 + /// + public List ProductIds { get; set; } = []; +} + +/// +/// 指标请求。 +/// +public sealed class FullReductionMetricsRequest +{ + /// + /// 参与订单数。 + /// + public int ParticipatingOrderCount { get; set; } + + /// + /// 优惠总额。 + /// + public decimal DiscountTotalAmount { get; set; } + + /// + /// 客单价提升。 + /// + public decimal TicketIncreaseAmount { get; set; } + + /// + /// 赠出商品数量。 + /// + public int GiftedCount { get; set; } + + /// + /// 带动销售额。 + /// + public decimal DrivenSalesAmount { get; set; } + + /// + /// 连带率提升百分比。 + /// + public decimal AttachRateIncreasePercent { get; set; } + + /// + /// 本月带动销售额。 + /// + public decimal MonthlyDrivenSalesAmount { get; set; } + + /// + /// 平均客单价提升。 + /// + public decimal AverageTicketIncrease { get; set; } +} + +/// +/// 指标响应。 +/// +public sealed class FullReductionMetricsResponse +{ + /// + /// 参与订单数。 + /// + public int ParticipatingOrderCount { get; set; } + + /// + /// 优惠总额。 + /// + public decimal DiscountTotalAmount { get; set; } + + /// + /// 客单价提升。 + /// + public decimal TicketIncreaseAmount { get; set; } + + /// + /// 赠出商品数量。 + /// + public int GiftedCount { get; set; } + + /// + /// 带动销售额。 + /// + public decimal DrivenSalesAmount { get; set; } + + /// + /// 连带率提升百分比。 + /// + public decimal AttachRateIncreasePercent { get; set; } + + /// + /// 本月带动销售额。 + /// + public decimal MonthlyDrivenSalesAmount { get; set; } + + /// + /// 平均客单价提升。 + /// + public decimal AverageTicketIncrease { get; set; } +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFullReductionController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFullReductionController.cs new file mode 100644 index 0000000..8ec1310 --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFullReductionController.cs @@ -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; + +/// +/// 营销中心满减活动管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/tenant/v{version:apiVersion}/marketing/full-reduction")] +public sealed class MarketingFullReductionController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 获取满减活动列表。 + /// + [HttpGet("list")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.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 + } + }); + } + + /// + /// 获取满减活动详情。 + /// + [HttpGet("detail")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Error(ErrorCodes.NotFound, "活动不存在"); + } + + return ApiResponse.Ok(MapDetail(detail)); + } + + /// + /// 保存满减活动(新增/编辑)。 + /// + [HttpPost("save")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(MapDetail(result)); + } + + /// + /// 修改满减活动状态。 + /// + [HttpPost("status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(MapDetail(result)); + } + + /// + /// 删除满减活动。 + /// + [HttpPost("delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(null); + } + + private async Task> 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> ResolveStoreScopeStoreIdsAsync( + string? storeScopeMode, + IEnumerable? 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/ChangeFullReductionCampaignStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/ChangeFullReductionCampaignStatusCommand.cs new file mode 100644 index 0000000..3f88190 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/ChangeFullReductionCampaignStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands; + +/// +/// 修改满减活动状态命令。 +/// +public sealed class ChangeFullReductionCampaignStatusCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } + + /// + /// 状态(active/completed)。 + /// + public string Status { get; init; } = "completed"; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/DeleteFullReductionCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/DeleteFullReductionCampaignCommand.cs new file mode 100644 index 0000000..d827632 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/DeleteFullReductionCampaignCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands; + +/// +/// 删除满减活动命令。 +/// +public sealed class DeleteFullReductionCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/SaveFullReductionCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/SaveFullReductionCampaignCommand.cs new file mode 100644 index 0000000..d77f5e7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Commands/SaveFullReductionCampaignCommand.cs @@ -0,0 +1,90 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Commands; + +/// +/// 保存满减活动命令。 +/// +public sealed class SaveFullReductionCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID(编辑时传)。 + /// + public long? CampaignId { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 活动类型(reduce/gift/second_half)。 + /// + public string ActivityType { get; init; } = "reduce"; + + /// + /// 满减阶梯。 + /// + public IReadOnlyCollection ReduceTiers { get; init; } = []; + + /// + /// 满赠规则。 + /// + public FullReductionGiftRuleDto? GiftRule { get; init; } + + /// + /// 第二份半价规则。 + /// + public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; } + + /// + /// 活动开始时间。 + /// + public DateTime StartAt { get; init; } + + /// + /// 活动结束时间。 + /// + public DateTime EndAt { get; init; } + + /// + /// 适用渠道。 + /// + public IReadOnlyCollection Channels { get; init; } = []; + + /// + /// 门店范围模式(all/stores)。 + /// + public string StoreScopeMode { get; init; } = "all"; + + /// + /// 门店范围 ID。 + /// + public IReadOnlyCollection StoreScopeStoreIds { get; init; } = []; + + /// + /// 选品基准门店 ID。 + /// + public long ScopeStoreId { get; init; } + + /// + /// 是否可叠加优惠券。 + /// + public bool StackWithCoupon { get; init; } + + /// + /// 活动说明。 + /// + public string? Description { get; init; } + + /// + /// 活动指标。 + /// + public FullReductionMetricsDto? Metrics { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionDetailDto.cs new file mode 100644 index 0000000..55f3e7c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionDetailDto.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动详情 DTO。 +/// +public sealed class FullReductionDetailDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 活动开始时间。 + /// + public DateTime StartAt { get; init; } + + /// + /// 活动结束时间。 + /// + public DateTime EndAt { get; init; } + + /// + /// 活动状态(active/completed)。 + /// + public string Status { get; init; } = "active"; + + /// + /// 展示状态。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 规则配置。 + /// + public FullReductionRulesDto Rules { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionGiftRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionGiftRuleDto.cs new file mode 100644 index 0000000..ae2bc03 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionGiftRuleDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满赠规则 DTO。 +/// +public sealed class FullReductionGiftRuleDto +{ + /// + /// 购买数量门槛。 + /// + public int BuyQuantity { get; init; } + + /// + /// 赠送数量。 + /// + public int GiftQuantity { get; init; } + + /// + /// 赠品范围类型(same_lowest/specified)。 + /// + public string GiftScopeType { get; init; } = "same_lowest"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleDto ApplicableScope { get; init; } = new(); + + /// + /// 指定赠品范围。 + /// + public FullReductionScopeRuleDto GiftScope { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListItemDto.cs new file mode 100644 index 0000000..6c14f99 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListItemDto.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动列表项 DTO。 +/// +public sealed class FullReductionListItemDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 活动开始时间。 + /// + public DateTime StartAt { get; init; } + + /// + /// 活动结束时间。 + /// + public DateTime EndAt { get; init; } + + /// + /// 展示状态。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; init; } + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 规则配置。 + /// + public FullReductionRulesDto Rules { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListResultDto.cs new file mode 100644 index 0000000..15ef565 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionListResultDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动列表结果 DTO。 +/// +public sealed class FullReductionListResultDto +{ + /// + /// 列表。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总条数。 + /// + public int TotalCount { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } + + /// + /// 统计信息。 + /// + public FullReductionStatsDto Stats { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionMetricsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionMetricsDto.cs new file mode 100644 index 0000000..416da9a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionMetricsDto.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动指标 DTO。 +/// +public sealed class FullReductionMetricsDto +{ + /// + /// 参与订单数。 + /// + public int ParticipatingOrderCount { get; init; } + + /// + /// 优惠总额。 + /// + public decimal DiscountTotalAmount { get; init; } + + /// + /// 客单价提升。 + /// + public decimal TicketIncreaseAmount { get; init; } + + /// + /// 赠出商品数量。 + /// + public int GiftedCount { get; init; } + + /// + /// 带动销售额。 + /// + public decimal DrivenSalesAmount { get; init; } + + /// + /// 连带率提升百分比。 + /// + public decimal AttachRateIncreasePercent { get; init; } + + /// + /// 本月带动销售额。 + /// + public decimal MonthlyDrivenSalesAmount { get; init; } + + /// + /// 平均客单价提升。 + /// + public decimal AverageTicketIncrease { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionRulesDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionRulesDto.cs new file mode 100644 index 0000000..a133b3c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionRulesDto.cs @@ -0,0 +1,62 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动规则 DTO。 +/// +public sealed class FullReductionRulesDto +{ + /// + /// 活动类型(reduce/gift/second_half)。 + /// + public string ActivityType { get; init; } = "reduce"; + + /// + /// 满减阶梯。 + /// + public IReadOnlyList ReduceTiers { get; init; } = []; + + /// + /// 满赠规则。 + /// + public FullReductionGiftRuleDto? GiftRule { get; init; } + + /// + /// 第二份半价规则。 + /// + public FullReductionSecondHalfRuleDto? SecondHalfRule { get; init; } + + /// + /// 适用渠道。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 门店范围模式(all/stores)。 + /// + public string StoreScopeMode { get; init; } = "all"; + + /// + /// 门店范围 ID。 + /// + public IReadOnlyList StoreIds { get; init; } = []; + + /// + /// 选品基准门店 ID。 + /// + public long ScopeStoreId { get; init; } + + /// + /// 是否可叠加优惠券。 + /// + public bool StackWithCoupon { get; init; } + + /// + /// 活动说明。 + /// + public string? Description { get; init; } + + /// + /// 统计指标。 + /// + public FullReductionMetricsDto Metrics { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionScopeRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionScopeRuleDto.cs new file mode 100644 index 0000000..177a17b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionScopeRuleDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 商品范围 DTO。 +/// +public sealed class FullReductionScopeRuleDto +{ + /// + /// 范围类型(all/category/product)。 + /// + public string ScopeType { get; init; } = "all"; + + /// + /// 分类 ID。 + /// + public IReadOnlyList CategoryIds { get; init; } = []; + + /// + /// 商品 ID。 + /// + public IReadOnlyList ProductIds { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionSecondHalfRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionSecondHalfRuleDto.cs new file mode 100644 index 0000000..cab52b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionSecondHalfRuleDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 第二份半价规则 DTO。 +/// +public sealed class FullReductionSecondHalfRuleDto +{ + /// + /// 折扣类型(half/sixty/seventy/free)。 + /// + public string DiscountType { get; init; } = "half"; + + /// + /// 适用商品范围。 + /// + public FullReductionScopeRuleDto ApplicableScope { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionStatsDto.cs new file mode 100644 index 0000000..be67ce2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionStatsDto.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减活动统计 DTO。 +/// +public sealed class FullReductionStatsDto +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; init; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; init; } + + /// + /// 本月带动销售额。 + /// + public decimal MonthlyDrivenSalesAmount { get; init; } + + /// + /// 平均客单价提升。 + /// + public decimal AverageTicketIncrease { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionTierRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionTierRuleDto.cs new file mode 100644 index 0000000..72e14bb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Dto/FullReductionTierRuleDto.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +/// +/// 满减阶梯 DTO。 +/// +public sealed class FullReductionTierRuleDto +{ + /// + /// 满足金额。 + /// + public decimal MeetAmount { get; init; } + + /// + /// 减免金额。 + /// + public decimal ReduceAmount { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionDtoFactory.cs new file mode 100644 index 0000000..7ffa111 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionDtoFactory.cs @@ -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; + +/// +/// 满减活动 DTO 映射工厂。 +/// +internal static class FullReductionDtoFactory +{ + /// + /// 构建列表项 DTO。 + /// + 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 + }; + } + + /// + /// 构建详情 DTO。 + /// + 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 + }; + } + + /// + /// 构建统计 DTO。 + /// + public static FullReductionStatsDto ToStatsDto(IReadOnlyCollection 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 + }; + } + + /// + /// 构建默认新增活动实体。 + /// + 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionMapping.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionMapping.cs new file mode 100644 index 0000000..e04164f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/FullReductionMapping.cs @@ -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; + +/// +/// 满减活动映射辅助。 +/// +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 AllowedActivityTypes = + [ + ActivityTypeReduce, + ActivityTypeGift, + ActivityTypeSecondHalf + ]; + + private static readonly HashSet AllowedChannels = + [ + "delivery", + "pickup", + "dine_in" + ]; + + private static readonly HashSet AllowedDiscountTypes = + [ + DiscountTypeHalf, + DiscountTypeSixty, + DiscountTypeSeventy, + DiscountTypeFree + ]; + + private static readonly HashSet AllowedDisplayStatuses = + [ + DisplayStatusOngoing, + DisplayStatusUpcoming, + DisplayStatusEnded + ]; + + private static readonly HashSet AllowedStoreScopeModes = + [ + "all", + "stores" + ]; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + /// + /// 校验并标准化活动类型筛选。 + /// + 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; + } + + /// + /// 校验并标准化展示状态筛选。 + /// + 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; + } + + /// + /// 解析状态文本为领域状态。 + /// + 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 参数不合法") + }; + } + + /// + /// 输出状态文本。 + /// + public static string ToStatusText(PromotionStatus status) + { + return status switch + { + PromotionStatus.Active => "active", + PromotionStatus.Completed => "completed", + _ => "completed" + }; + } + + /// + /// 解析活动规则。 + /// + public static FullReductionRulesDto DeserializeRules(string? rulesJson, long campaignId) + { + if (string.IsNullOrWhiteSpace(rulesJson)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失"); + } + + var payload = JsonSerializer.Deserialize(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); + } + + /// + /// 序列化活动规则。 + /// + public static string SerializeRules(FullReductionRulesDto rules) + { + return JsonSerializer.Serialize(rules, JsonOptions); + } + + /// + /// 标准化并校验保存规则。 + /// + public static FullReductionRulesDto NormalizeRulesForSave( + string? activityType, + IReadOnlyCollection? reduceTiers, + FullReductionGiftRuleDto? giftRule, + FullReductionSecondHalfRuleDto? secondHalfRule, + IReadOnlyCollection? channels, + string? storeScopeMode, + IReadOnlyCollection? 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 参数不合法") + }; + } + + /// + /// 判断活动是否对可见门店集合可见。 + /// + public static bool HasVisibleStore(FullReductionRulesDto rules, IReadOnlyCollection visibleStoreIds) + { + if (visibleStoreIds.Count == 0) + { + return false; + } + + return rules.StoreIds.Any(visibleStoreIds.Contains); + } + + /// + /// 解析卡片展示状态。 + /// + 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; + } + + /// + /// 判断是否弱化展示。 + /// + public static bool IsDimmed(string displayStatus) + { + return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal); + } + + /// + /// 将结束时间规范为当天 23:59:59.9999999。 + /// + 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); + } + + /// + /// 将开始时间规范为当天 00:00:00。 + /// + 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 NormalizeStoreIds(IReadOnlyCollection? storeIds) + { + var normalized = (storeIds ?? Array.Empty()) + .Where(id => id > 0) + .Distinct() + .OrderBy(id => id) + .ToList(); + + if (normalized.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空"); + } + + return normalized; + } + + private static IReadOnlyList NormalizeChannels(IReadOnlyCollection? channels) + { + var normalized = (channels ?? Array.Empty()) + .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 NormalizeReduceTiers(IReadOnlyCollection? tiers) + { + var normalized = (tiers ?? Array.Empty()) + .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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/ChangeFullReductionCampaignStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/ChangeFullReductionCampaignStatusCommandHandler.cs new file mode 100644 index 0000000..73ff7dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/ChangeFullReductionCampaignStatusCommandHandler.cs @@ -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; + +/// +/// 修改满减活动状态命令处理器。 +/// +public sealed class ChangeFullReductionCampaignStatusCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/DeleteFullReductionCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/DeleteFullReductionCampaignCommandHandler.cs new file mode 100644 index 0000000..e03b816 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/DeleteFullReductionCampaignCommandHandler.cs @@ -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; + +/// +/// 删除满减活动命令处理器。 +/// +public sealed class DeleteFullReductionCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignDetailQueryHandler.cs new file mode 100644 index 0000000..6430829 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignDetailQueryHandler.cs @@ -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; + +/// +/// 满减活动详情查询处理器。 +/// +public sealed class GetFullReductionCampaignDetailQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignListQueryHandler.cs new file mode 100644 index 0000000..b0283d7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/GetFullReductionCampaignListQueryHandler.cs @@ -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; + +/// +/// 满减活动列表查询处理器。 +/// +public sealed class GetFullReductionCampaignListQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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(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 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/SaveFullReductionCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/SaveFullReductionCampaignCommandHandler.cs new file mode 100644 index 0000000..3e068f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Handlers/SaveFullReductionCampaignCommandHandler.cs @@ -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; + +/// +/// 保存满减活动命令处理器。 +/// +public sealed class SaveFullReductionCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} + + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignDetailQuery.cs new file mode 100644 index 0000000..f447a52 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries; + +/// +/// 查询满减活动详情。 +/// +public sealed class GetFullReductionCampaignDetailQuery : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignListQuery.cs new file mode 100644 index 0000000..3059a21 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FullReduction/Queries/GetFullReductionCampaignListQuery.cs @@ -0,0 +1,40 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FullReduction.Queries; + +/// +/// 查询满减活动列表。 +/// +public sealed class GetFullReductionCampaignListQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 活动类型筛选。 + /// + public string? ActivityType { get; init; } + + /// + /// 状态筛选。 + /// + public string? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 4; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPromotionCampaignRepository.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPromotionCampaignRepository.cs new file mode 100644 index 0000000..87ecce7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPromotionCampaignRepository.cs @@ -0,0 +1,47 @@ +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; + +namespace TakeoutSaaS.Domain.Coupons.Repositories; + +/// +/// 营销活动仓储契约。 +/// +public interface IPromotionCampaignRepository +{ + /// + /// 查询租户下指定活动类型的活动列表。 + /// + Task> GetByPromotionTypeAsync( + long tenantId, + PromotionType promotionType, + CancellationToken cancellationToken = default); + + /// + /// 查询活动详情。 + /// + Task FindByIdAsync( + long campaignId, + long tenantId, + PromotionType promotionType, + CancellationToken cancellationToken = default); + + /// + /// 新增活动。 + /// + Task AddAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default); + + /// + /// 更新活动。 + /// + Task UpdateAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default); + + /// + /// 删除活动。 + /// + Task DeleteAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default); + + /// + /// 提交变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index ce39e68..ac8502e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -86,3 +87,4 @@ public static class AppServiceCollectionExtensions return services; } } + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPromotionCampaignRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPromotionCampaignRepository.cs new file mode 100644 index 0000000..9ed32a7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPromotionCampaignRepository.cs @@ -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; + +/// +/// 营销活动仓储 EF Core 实现。 +/// +public sealed class EfPromotionCampaignRepository(TakeoutAppDbContext context) : IPromotionCampaignRepository +{ + /// + public async Task> 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); + } + + /// + public Task 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); + } + + /// + public Task AddAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default) + { + return context.PromotionCampaigns.AddAsync(campaign, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default) + { + context.PromotionCampaigns.Update(campaign); + return Task.CompletedTask; + } + + /// + public Task DeleteAsync(PromotionCampaign campaign, CancellationToken cancellationToken = default) + { + context.PromotionCampaigns.Remove(campaign); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +}