From 0f3542f33faad1c8398bdf05c398c9ddc9165112 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 2 Mar 2026 11:08:14 +0800 Subject: [PATCH] feat(marketing): implement flash sale module api and app layer --- .../Contracts/Marketing/FlashSaleContracts.cs | 643 ++++++++++++++++++ .../MarketingFlashSaleController.cs | 427 ++++++++++++ .../ChangeFlashSaleCampaignStatusCommand.cs | 26 + .../DeleteFlashSaleCampaignCommand.cs | 20 + .../Commands/SaveFlashSaleCampaignCommand.cs | 86 +++ .../FlashSale/Dto/FlashSaleDetailDto.cs | 38 ++ .../FlashSale/Dto/FlashSaleListItemDto.cs | 43 ++ .../FlashSale/Dto/FlashSaleListResultDto.cs | 33 + .../FlashSale/Dto/FlashSaleMetricsDto.cs | 28 + .../Dto/FlashSalePickerCategoryItemDto.cs | 23 + .../Dto/FlashSalePickerProductItemDto.cs | 48 ++ .../FlashSale/Dto/FlashSaleProductRuleDto.cs | 58 ++ .../FlashSale/Dto/FlashSaleRulesDto.cs | 68 ++ .../Dto/FlashSaleSaveProductInputDto.cs | 23 + .../FlashSale/Dto/FlashSaleStatsDto.cs | 28 + .../Coupons/FlashSale/FlashSaleDtoFactory.cs | 102 +++ .../App/Coupons/FlashSale/FlashSaleMapping.cs | 581 ++++++++++++++++ ...geFlashSaleCampaignStatusCommandHandler.cs | 44 ++ .../DeleteFlashSaleCampaignCommandHandler.cs | 41 ++ .../GetFlashSaleCampaignDetailQueryHandler.cs | 44 ++ .../GetFlashSaleCampaignListQueryHandler.cs | 112 +++ ...etFlashSalePickerCategoriesQueryHandler.cs | 50 ++ .../GetFlashSalePickerProductsQueryHandler.cs | 60 ++ .../SaveFlashSaleCampaignCommandHandler.cs | 177 +++++ .../GetFlashSaleCampaignDetailQuery.cs | 21 + .../Queries/GetFlashSaleCampaignListQuery.cs | 36 + .../GetFlashSalePickerCategoriesQuery.cs | 16 + .../GetFlashSalePickerProductsQuery.cs | 31 + .../Repositories/IProductRepository.cs | 9 + .../App/Repositories/EfProductRepository.cs | 23 + 30 files changed, 2939 insertions(+) create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FlashSaleContracts.cs create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFlashSaleController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/ChangeFlashSaleCampaignStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/DeleteFlashSaleCampaignCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/SaveFlashSaleCampaignCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListResultDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleMetricsDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerCategoryItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerProductItemDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleProductRuleDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleRulesDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleSaveProductInputDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleStatsDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleDtoFactory.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/ChangeFlashSaleCampaignStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/DeleteFlashSaleCampaignCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignListQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerCategoriesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerProductsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/SaveFlashSaleCampaignCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignListQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerCategoriesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerProductsQuery.cs diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FlashSaleContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FlashSaleContracts.cs new file mode 100644 index 0000000..b42bf6d --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/FlashSaleContracts.cs @@ -0,0 +1,643 @@ +namespace TakeoutSaaS.TenantApi.Contracts.Marketing; + +/// +/// 限时折扣列表查询请求。 +/// +public sealed class FlashSaleListRequest +{ + /// + /// 门店 ID(可空,空表示全部门店)。 + /// + public string? StoreId { get; set; } + + /// + /// 活动名称关键字。 + /// + public string? Keyword { 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 FlashSaleDetailRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 保存限时折扣请求。 +/// +public sealed class SaveFlashSaleRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID(编辑时传)。 + /// + public string? Id { get; set; } + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动周期(once/recurring)。 + /// + public string CycleType { get; set; } = "once"; + + /// + /// 周期日期模式(fixed/long_term)。 + /// + public string RecurringDateMode { get; set; } = "fixed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 循环星期(1-7,周一到周日)。 + /// + public List? WeekDays { get; set; } + + /// + /// 适用渠道(delivery/pickup/dine_in)。 + /// + public List? Channels { get; set; } + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 活动门店 ID。 + /// + public List? StoreIds { get; set; } + + /// + /// 折扣商品列表。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public FlashSaleMetricsRequest? Metrics { get; set; } +} + +/// +/// 修改限时折扣状态请求。 +/// +public sealed class ChangeFlashSaleStatusRequest +{ + /// + /// 操作门店 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 DeleteFlashSaleRequest +{ + /// + /// 操作门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 活动 ID。 + /// + public string ActivityId { get; set; } = string.Empty; +} + +/// +/// 限时折扣列表响应。 +/// +public sealed class FlashSaleListResultResponse +{ + /// + /// 列表项。 + /// + public List Items { get; set; } = []; + + /// + /// 总条数。 + /// + public int Total { get; set; } + + /// + /// 页码。 + /// + public int Page { get; set; } + + /// + /// 每页条数。 + /// + public int PageSize { get; set; } + + /// + /// 统计数据。 + /// + public FlashSaleStatsResponse Stats { get; set; } = new(); +} + +/// +/// 限时折扣列表项响应。 +/// +public sealed class FlashSaleListItemResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动周期(once/recurring)。 + /// + public string CycleType { get; set; } = "once"; + + /// + /// 周期日期模式(fixed/long_term)。 + /// + public string RecurringDateMode { get; set; } = "fixed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 循环星期(1-7)。 + /// + public List WeekDays { get; set; } = []; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; set; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; set; } + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 活动门店。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 折扣商品。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public FlashSaleMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 限时折扣详情响应。 +/// +public sealed class FlashSaleDetailResponse +{ + /// + /// 活动 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动周期(once/recurring)。 + /// + public string CycleType { get; set; } = "once"; + + /// + /// 周期日期模式(fixed/long_term)。 + /// + public string RecurringDateMode { get; set; } = "fixed"; + + /// + /// 活动开始日期(yyyy-MM-dd)。 + /// + public string? StartDate { get; set; } + + /// + /// 活动结束日期(yyyy-MM-dd)。 + /// + public string? EndDate { get; set; } + + /// + /// 每日开始时间(HH:mm)。 + /// + public string? TimeStart { get; set; } + + /// + /// 每日结束时间(HH:mm)。 + /// + public string? TimeEnd { get; set; } + + /// + /// 循环星期(1-7)。 + /// + public List WeekDays { get; set; } = []; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; set; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; set; } = "ongoing"; + + /// + /// 适用渠道。 + /// + public List Channels { get; set; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 活动门店。 + /// + public List StoreIds { get; set; } = []; + + /// + /// 折扣商品。 + /// + public List Products { get; set; } = []; + + /// + /// 活动指标。 + /// + public FlashSaleMetricsResponse Metrics { get; set; } = new(); + + /// + /// 更新时间(yyyy-MM-dd HH:mm:ss)。 + /// + public string UpdatedAt { get; set; } = string.Empty; +} + +/// +/// 限时折扣统计响应。 +/// +public sealed class FlashSaleStatsResponse +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; set; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; set; } + + /// + /// 参与商品数。 + /// + public int ParticipatingProductCount { get; set; } + + /// + /// 本月折扣销量。 + /// + public int MonthlyDiscountSalesCount { get; set; } +} + +/// +/// 限时折扣商品请求。 +/// +public sealed class FlashSaleSaveProductRequest +{ + /// + /// 商品 ID。 + /// + public string ProductId { get; set; } = string.Empty; + + /// + /// 折扣价。 + /// + public decimal DiscountPrice { get; set; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } +} + +/// +/// 限时折扣商品响应。 +/// +public sealed class FlashSaleProductResponse +{ + /// + /// 商品 ID。 + /// + public string ProductId { get; set; } = string.Empty; + + /// + /// 分类 ID。 + /// + public string CategoryId { get; set; } = string.Empty; + + /// + /// 分类名称。 + /// + public string CategoryName { get; set; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// SPU 编码。 + /// + public string SpuCode { get; set; } = string.Empty; + + /// + /// 商品状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; set; } = "off_shelf"; + + /// + /// 原价。 + /// + public decimal OriginalPrice { get; set; } + + /// + /// 折扣价。 + /// + public decimal DiscountPrice { get; set; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; set; } + + /// + /// 已售数量。 + /// + public int SoldCount { get; set; } +} + +/// +/// 限时折扣指标请求。 +/// +public sealed class FlashSaleMetricsRequest +{ + /// + /// 活动销量(单)。 + /// + public int ActivitySalesCount { get; set; } + + /// + /// 折扣总额。 + /// + public decimal DiscountTotalAmount { get; set; } + + /// + /// 已循环周数。 + /// + public int LoopedWeeks { get; set; } + + /// + /// 本月折扣销量(单)。 + /// + public int MonthlyDiscountSalesCount { get; set; } +} + +/// +/// 限时折扣指标响应。 +/// +public sealed class FlashSaleMetricsResponse +{ + /// + /// 活动销量(单)。 + /// + public int ActivitySalesCount { get; set; } + + /// + /// 折扣总额。 + /// + public decimal DiscountTotalAmount { get; set; } + + /// + /// 已循环周数。 + /// + public int LoopedWeeks { get; set; } + + /// + /// 本月折扣销量(单)。 + /// + public int MonthlyDiscountSalesCount { get; set; } +} + +/// +/// 限时折扣商品分类选择器请求。 +/// +public sealed class FlashSalePickerCategoriesRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; +} + +/// +/// 限时折扣商品分类选择器响应项。 +/// +public sealed class FlashSalePickerCategoryItemResponse +{ + /// + /// 分类 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 分类名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 商品数量。 + /// + public int ProductCount { get; set; } +} + +/// +/// 限时折扣商品选择器请求。 +/// +public sealed class FlashSalePickerProductsRequest +{ + /// + /// 门店 ID。 + /// + public string StoreId { get; set; } = string.Empty; + + /// + /// 分类 ID(可空)。 + /// + public string? CategoryId { get; set; } + + /// + /// 关键字。 + /// + public string? Keyword { get; set; } + + /// + /// 数量上限。 + /// + public int? Limit { get; set; } +} + +/// +/// 限时折扣商品选择器响应项。 +/// +public sealed class FlashSalePickerProductItemResponse +{ + /// + /// 商品 ID。 + /// + public string Id { get; set; } = string.Empty; + + /// + /// 分类 ID。 + /// + public string CategoryId { get; set; } = string.Empty; + + /// + /// 分类名称。 + /// + public string CategoryName { get; set; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 售价。 + /// + public decimal Price { get; set; } + + /// + /// 库存。 + /// + public int Stock { get; set; } + + /// + /// SPU 编码。 + /// + public string SpuCode { get; set; } = string.Empty; + + /// + /// 状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; set; } = "off_shelf"; +} diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFlashSaleController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFlashSaleController.cs new file mode 100644 index 0000000..d7ca13e --- /dev/null +++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingFlashSaleController.cs @@ -0,0 +1,427 @@ +using System.Globalization; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Application.App.Coupons.FlashSale.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/flash-sale")] +public sealed class MarketingFlashSaleController( + IMediator mediator, + TakeoutAppDbContext dbContext, + StoreContextService storeContextService) : BaseApiController +{ + /// + /// 获取限时折扣活动列表。 + /// + [HttpGet("list")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] FlashSaleListRequest request, + CancellationToken cancellationToken) + { + var visibleStoreIds = await ResolveVisibleStoreIdsAsync(request.StoreId, cancellationToken); + + var result = await mediator.Send(new GetFlashSaleCampaignListQuery + { + VisibleStoreIds = visibleStoreIds, + Keyword = request.Keyword, + Status = request.Status, + Page = request.Page, + PageSize = request.PageSize + }, cancellationToken); + + return ApiResponse.Ok(new FlashSaleListResultResponse + { + Items = result.Items.Select(MapListItem).ToList(), + Total = result.TotalCount, + Page = result.Page, + PageSize = result.PageSize, + Stats = new FlashSaleStatsResponse + { + TotalCount = result.Stats.TotalCount, + OngoingCount = result.Stats.OngoingCount, + ParticipatingProductCount = result.Stats.ParticipatingProductCount, + MonthlyDiscountSalesCount = result.Stats.MonthlyDiscountSalesCount + } + }); + } + + /// + /// 获取限时折扣活动详情。 + /// + [HttpGet("detail")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail( + [FromQuery] FlashSaleDetailRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetFlashSaleCampaignDetailQuery + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)) + }, cancellationToken); + + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "活动不存在"); + } + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 保存限时折扣活动(新增/编辑)。 + /// + [HttpPost("save")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Save( + [FromBody] SaveFlashSaleRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var resolvedStoreIds = await ResolveStoreIdsForSaveAsync( + request.StoreIds, + operationStoreId, + cancellationToken); + + var result = await mediator.Send(new SaveFlashSaleCampaignCommand + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseSnowflakeOrNull(request.Id), + Name = request.Name, + CycleType = request.CycleType, + RecurringDateMode = request.RecurringDateMode, + StartDate = ParseDateOnlyOrNull(request.StartDate, nameof(request.StartDate)), + EndDate = ParseDateOnlyOrNull(request.EndDate, nameof(request.EndDate)), + TimeStart = ParseTimeOrNull(request.TimeStart, nameof(request.TimeStart)), + TimeEnd = ParseTimeOrNull(request.TimeEnd, nameof(request.TimeEnd)), + WeekDays = request.WeekDays ?? [], + Channels = request.Channels ?? [], + PerUserLimit = request.PerUserLimit, + StoreIds = resolvedStoreIds, + Products = request.Products.Select(item => new FlashSaleSaveProductInputDto + { + ProductId = StoreApiHelpers.ParseRequiredSnowflake(item.ProductId, nameof(item.ProductId)), + DiscountPrice = item.DiscountPrice, + PerUserLimit = item.PerUserLimit + }).ToList(), + Metrics = request.Metrics is null + ? null + : new FlashSaleMetricsDto + { + ActivitySalesCount = request.Metrics.ActivitySalesCount, + DiscountTotalAmount = request.Metrics.DiscountTotalAmount, + LoopedWeeks = request.Metrics.LoopedWeeks, + MonthlyDiscountSalesCount = request.Metrics.MonthlyDiscountSalesCount + } + }, cancellationToken); + + return ApiResponse.Ok(MapDetail(result)); + } + + /// + /// 修改限时折扣活动状态。 + /// + [HttpPost("status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangeStatus( + [FromBody] ChangeFlashSaleStatusRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new ChangeFlashSaleCampaignStatusCommand + { + 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] DeleteFlashSaleRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + await mediator.Send(new DeleteFlashSaleCampaignCommand + { + OperationStoreId = operationStoreId, + CampaignId = StoreApiHelpers.ParseRequiredSnowflake(request.ActivityId, nameof(request.ActivityId)) + }, cancellationToken); + + return ApiResponse.Ok(null); + } + + /// + /// 获取限时折扣选品分类。 + /// + [HttpGet("picker/categories")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> PickerCategories( + [FromQuery] FlashSalePickerCategoriesRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetFlashSalePickerCategoriesQuery + { + OperationStoreId = operationStoreId + }, cancellationToken); + + return ApiResponse>.Ok(result + .Select(item => new FlashSalePickerCategoryItemResponse + { + Id = item.Id.ToString(), + Name = item.Name, + ProductCount = item.ProductCount + }) + .ToList()); + } + + /// + /// 获取限时折扣选品商品。 + /// + [HttpGet("picker/products")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> PickerProducts( + [FromQuery] FlashSalePickerProductsRequest request, + CancellationToken cancellationToken) + { + var operationStoreId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId)); + await EnsureStoreAccessibleAsync(operationStoreId, cancellationToken); + + var result = await mediator.Send(new GetFlashSalePickerProductsQuery + { + OperationStoreId = operationStoreId, + CategoryId = StoreApiHelpers.ParseSnowflakeOrNull(request.CategoryId), + Keyword = request.Keyword, + Limit = request.Limit + }, cancellationToken); + + return ApiResponse>.Ok(result + .Select(item => new FlashSalePickerProductItemResponse + { + Id = item.Id.ToString(), + CategoryId = item.CategoryId.ToString(), + CategoryName = item.CategoryName, + Name = item.Name, + Price = item.Price, + Stock = item.Stock, + SpuCode = item.SpuCode, + Status = item.Status + }) + .ToList()); + } + + private async Task> ResolveVisibleStoreIdsAsync( + string? storeId, + CancellationToken cancellationToken) + { + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + + if (!string.IsNullOrWhiteSpace(storeId)) + { + var parsedStoreId = StoreApiHelpers.ParseRequiredSnowflake(storeId, nameof(storeId)); + await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, parsedStoreId, cancellationToken); + return [parsedStoreId]; + } + + var allStoreIds = await dbContext.Stores + .AsNoTracking() + .Where(item => item.TenantId == tenantId && item.MerchantId == merchantId) + .Select(item => item.Id) + .OrderBy(item => item) + .ToListAsync(cancellationToken); + + if (allStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前商户下不存在可用门店"); + } + + return allStoreIds; + } + + private async Task> ResolveStoreIdsForSaveAsync( + IEnumerable? storeIds, + long operationStoreId, + CancellationToken cancellationToken) + { + var parsedStoreIds = StoreApiHelpers.ParseSnowflakeList(storeIds); + if (parsedStoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空"); + } + + var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService); + var accessibleStoreIds = await StoreApiHelpers.FilterAccessibleStoreIdsAsync( + dbContext, + tenantId, + merchantId, + parsedStoreIds, + cancellationToken); + + if (accessibleStoreIds.Count != parsedStoreIds.Count) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 存在无权限门店"); + } + + if (!accessibleStoreIds.Contains(operationStoreId)) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店"); + } + + return accessibleStoreIds.OrderBy(item => item).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 DateTime? ParseDateOnlyOrNull(string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (!DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"{fieldName} 日期格式必须为 yyyy-MM-dd"); + } + + return DateTime.SpecifyKind(parsed.Date, DateTimeKind.Utc); + } + + private static TimeSpan? ParseTimeOrNull(string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return StoreApiHelpers.ParseRequiredTime(value, fieldName); + } + + private static FlashSaleListItemResponse MapListItem(FlashSaleListItemDto source) + { + return new FlashSaleListItemResponse + { + Id = source.Id.ToString(), + Name = source.Name, + CycleType = source.Rules.CycleType, + RecurringDateMode = source.Rules.RecurringDateMode, + StartDate = ToDateOnly(source.Rules.StartDate), + EndDate = ToDateOnly(source.Rules.EndDate), + TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart), + TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd), + WeekDays = source.Rules.WeekDays.ToList(), + Status = source.Status, + DisplayStatus = source.DisplayStatus, + IsDimmed = source.IsDimmed, + Channels = source.Rules.Channels.ToList(), + PerUserLimit = source.Rules.PerUserLimit, + StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(), + Products = source.Rules.Products.Select(MapProduct).ToList(), + Metrics = MapMetrics(source.Rules.Metrics), + UpdatedAt = ToDateTime(source.UpdatedAt) + }; + } + + private static FlashSaleDetailResponse MapDetail(FlashSaleDetailDto source) + { + return new FlashSaleDetailResponse + { + Id = source.Id.ToString(), + Name = source.Name, + CycleType = source.Rules.CycleType, + RecurringDateMode = source.Rules.RecurringDateMode, + StartDate = ToDateOnly(source.Rules.StartDate), + EndDate = ToDateOnly(source.Rules.EndDate), + TimeStart = StoreApiHelpers.ToHHmm(source.Rules.TimeStart), + TimeEnd = StoreApiHelpers.ToHHmm(source.Rules.TimeEnd), + WeekDays = source.Rules.WeekDays.ToList(), + Status = source.Status, + DisplayStatus = source.DisplayStatus, + Channels = source.Rules.Channels.ToList(), + PerUserLimit = source.Rules.PerUserLimit, + StoreIds = source.Rules.StoreIds.Select(item => item.ToString()).ToList(), + Products = source.Rules.Products.Select(MapProduct).ToList(), + Metrics = MapMetrics(source.Rules.Metrics), + UpdatedAt = ToDateTime(source.UpdatedAt) + }; + } + + private static FlashSaleProductResponse MapProduct(FlashSaleProductRuleDto source) + { + return new FlashSaleProductResponse + { + ProductId = source.ProductId.ToString(), + CategoryId = source.CategoryId.ToString(), + CategoryName = source.CategoryName, + Name = source.Name, + SpuCode = source.SpuCode, + Status = source.Status, + OriginalPrice = source.OriginalPrice, + DiscountPrice = source.DiscountPrice, + PerUserLimit = source.PerUserLimit, + SoldCount = source.SoldCount + }; + } + + private static FlashSaleMetricsResponse MapMetrics(FlashSaleMetricsDto source) + { + return new FlashSaleMetricsResponse + { + ActivitySalesCount = source.ActivitySalesCount, + DiscountTotalAmount = source.DiscountTotalAmount, + LoopedWeeks = source.LoopedWeeks, + MonthlyDiscountSalesCount = source.MonthlyDiscountSalesCount + }; + } + + 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/FlashSale/Commands/ChangeFlashSaleCampaignStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/ChangeFlashSaleCampaignStatusCommand.cs new file mode 100644 index 0000000..15c8c2c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/ChangeFlashSaleCampaignStatusCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; + +/// +/// 修改限时折扣活动状态命令。 +/// +public sealed class ChangeFlashSaleCampaignStatusCommand : 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/FlashSale/Commands/DeleteFlashSaleCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/DeleteFlashSaleCampaignCommand.cs new file mode 100644 index 0000000..d8a1892 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/DeleteFlashSaleCampaignCommand.cs @@ -0,0 +1,20 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; + +/// +/// 删除限时折扣活动命令。 +/// +public sealed class DeleteFlashSaleCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/SaveFlashSaleCampaignCommand.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/SaveFlashSaleCampaignCommand.cs new file mode 100644 index 0000000..68a3d4e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Commands/SaveFlashSaleCampaignCommand.cs @@ -0,0 +1,86 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; + +/// +/// 保存限时折扣活动命令。 +/// +public sealed class SaveFlashSaleCampaignCommand : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID(编辑时传)。 + /// + public long? CampaignId { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 活动周期(once/recurring)。 + /// + public string CycleType { get; init; } = "once"; + + /// + /// 周期日期模式(fixed/long_term)。 + /// + public string RecurringDateMode { get; init; } = "fixed"; + + /// + /// 活动开始日期(UTC 日期)。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 活动结束日期(UTC 日期)。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 每日开始时间。 + /// + public TimeSpan? TimeStart { get; init; } + + /// + /// 每日结束时间。 + /// + public TimeSpan? TimeEnd { get; init; } + + /// + /// 循环星期(1-7)。 + /// + public IReadOnlyCollection WeekDays { get; init; } = []; + + /// + /// 适用渠道。 + /// + public IReadOnlyCollection Channels { get; init; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 活动门店。 + /// + public IReadOnlyCollection StoreIds { get; init; } = []; + + /// + /// 商品配置输入。 + /// + public IReadOnlyCollection Products { get; init; } = []; + + /// + /// 活动指标。 + /// + public FlashSaleMetricsDto? Metrics { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleDetailDto.cs new file mode 100644 index 0000000..290233f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleDetailDto.cs @@ -0,0 +1,38 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动详情 DTO。 +/// +public sealed class FlashSaleDetailDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; init; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 活动规则。 + /// + public FlashSaleRulesDto Rules { get; init; } = new(); +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListItemDto.cs new file mode 100644 index 0000000..13305fd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListItemDto.cs @@ -0,0 +1,43 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动列表项 DTO。 +/// +public sealed class FlashSaleListItemDto +{ + /// + /// 活动 ID。 + /// + public long Id { get; init; } + + /// + /// 活动名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 编辑状态(active/completed)。 + /// + public string Status { get; init; } = "active"; + + /// + /// 展示状态(ongoing/upcoming/ended)。 + /// + public string DisplayStatus { get; init; } = "ongoing"; + + /// + /// 是否弱化展示。 + /// + public bool IsDimmed { get; init; } + + /// + /// 更新时间。 + /// + public DateTime UpdatedAt { get; init; } + + /// + /// 活动规则。 + /// + public FlashSaleRulesDto Rules { get; init; } = new(); +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListResultDto.cs new file mode 100644 index 0000000..a55dc79 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleListResultDto.cs @@ -0,0 +1,33 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动列表结果 DTO。 +/// +public sealed class FlashSaleListResultDto +{ + /// + /// 列表数据。 + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// 总条数。 + /// + public int TotalCount { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } + + /// + /// 统计信息。 + /// + public FlashSaleStatsDto Stats { get; init; } = new(); +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleMetricsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleMetricsDto.cs new file mode 100644 index 0000000..dcaeffe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleMetricsDto.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动指标 DTO。 +/// +public sealed class FlashSaleMetricsDto +{ + /// + /// 活动销量(单)。 + /// + public int ActivitySalesCount { get; init; } + + /// + /// 折扣总额。 + /// + public decimal DiscountTotalAmount { get; init; } + + /// + /// 已循环周数。 + /// + public int LoopedWeeks { get; init; } + + /// + /// 本月折扣销量(单)。 + /// + public int MonthlyDiscountSalesCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerCategoryItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerCategoryItemDto.cs new file mode 100644 index 0000000..ed038bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerCategoryItemDto.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣选品分类项 DTO。 +/// +public sealed class FlashSalePickerCategoryItemDto +{ + /// + /// 分类 ID。 + /// + public long Id { get; init; } + + /// + /// 分类名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 商品数量。 + /// + public int ProductCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerProductItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerProductItemDto.cs new file mode 100644 index 0000000..1e4906f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSalePickerProductItemDto.cs @@ -0,0 +1,48 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣选品商品项 DTO。 +/// +public sealed class FlashSalePickerProductItemDto +{ + /// + /// 商品 ID。 + /// + public long Id { get; init; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; init; } + + /// + /// 分类名称。 + /// + public string CategoryName { get; init; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 售价。 + /// + public decimal Price { get; init; } + + /// + /// 库存。 + /// + public int Stock { get; init; } + + /// + /// SPU 编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; init; } = "off_shelf"; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleProductRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleProductRuleDto.cs new file mode 100644 index 0000000..38ffcd0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleProductRuleDto.cs @@ -0,0 +1,58 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣商品规则 DTO。 +/// +public sealed class FlashSaleProductRuleDto +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; init; } + + /// + /// 分类名称。 + /// + public string CategoryName { get; init; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// SPU 编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 商品状态(on_sale/off_shelf/sold_out)。 + /// + public string Status { get; init; } = "off_shelf"; + + /// + /// 原价。 + /// + public decimal OriginalPrice { get; init; } + + /// + /// 折扣价。 + /// + public decimal DiscountPrice { get; init; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 已售数量。 + /// + public int SoldCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleRulesDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleRulesDto.cs new file mode 100644 index 0000000..63c5416 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleRulesDto.cs @@ -0,0 +1,68 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动规则 DTO。 +/// +public sealed class FlashSaleRulesDto +{ + /// + /// 活动周期(once/recurring)。 + /// + public string CycleType { get; init; } = "once"; + + /// + /// 周期日期模式(fixed/long_term)。 + /// + public string RecurringDateMode { get; init; } = "fixed"; + + /// + /// 活动开始日期(UTC 日期)。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 活动结束日期(UTC 日期)。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 每日开始时间。 + /// + public TimeSpan? TimeStart { get; init; } + + /// + /// 每日结束时间。 + /// + public TimeSpan? TimeEnd { get; init; } + + /// + /// 循环星期(1-7,周一到周日)。 + /// + public IReadOnlyList WeekDays { get; init; } = []; + + /// + /// 适用渠道(delivery/pickup/dine_in)。 + /// + public IReadOnlyList Channels { get; init; } = []; + + /// + /// 活动每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } + + /// + /// 活动门店 ID。 + /// + public IReadOnlyList StoreIds { get; init; } = []; + + /// + /// 折扣商品。 + /// + public IReadOnlyList Products { get; init; } = []; + + /// + /// 活动指标。 + /// + public FlashSaleMetricsDto Metrics { get; init; } = new(); +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleSaveProductInputDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleSaveProductInputDto.cs new file mode 100644 index 0000000..cd19c22 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleSaveProductInputDto.cs @@ -0,0 +1,23 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣保存商品输入 DTO。 +/// +public sealed class FlashSaleSaveProductInputDto +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 折扣价。 + /// + public decimal DiscountPrice { get; init; } + + /// + /// 商品每人限购(空表示不限)。 + /// + public int? PerUserLimit { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleStatsDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleStatsDto.cs new file mode 100644 index 0000000..03ba4a5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Dto/FlashSaleStatsDto.cs @@ -0,0 +1,28 @@ +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +/// +/// 限时折扣活动统计 DTO。 +/// +public sealed class FlashSaleStatsDto +{ + /// + /// 活动总数。 + /// + public int TotalCount { get; init; } + + /// + /// 进行中数量。 + /// + public int OngoingCount { get; init; } + + /// + /// 参与商品数。 + /// + public int ParticipatingProductCount { get; init; } + + /// + /// 本月折扣销量(单)。 + /// + public int MonthlyDiscountSalesCount { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleDtoFactory.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleDtoFactory.cs new file mode 100644 index 0000000..6177386 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleDtoFactory.cs @@ -0,0 +1,102 @@ +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale; + +/// +/// 限时折扣 DTO 映射工厂。 +/// +internal static class FlashSaleDtoFactory +{ + /// + /// 构建列表项 DTO。 + /// + public static FlashSaleListItemDto ToListItemDto( + PromotionCampaign campaign, + FlashSaleRulesDto rules, + DateTime nowUtc) + { + var displayStatus = FlashSaleMapping.ResolveDisplayStatus(campaign, rules, nowUtc); + return new FlashSaleListItemDto + { + Id = campaign.Id, + Name = campaign.Name, + Status = FlashSaleMapping.ToStatusText(campaign.Status), + DisplayStatus = displayStatus, + IsDimmed = FlashSaleMapping.IsDimmed(displayStatus), + UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt, + Rules = rules + }; + } + + /// + /// 构建详情 DTO。 + /// + public static FlashSaleDetailDto ToDetailDto( + PromotionCampaign campaign, + FlashSaleRulesDto rules, + DateTime nowUtc) + { + return new FlashSaleDetailDto + { + Id = campaign.Id, + Name = campaign.Name, + Status = FlashSaleMapping.ToStatusText(campaign.Status), + DisplayStatus = FlashSaleMapping.ResolveDisplayStatus(campaign, rules, nowUtc), + UpdatedAt = campaign.UpdatedAt ?? campaign.CreatedAt, + Rules = rules + }; + } + + /// + /// 构建统计 DTO。 + /// + public static FlashSaleStatsDto ToStatsDto(IReadOnlyCollection items) + { + if (items.Count == 0) + { + return new FlashSaleStatsDto(); + } + + var participatingProductCount = items + .SelectMany(item => item.Rules.Products) + .Select(item => item.ProductId) + .Distinct() + .Count(); + + var monthlyDiscountSalesCount = items.Sum(item => item.Rules.Metrics.MonthlyDiscountSalesCount); + + return new FlashSaleStatsDto + { + TotalCount = items.Count, + OngoingCount = items.Count(item => string.Equals(item.DisplayStatus, "ongoing", StringComparison.Ordinal)), + ParticipatingProductCount = participatingProductCount, + MonthlyDiscountSalesCount = monthlyDiscountSalesCount + }; + } + + /// + /// 构建默认新增活动实体。 + /// + public static PromotionCampaign CreateNewCampaign( + string name, + DateTime startAt, + DateTime endAt, + string rulesJson) + { + return new PromotionCampaign + { + Name = name, + PromotionType = PromotionType.FlashSale, + Status = PromotionStatus.Active, + StartAt = startAt, + EndAt = endAt, + RulesJson = rulesJson, + AudienceDescription = null, + Budget = null, + BannerUrl = null + }; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleMapping.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleMapping.cs new file mode 100644 index 0000000..9c4aae4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/FlashSaleMapping.cs @@ -0,0 +1,581 @@ +using System.Text.Json; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale; + +/// +/// 限时折扣映射与规则校验辅助。 +/// +internal static class FlashSaleMapping +{ + private const string CycleTypeOnce = "once"; + private const string CycleTypeRecurring = "recurring"; + + private const string RecurringDateModeFixed = "fixed"; + private const string RecurringDateModeLongTerm = "long_term"; + + private const string DisplayStatusEnded = "ended"; + private const string DisplayStatusOngoing = "ongoing"; + private const string DisplayStatusUpcoming = "upcoming"; + + private static readonly HashSet AllowedChannels = + [ + "delivery", + "pickup", + "dine_in" + ]; + + private static readonly HashSet AllowedDisplayStatuses = + [ + DisplayStatusOngoing, + DisplayStatusUpcoming, + DisplayStatusEnded + ]; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + /// + /// 校验并标准化展示状态筛选。 + /// + 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 string ToProductStatusText(ProductStatus status, ProductSoldoutMode? soldoutMode) + { + if (soldoutMode.HasValue) + { + return "sold_out"; + } + + return status switch + { + ProductStatus.OnSale => "on_sale", + _ => "off_shelf" + }; + } + + /// + /// 将开始时间规范为当天 00:00:00(UTC)。 + /// + public static DateTime NormalizeStartOfDay(DateTime dateTime) + { + var utc = dateTime.Kind == DateTimeKind.Utc + ? dateTime + : DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + return utc.Date; + } + + /// + /// 将结束时间规范为当天 23:59:59.9999999(UTC)。 + /// + 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); + } + + /// + /// 解析活动规则。 + /// + public static FlashSaleRulesDto 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.CycleType, + payload.RecurringDateMode, + payload.StartDate, + payload.EndDate, + payload.TimeStart, + payload.TimeEnd, + payload.WeekDays, + payload.Channels, + payload.PerUserLimit, + payload.StoreIds, + payload.Products, + payload.Metrics, + null); + } + + /// + /// 序列化活动规则。 + /// + public static string SerializeRules(FlashSaleRulesDto rules) + { + return JsonSerializer.Serialize(rules, JsonOptions); + } + + /// + /// 标准化并校验保存规则。 + /// + public static FlashSaleRulesDto NormalizeRulesForSave( + string? cycleType, + string? recurringDateMode, + DateTime? startDate, + DateTime? endDate, + TimeSpan? timeStart, + TimeSpan? timeEnd, + IReadOnlyCollection? weekDays, + IReadOnlyCollection? channels, + int? perUserLimit, + IReadOnlyCollection? storeIds, + IReadOnlyCollection? products, + FlashSaleMetricsDto? metrics, + FlashSaleMetricsDto? fallbackMetrics) + { + var normalizedCycleType = NormalizeCycleType(cycleType); + var normalizedRecurringDateMode = NormalizeRecurringDateMode(recurringDateMode, normalizedCycleType); + var (normalizedStartDate, normalizedEndDate) = NormalizeDateRange( + normalizedCycleType, + normalizedRecurringDateMode, + startDate, + endDate); + var (normalizedTimeStart, normalizedTimeEnd) = NormalizeTimeRange(timeStart, timeEnd); + + var normalizedWeekDays = NormalizeWeekDays(normalizedCycleType, weekDays); + var normalizedChannels = NormalizeChannels(channels); + var normalizedPerUserLimit = NormalizeOptionalLimit(perUserLimit, "perUserLimit 参数不合法"); + var normalizedStoreIds = NormalizeStoreIds(storeIds); + var normalizedProducts = NormalizeProducts(products, normalizedPerUserLimit); + var normalizedMetrics = NormalizeMetrics(metrics ?? fallbackMetrics); + + return new FlashSaleRulesDto + { + CycleType = normalizedCycleType, + RecurringDateMode = normalizedRecurringDateMode, + StartDate = normalizedStartDate, + EndDate = normalizedEndDate, + TimeStart = normalizedTimeStart, + TimeEnd = normalizedTimeEnd, + WeekDays = normalizedWeekDays, + Channels = normalizedChannels, + PerUserLimit = normalizedPerUserLimit, + StoreIds = normalizedStoreIds, + Products = normalizedProducts, + Metrics = normalizedMetrics + }; + } + + /// + /// 判断活动是否对可见门店可见。 + /// + public static bool HasVisibleStore(FlashSaleRulesDto rules, IReadOnlyCollection visibleStoreIds) + { + if (visibleStoreIds.Count == 0) + { + return false; + } + + return rules.StoreIds.Any(visibleStoreIds.Contains); + } + + /// + /// 解析活动展示状态。 + /// + public static string ResolveDisplayStatus( + PromotionCampaign campaign, + FlashSaleRulesDto rules, + DateTime nowUtc) + { + if (campaign.Status is PromotionStatus.Completed or PromotionStatus.Paused) + { + return DisplayStatusEnded; + } + + if (string.Equals(rules.CycleType, CycleTypeRecurring, StringComparison.Ordinal) && + string.Equals(rules.RecurringDateMode, RecurringDateModeLongTerm, StringComparison.Ordinal)) + { + return DisplayStatusOngoing; + } + + 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); + } + + /// + /// 计算活动实体起止时间窗口。 + /// + public static (DateTime StartAt, DateTime EndAt) ResolveCampaignWindow(FlashSaleRulesDto rules, DateTime nowUtc) + { + if (string.Equals(rules.CycleType, CycleTypeRecurring, StringComparison.Ordinal) && + string.Equals(rules.RecurringDateMode, RecurringDateModeLongTerm, StringComparison.Ordinal) && + (!rules.StartDate.HasValue || !rules.EndDate.HasValue)) + { + var defaultStart = NormalizeStartOfDay(nowUtc); + var defaultEnd = NormalizeEndOfDay(defaultStart.AddYears(20)); + return (defaultStart, defaultEnd); + } + + if (!rules.StartDate.HasValue || !rules.EndDate.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "活动日期范围缺失"); + } + + return (NormalizeStartOfDay(rules.StartDate.Value), NormalizeEndOfDay(rules.EndDate.Value)); + } + + private static string NormalizeCycleType(string? value) + { + var candidate = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (candidate is not (CycleTypeOnce or CycleTypeRecurring)) + { + throw new BusinessException(ErrorCodes.BadRequest, "cycleType 参数不合法"); + } + + return candidate; + } + + private static string NormalizeRecurringDateMode(string? value, string cycleType) + { + if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal)) + { + return RecurringDateModeFixed; + } + + var candidate = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (candidate is not (RecurringDateModeFixed or RecurringDateModeLongTerm)) + { + throw new BusinessException(ErrorCodes.BadRequest, "recurringDateMode 参数不合法"); + } + + return candidate; + } + + private static (DateTime? StartDate, DateTime? EndDate) NormalizeDateRange( + string cycleType, + string recurringDateMode, + DateTime? startDate, + DateTime? endDate) + { + var normalizedStart = startDate.HasValue ? NormalizeStartOfDay(startDate.Value) : (DateTime?)null; + var normalizedEnd = endDate.HasValue ? NormalizeStartOfDay(endDate.Value) : (DateTime?)null; + + if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal) || + string.Equals(recurringDateMode, RecurringDateModeFixed, StringComparison.Ordinal)) + { + if (!normalizedStart.HasValue || !normalizedEnd.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "startDate 与 endDate 不能为空"); + } + + if (normalizedStart.Value > normalizedEnd.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "活动开始日期不能晚于结束日期"); + } + + return (normalizedStart, normalizedEnd); + } + + if (normalizedStart.HasValue != normalizedEnd.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "startDate 与 endDate 必须同时传入"); + } + + if (normalizedStart.HasValue && normalizedEnd.HasValue && normalizedStart.Value > normalizedEnd.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "活动开始日期不能晚于结束日期"); + } + + return (normalizedStart, normalizedEnd); + } + + private static (TimeSpan? TimeStart, TimeSpan? TimeEnd) NormalizeTimeRange(TimeSpan? timeStart, TimeSpan? timeEnd) + { + if (!timeStart.HasValue && !timeEnd.HasValue) + { + return (null, null); + } + + if (!timeStart.HasValue || !timeEnd.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, "timeStart 与 timeEnd 必须同时传入"); + } + + if (timeStart.Value >= timeEnd.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "每日开始时间必须早于结束时间"); + } + + return (timeStart.Value, timeEnd.Value); + } + + private static IReadOnlyList NormalizeWeekDays(string cycleType, IReadOnlyCollection? weekDays) + { + if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal)) + { + return []; + } + + var normalized = (weekDays ?? Array.Empty()) + .Where(day => day is >= 1 and <= 7) + .Distinct() + .OrderBy(day => day) + .ToList(); + + if (normalized.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "weekDays 不能为空"); + } + + 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 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 NormalizeProducts( + IReadOnlyCollection? products, + int? campaignPerUserLimit) + { + var normalized = (products ?? Array.Empty()) + .Select(item => + { + if (item.ProductId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "productId 参数不合法"); + } + + if (item.CategoryId <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "categoryId 参数不合法"); + } + + var normalizedName = (item.Name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedName)) + { + throw new BusinessException(ErrorCodes.BadRequest, "商品名称不能为空"); + } + + var normalizedSpuCode = (item.SpuCode ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedSpuCode)) + { + throw new BusinessException(ErrorCodes.BadRequest, "商品 SPU 编码不能为空"); + } + + var normalizedStatus = NormalizeProductStatus(item.Status); + var normalizedOriginalPrice = NormalizeMoney(item.OriginalPrice, "商品原价必须大于 0"); + var normalizedDiscountPrice = NormalizeMoney(item.DiscountPrice, "折扣价必须大于 0"); + if (normalizedDiscountPrice > normalizedOriginalPrice) + { + throw new BusinessException(ErrorCodes.BadRequest, "折扣价不能高于原价"); + } + + var normalizedPerUserLimit = NormalizeOptionalLimit(item.PerUserLimit, "商品每人限购必须大于 0"); + if (campaignPerUserLimit.HasValue && + normalizedPerUserLimit.HasValue && + normalizedPerUserLimit.Value > campaignPerUserLimit.Value) + { + throw new BusinessException(ErrorCodes.BadRequest, "商品每人限购不能大于活动每人限购"); + } + + return new FlashSaleProductRuleDto + { + ProductId = item.ProductId, + CategoryId = item.CategoryId, + CategoryName = (item.CategoryName ?? string.Empty).Trim(), + Name = normalizedName, + SpuCode = normalizedSpuCode, + Status = normalizedStatus, + OriginalPrice = normalizedOriginalPrice, + DiscountPrice = normalizedDiscountPrice, + PerUserLimit = normalizedPerUserLimit, + SoldCount = Math.Max(0, item.SoldCount) + }; + }) + .OrderBy(item => item.Name) + .ThenBy(item => item.ProductId) + .ToList(); + + if (normalized.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "products 不能为空"); + } + + var duplicateProductId = normalized + .GroupBy(item => item.ProductId) + .FirstOrDefault(group => group.Count() > 1)? + .Key; + if (duplicateProductId.HasValue) + { + throw new BusinessException(ErrorCodes.BadRequest, $"商品重复: {duplicateProductId.Value}"); + } + + return normalized; + } + + private static string NormalizeProductStatus(string? value) + { + var candidate = (value ?? string.Empty).Trim().ToLowerInvariant(); + return candidate switch + { + "on_sale" => "on_sale", + "off_shelf" => "off_shelf", + "sold_out" => "sold_out", + _ => throw new BusinessException(ErrorCodes.BadRequest, "商品状态不合法") + }; + } + + private static int? NormalizeOptionalLimit(int? value, string errorMessage) + { + if (!value.HasValue) + { + return null; + } + + if (value.Value <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, errorMessage); + } + + return value.Value; + } + + private static FlashSaleMetricsDto NormalizeMetrics(FlashSaleMetricsDto? metrics) + { + if (metrics is null) + { + return new FlashSaleMetricsDto(); + } + + return new FlashSaleMetricsDto + { + ActivitySalesCount = Math.Max(0, metrics.ActivitySalesCount), + DiscountTotalAmount = NormalizeNonNegativeMoney(metrics.DiscountTotalAmount), + LoopedWeeks = Math.Max(0, metrics.LoopedWeeks), + MonthlyDiscountSalesCount = Math.Max(0, metrics.MonthlyDiscountSalesCount) + }; + } + + 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/FlashSale/Handlers/ChangeFlashSaleCampaignStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/ChangeFlashSaleCampaignStatusCommandHandler.cs new file mode 100644 index 0000000..7de1500 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/ChangeFlashSaleCampaignStatusCommandHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; +using TakeoutSaaS.Application.App.Coupons.FlashSale.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.FlashSale.Handlers; + +/// +/// 修改限时折扣活动状态命令处理器。 +/// +public sealed class ChangeFlashSaleCampaignStatusCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(ChangeFlashSaleCampaignStatusCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.FlashSale, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!rules.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + } + + campaign.Status = FlashSaleMapping.ParseStatus(request.Status); + + await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return FlashSaleDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/DeleteFlashSaleCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/DeleteFlashSaleCampaignCommandHandler.cs new file mode 100644 index 0000000..860f74a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/DeleteFlashSaleCampaignCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.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.FlashSale.Handlers; + +/// +/// 删除限时折扣活动命令处理器。 +/// +public sealed class DeleteFlashSaleCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(DeleteFlashSaleCampaignCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.FlashSale, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var rules = FlashSaleMapping.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/FlashSale/Handlers/GetFlashSaleCampaignDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignDetailQueryHandler.cs new file mode 100644 index 0000000..8608c80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignDetailQueryHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Application.App.Coupons.FlashSale.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.FlashSale.Handlers; + +/// +/// 限时折扣活动详情查询处理器。 +/// +public sealed class GetFlashSaleCampaignDetailQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetFlashSaleCampaignDetailQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId, + tenantId, + PromotionType.FlashSale, + cancellationToken); + + if (campaign is null) + { + return null; + } + + var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!rules.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + } + + return FlashSaleDtoFactory.ToDetailDto(campaign, rules, DateTime.UtcNow); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignListQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignListQueryHandler.cs new file mode 100644 index 0000000..1ee9f0f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSaleCampaignListQueryHandler.cs @@ -0,0 +1,112 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Application.App.Coupons.FlashSale.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.FlashSale.Handlers; + +/// +/// 限时折扣活动列表查询处理器。 +/// +public sealed class GetFlashSaleCampaignListQueryHandler( + IPromotionCampaignRepository promotionCampaignRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetFlashSaleCampaignListQuery request, CancellationToken cancellationToken) + { + var page = Math.Max(1, request.Page); + var pageSize = Math.Clamp(request.PageSize, 1, 200); + + if (!FlashSaleMapping.TryNormalizeDisplayStatusFilter(request.Status, out var normalizedStatus)) + { + throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法"); + } + + if (request.VisibleStoreIds.Count == 0) + { + return new FlashSaleListResultDto + { + Items = [], + TotalCount = 0, + Page = page, + PageSize = pageSize, + Stats = new FlashSaleStatsDto() + }; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync( + tenantId, + PromotionType.FlashSale, + cancellationToken); + + if (campaigns.Count == 0) + { + return new FlashSaleListResultDto + { + Items = [], + TotalCount = 0, + Page = page, + PageSize = pageSize, + Stats = new FlashSaleStatsDto() + }; + } + + var nowUtc = DateTime.UtcNow; + + var visibleItems = new List(campaigns.Count); + foreach (var campaign in campaigns) + { + var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!FlashSaleMapping.HasVisibleStore(rules, request.VisibleStoreIds)) + { + continue; + } + + visibleItems.Add(FlashSaleDtoFactory.ToListItemDto(campaign, rules, nowUtc)); + } + + var stats = FlashSaleDtoFactory.ToStatsDto(visibleItems); + + IEnumerable filtered = visibleItems; + 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 FlashSaleListResultDto + { + Items = paged, + TotalCount = total, + Page = page, + PageSize = pageSize, + Stats = stats + }; + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerCategoriesQueryHandler.cs new file mode 100644 index 0000000..4e99cf1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerCategoriesQueryHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers; + +/// +/// 限时折扣选品分类查询处理器。 +/// +public sealed class GetFlashSalePickerCategoriesQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetFlashSalePickerCategoriesQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await productRepository.GetCategoriesByStoreAsync( + tenantId, + request.OperationStoreId, + true, + cancellationToken); + if (categories.Count == 0) + { + return []; + } + + var categoryIds = categories.Select(item => item.Id).ToList(); + var productCountLookup = await productRepository.CountProductsByCategoryIdsAsync( + tenantId, + request.OperationStoreId, + categoryIds, + cancellationToken); + + return categories + .Select(item => new FlashSalePickerCategoryItemDto + { + Id = item.Id, + Name = item.Name, + ProductCount = productCountLookup.GetValueOrDefault(item.Id, 0) + }) + .ToList(); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerProductsQueryHandler.cs new file mode 100644 index 0000000..7c5985d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/GetFlashSalePickerProductsQueryHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers; + +/// +/// 限时折扣选品商品查询处理器。 +/// +public sealed class GetFlashSalePickerProductsQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetFlashSalePickerProductsQuery request, + CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + + var limit = Math.Clamp(request.Limit ?? 200, 1, 500); + var products = await productRepository.SearchPickerAsync( + tenantId, + request.OperationStoreId, + request.CategoryId, + request.Keyword, + limit, + cancellationToken); + + if (products.Count == 0) + { + return []; + } + + var categoryLookup = (await productRepository.GetCategoriesByStoreAsync( + tenantId, + request.OperationStoreId, + false, + cancellationToken)) + .ToDictionary(item => item.Id, item => item.Name); + + return products + .Select(item => new FlashSalePickerProductItemDto + { + Id = item.Id, + CategoryId = item.CategoryId, + CategoryName = categoryLookup.GetValueOrDefault(item.CategoryId, string.Empty), + Name = item.Name, + Price = decimal.Round(item.Price, 2, MidpointRounding.AwayFromZero), + Stock = Math.Max(0, item.StockQuantity ?? 0), + SpuCode = item.SpuCode, + Status = FlashSaleMapping.ToProductStatusText(item.Status, item.SoldoutMode) + }) + .ToList(); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/SaveFlashSaleCampaignCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/SaveFlashSaleCampaignCommandHandler.cs new file mode 100644 index 0000000..624a57b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Handlers/SaveFlashSaleCampaignCommandHandler.cs @@ -0,0 +1,177 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Commands; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Domain.Coupons.Repositories; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Handlers; + +/// +/// 保存限时折扣活动命令处理器。 +/// +public sealed class SaveFlashSaleCampaignCommandHandler( + IPromotionCampaignRepository promotionCampaignRepository, + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(SaveFlashSaleCampaignCommand 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"); + } + + if (request.StoreIds.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空"); + } + + if (!request.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.BadRequest, "storeIds 必须包含当前操作门店"); + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + + PromotionCampaign? campaign = null; + FlashSaleMetricsDto? fallbackMetrics = null; + var soldCountLookup = new Dictionary(); + if (request.CampaignId.HasValue) + { + campaign = await promotionCampaignRepository.FindByIdAsync( + request.CampaignId.Value, + tenantId, + PromotionType.FlashSale, + cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + + var existingRules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id); + if (!existingRules.StoreIds.Contains(request.OperationStoreId)) + { + throw new BusinessException(ErrorCodes.NotFound, "活动不存在"); + } + + fallbackMetrics = existingRules.Metrics; + soldCountLookup = existingRules.Products + .GroupBy(item => item.ProductId) + .ToDictionary(group => group.Key, group => Math.Max(0, group.First().SoldCount)); + } + + var saveProductItems = request.Products + .Where(item => item.ProductId > 0) + .GroupBy(item => item.ProductId) + .Select(group => group.First()) + .ToList(); + + if (saveProductItems.Count == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "折扣商品不能为空"); + } + + var productIds = saveProductItems.Select(item => item.ProductId).ToList(); + var products = await productRepository.GetByIdsAsync( + tenantId, + request.OperationStoreId, + productIds, + cancellationToken); + + if (products.Count != saveProductItems.Count) + { + throw new BusinessException(ErrorCodes.BadRequest, "存在无效商品,请刷新后重试"); + } + + var categories = await productRepository.GetCategoriesByStoreAsync( + tenantId, + request.OperationStoreId, + false, + cancellationToken); + var categoryNameLookup = categories.ToDictionary(item => item.Id, item => item.Name); + + var productLookup = products.ToDictionary(item => item.Id, item => item); + var normalizedProducts = saveProductItems + .Select(item => + { + var product = productLookup[item.ProductId]; + var normalizedOriginalPrice = decimal.Round(product.Price, 2, MidpointRounding.AwayFromZero); + if (item.DiscountPrice > normalizedOriginalPrice) + { + throw new BusinessException(ErrorCodes.BadRequest, $"商品[{product.Name}]折扣价不能高于原价"); + } + + soldCountLookup.TryGetValue(item.ProductId, out var soldCount); + + return new FlashSaleProductRuleDto + { + ProductId = product.Id, + CategoryId = product.CategoryId, + CategoryName = categoryNameLookup.GetValueOrDefault(product.CategoryId, string.Empty), + Name = product.Name, + SpuCode = product.SpuCode, + Status = FlashSaleMapping.ToProductStatusText(product.Status, product.SoldoutMode), + OriginalPrice = normalizedOriginalPrice, + DiscountPrice = item.DiscountPrice, + PerUserLimit = item.PerUserLimit, + SoldCount = soldCount + }; + }) + .ToList(); + + var normalizedRules = FlashSaleMapping.NormalizeRulesForSave( + request.CycleType, + request.RecurringDateMode, + request.StartDate, + request.EndDate, + request.TimeStart, + request.TimeEnd, + request.WeekDays, + request.Channels, + request.PerUserLimit, + request.StoreIds, + normalizedProducts, + request.Metrics, + fallbackMetrics); + + var nowUtc = DateTime.UtcNow; + var campaignWindow = FlashSaleMapping.ResolveCampaignWindow(normalizedRules, nowUtc); + if (campaign is null) + { + campaign = FlashSaleDtoFactory.CreateNewCampaign( + normalizedName, + campaignWindow.StartAt, + campaignWindow.EndAt, + FlashSaleMapping.SerializeRules(normalizedRules)); + + await promotionCampaignRepository.AddAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return FlashSaleDtoFactory.ToDetailDto(campaign, normalizedRules, nowUtc); + } + + campaign.Name = normalizedName; + campaign.StartAt = campaignWindow.StartAt; + campaign.EndAt = campaignWindow.EndAt; + campaign.RulesJson = FlashSaleMapping.SerializeRules(normalizedRules); + + if (campaign.Status != PromotionStatus.Completed) + { + campaign.Status = PromotionStatus.Active; + } + + await promotionCampaignRepository.UpdateAsync(campaign, cancellationToken); + await promotionCampaignRepository.SaveChangesAsync(cancellationToken); + return FlashSaleDtoFactory.ToDetailDto(campaign, normalizedRules, DateTime.UtcNow); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignDetailQuery.cs new file mode 100644 index 0000000..06306d6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignDetailQuery.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; + +/// +/// 查询限时折扣活动详情。 +/// +public sealed class GetFlashSaleCampaignDetailQuery : IRequest +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 活动 ID。 + /// + public long CampaignId { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignListQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignListQuery.cs new file mode 100644 index 0000000..823c2d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSaleCampaignListQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; + +/// +/// 查询限时折扣活动列表。 +/// +public sealed class GetFlashSaleCampaignListQuery : IRequest +{ + /// + /// 可见门店 ID 集合。 + /// + public IReadOnlyCollection VisibleStoreIds { get; init; } = []; + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 状态筛选(ongoing/upcoming/ended)。 + /// + public string? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 4; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerCategoriesQuery.cs new file mode 100644 index 0000000..a02a3f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerCategoriesQuery.cs @@ -0,0 +1,16 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; + +/// +/// 查询限时折扣选品分类列表。 +/// +public sealed class GetFlashSalePickerCategoriesQuery : IRequest> +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerProductsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerProductsQuery.cs new file mode 100644 index 0000000..8bac7c9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Coupons/FlashSale/Queries/GetFlashSalePickerProductsQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto; + +namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries; + +/// +/// 查询限时折扣选品商品列表。 +/// +public sealed class GetFlashSalePickerProductsQuery : IRequest> +{ + /// + /// 操作门店 ID。 + /// + public long OperationStoreId { get; init; } + + /// + /// 分类 ID(可空)。 + /// + public long? CategoryId { get; init; } + + /// + /// 关键字。 + /// + public string? Keyword { get; init; } + + /// + /// 条数上限。 + /// + public int? Limit { get; init; } +} + diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index b0b119c..47d51dc 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -140,6 +140,15 @@ public interface IProductRepository /// Task> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default); + /// + /// 按标识批量读取商品。 + /// + Task> GetByIdsAsync( + long tenantId, + long storeId, + IReadOnlyCollection productIds, + CancellationToken cancellationToken = default); + /// /// 批量更新商品分类。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 633bea7..6d5c8a6 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -510,6 +510,29 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR .ToListAsync(cancellationToken); } + /// + public async Task> GetByIdsAsync( + long tenantId, + long storeId, + IReadOnlyCollection productIds, + CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return []; + } + + return await context.Products + .AsNoTracking() + .Where(x => + x.TenantId == tenantId && + x.StoreId == storeId && + productIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } + /// public async Task BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection productIds, CancellationToken cancellationToken = default) {