feat(marketing): implement flash sale module api and app layer
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m4s
All checks were successful
Build and Deploy TenantApi + SkuWorker / build-and-deploy (push) Successful in 2m4s
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表查询请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID(可空,空表示全部门店)。
|
||||
/// </summary>
|
||||
public string? StoreId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态筛选(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣详情请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleDetailRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣请求。
|
||||
/// </summary>
|
||||
public sealed class SaveFlashSaleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7,周一到周日)。
|
||||
/// </summary>
|
||||
public List<int>? WeekDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店 ID。
|
||||
/// </summary>
|
||||
public List<string>? StoreIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品列表。
|
||||
/// </summary>
|
||||
public List<FlashSaleSaveProductRequest> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsRequest? Metrics { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣状态请求。
|
||||
/// </summary>
|
||||
public sealed class ChangeFlashSaleStatusRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "completed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣请求。
|
||||
/// </summary>
|
||||
public sealed class DeleteFlashSaleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string ActivityId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListResultResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表项。
|
||||
/// </summary>
|
||||
public List<FlashSaleListItemResponse> Items { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public FlashSaleStatsResponse Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣列表项响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品。
|
||||
/// </summary>
|
||||
public List<FlashSaleProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣详情响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleDetailResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; set; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; set; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(yyyy-MM-dd)。
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间(HH:mm)。
|
||||
/// </summary>
|
||||
public string? TimeEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7)。
|
||||
/// </summary>
|
||||
public List<int> WeekDays { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; set; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public List<string> Channels { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public List<string> StoreIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品。
|
||||
/// </summary>
|
||||
public List<FlashSaleProductResponse> Products { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsResponse Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(yyyy-MM-dd HH:mm:ss)。
|
||||
/// </summary>
|
||||
public string UpdatedAt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣统计响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleStatsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 参与商品数。
|
||||
/// </summary>
|
||||
public int ParticipatingProductCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleSaveProductRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleProductResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SPU 编码。
|
||||
/// </summary>
|
||||
public string SpuCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品状态(on_sale/off_shelf/sold_out)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "off_shelf";
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal OriginalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣指标请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleMetricsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动销量(单)。
|
||||
/// </summary>
|
||||
public int ActivitySalesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已循环周数。
|
||||
/// </summary>
|
||||
public int LoopedWeeks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣指标响应。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleMetricsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动销量(单)。
|
||||
/// </summary>
|
||||
public int ActivitySalesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已循环周数。
|
||||
/// </summary>
|
||||
public int LoopedWeeks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品分类选择器请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerCategoriesRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品分类选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerCategoryItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品选择器请求。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerProductsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public string StoreId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID(可空)。
|
||||
/// </summary>
|
||||
public string? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量上限。
|
||||
/// </summary>
|
||||
public int? Limit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品选择器响应项。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerProductItemResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SPU 编码。
|
||||
/// </summary>
|
||||
public string SpuCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(on_sale/off_shelf/sold_out)。
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "off_shelf";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 营销中心限时折扣活动管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/marketing/flash-sale")]
|
||||
public sealed class MarketingFlashSaleController(
|
||||
IMediator mediator,
|
||||
TakeoutAppDbContext dbContext,
|
||||
StoreContextService storeContextService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取限时折扣活动列表。
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleListResultResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleListResultResponse>> 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<FlashSaleListResultResponse>.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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣活动详情。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> 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<FlashSaleDetailResponse>.Error(ErrorCodes.NotFound, "活动不存在");
|
||||
}
|
||||
|
||||
return ApiResponse<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣活动(新增/编辑)。
|
||||
/// </summary>
|
||||
[HttpPost("save")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> 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<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣活动状态。
|
||||
/// </summary>
|
||||
[HttpPost("status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<FlashSaleDetailResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<FlashSaleDetailResponse>> 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<FlashSaleDetailResponse>.Ok(MapDetail(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣活动。
|
||||
/// </summary>
|
||||
[HttpPost("delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> 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<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣选品分类。
|
||||
/// </summary>
|
||||
[HttpGet("picker/categories")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerCategoryItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<FlashSalePickerCategoryItemResponse>>> 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<List<FlashSalePickerCategoryItemResponse>>.Ok(result
|
||||
.Select(item => new FlashSalePickerCategoryItemResponse
|
||||
{
|
||||
Id = item.Id.ToString(),
|
||||
Name = item.Name,
|
||||
ProductCount = item.ProductCount
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取限时折扣选品商品。
|
||||
/// </summary>
|
||||
[HttpGet("picker/products")]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<FlashSalePickerProductItemResponse>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<FlashSalePickerProductItemResponse>>> 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<List<FlashSalePickerProductItemResponse>>.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<IReadOnlyCollection<long>> 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<IReadOnlyCollection<long>> ResolveStoreIdsForSaveAsync(
|
||||
IEnumerable<string>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣活动状态命令。
|
||||
/// </summary>
|
||||
public sealed class ChangeFlashSaleCampaignStatusCommand : IRequest<FlashSaleDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "completed";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣活动命令。
|
||||
/// </summary>
|
||||
public sealed class DeleteFlashSaleCampaignCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣活动命令。
|
||||
/// </summary>
|
||||
public sealed class SaveFlashSaleCampaignCommand : IRequest<FlashSaleDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID(编辑时传)。
|
||||
/// </summary>
|
||||
public long? CampaignId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; init; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; init; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan? TimeStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan? TimeEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7)。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<int> WeekDays { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> StoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 商品配置输入。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<FlashSaleSaveProductInputDto> Products { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsDto? Metrics { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动规则。
|
||||
/// </summary>
|
||||
public FlashSaleRulesDto Rules { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑状态(active/completed)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// 展示状态(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string DisplayStatus { get; init; } = "ongoing";
|
||||
|
||||
/// <summary>
|
||||
/// 是否弱化展示。
|
||||
/// </summary>
|
||||
public bool IsDimmed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动规则。
|
||||
/// </summary>
|
||||
public FlashSaleRulesDto Rules { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动列表结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleListResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 列表数据。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FlashSaleListItemDto> Items { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 总条数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计信息。
|
||||
/// </summary>
|
||||
public FlashSaleStatsDto Stats { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动指标 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleMetricsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动销量(单)。
|
||||
/// </summary>
|
||||
public int ActivitySalesCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣总额。
|
||||
/// </summary>
|
||||
public decimal DiscountTotalAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已循环周数。
|
||||
/// </summary>
|
||||
public int LoopedWeeks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣选品分类项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerCategoryItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品数量。
|
||||
/// </summary>
|
||||
public int ProductCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣选品商品项 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSalePickerProductItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public long CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string CategoryName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 售价。
|
||||
/// </summary>
|
||||
public decimal Price { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存。
|
||||
/// </summary>
|
||||
public int Stock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SPU 编码。
|
||||
/// </summary>
|
||||
public string SpuCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态(on_sale/off_shelf/sold_out)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "off_shelf";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣商品规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleProductRuleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public long CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类名称。
|
||||
/// </summary>
|
||||
public string CategoryName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SPU 编码。
|
||||
/// </summary>
|
||||
public string SpuCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商品状态(on_sale/off_shelf/sold_out)。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "off_shelf";
|
||||
|
||||
/// <summary>
|
||||
/// 原价。
|
||||
/// </summary>
|
||||
public decimal OriginalPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已售数量。
|
||||
/// </summary>
|
||||
public int SoldCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动规则 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleRulesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动周期(once/recurring)。
|
||||
/// </summary>
|
||||
public string CycleType { get; init; } = "once";
|
||||
|
||||
/// <summary>
|
||||
/// 周期日期模式(fixed/long_term)。
|
||||
/// </summary>
|
||||
public string RecurringDateMode { get; init; } = "fixed";
|
||||
|
||||
/// <summary>
|
||||
/// 活动开始日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime? StartDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动结束日期(UTC 日期)。
|
||||
/// </summary>
|
||||
public DateTime? EndDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日开始时间。
|
||||
/// </summary>
|
||||
public TimeSpan? TimeStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 每日结束时间。
|
||||
/// </summary>
|
||||
public TimeSpan? TimeEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 循环星期(1-7,周一到周日)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> WeekDays { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 适用渠道(delivery/pickup/dine_in)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Channels { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动门店 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<long> StoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 折扣商品。
|
||||
/// </summary>
|
||||
public IReadOnlyList<FlashSaleProductRuleDto> Products { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 活动指标。
|
||||
/// </summary>
|
||||
public FlashSaleMetricsDto Metrics { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣保存商品输入 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleSaveProductInputDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 商品 ID。
|
||||
/// </summary>
|
||||
public long ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 折扣价。
|
||||
/// </summary>
|
||||
public decimal DiscountPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商品每人限购(空表示不限)。
|
||||
/// </summary>
|
||||
public int? PerUserLimit { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动统计 DTO。
|
||||
/// </summary>
|
||||
public sealed class FlashSaleStatsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 活动总数。
|
||||
/// </summary>
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 进行中数量。
|
||||
/// </summary>
|
||||
public int OngoingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 参与商品数。
|
||||
/// </summary>
|
||||
public int ParticipatingProductCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 本月折扣销量(单)。
|
||||
/// </summary>
|
||||
public int MonthlyDiscountSalesCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣 DTO 映射工厂。
|
||||
/// </summary>
|
||||
internal static class FlashSaleDtoFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建列表项 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建详情 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建统计 DTO。
|
||||
/// </summary>
|
||||
public static FlashSaleStatsDto ToStatsDto(IReadOnlyCollection<FlashSaleListItemDto> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建默认新增活动实体。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣映射与规则校验辅助。
|
||||
/// </summary>
|
||||
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<string> AllowedChannels =
|
||||
[
|
||||
"delivery",
|
||||
"pickup",
|
||||
"dine_in"
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> AllowedDisplayStatuses =
|
||||
[
|
||||
DisplayStatusOngoing,
|
||||
DisplayStatusUpcoming,
|
||||
DisplayStatusEnded
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 校验并标准化展示状态筛选。
|
||||
/// </summary>
|
||||
public static bool TryNormalizeDisplayStatusFilter(string? value, out string? normalized)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
normalized = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AllowedDisplayStatuses.Contains(candidate))
|
||||
{
|
||||
normalized = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
normalized = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析状态文本为领域状态。
|
||||
/// </summary>
|
||||
public static PromotionStatus ParseStatus(string? value)
|
||||
{
|
||||
var candidate = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return candidate switch
|
||||
{
|
||||
"active" => PromotionStatus.Active,
|
||||
"completed" => PromotionStatus.Completed,
|
||||
_ => throw new BusinessException(ErrorCodes.BadRequest, "status 参数不合法")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 输出状态文本。
|
||||
/// </summary>
|
||||
public static string ToStatusText(PromotionStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
PromotionStatus.Active => "active",
|
||||
PromotionStatus.Completed => "completed",
|
||||
_ => "completed"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商品状态输出文本。
|
||||
/// </summary>
|
||||
public static string ToProductStatusText(ProductStatus status, ProductSoldoutMode? soldoutMode)
|
||||
{
|
||||
if (soldoutMode.HasValue)
|
||||
{
|
||||
return "sold_out";
|
||||
}
|
||||
|
||||
return status switch
|
||||
{
|
||||
ProductStatus.OnSale => "on_sale",
|
||||
_ => "off_shelf"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将开始时间规范为当天 00:00:00(UTC)。
|
||||
/// </summary>
|
||||
public static DateTime NormalizeStartOfDay(DateTime dateTime)
|
||||
{
|
||||
var utc = dateTime.Kind == DateTimeKind.Utc
|
||||
? dateTime
|
||||
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
|
||||
return utc.Date;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将结束时间规范为当天 23:59:59.9999999(UTC)。
|
||||
/// </summary>
|
||||
public static DateTime NormalizeEndOfDay(DateTime dateTime)
|
||||
{
|
||||
var utc = dateTime.Kind == DateTimeKind.Utc
|
||||
? dateTime
|
||||
: DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
|
||||
return utc.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析活动规则。
|
||||
/// </summary>
|
||||
public static FlashSaleRulesDto DeserializeRules(string? rulesJson, long campaignId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rulesJson))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"活动[{campaignId}]规则缺失");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Deserialize<FlashSaleRulesDto>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 序列化活动规则。
|
||||
/// </summary>
|
||||
public static string SerializeRules(FlashSaleRulesDto rules)
|
||||
{
|
||||
return JsonSerializer.Serialize(rules, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标准化并校验保存规则。
|
||||
/// </summary>
|
||||
public static FlashSaleRulesDto NormalizeRulesForSave(
|
||||
string? cycleType,
|
||||
string? recurringDateMode,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
TimeSpan? timeStart,
|
||||
TimeSpan? timeEnd,
|
||||
IReadOnlyCollection<int>? weekDays,
|
||||
IReadOnlyCollection<string>? channels,
|
||||
int? perUserLimit,
|
||||
IReadOnlyCollection<long>? storeIds,
|
||||
IReadOnlyCollection<FlashSaleProductRuleDto>? 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断活动是否对可见门店可见。
|
||||
/// </summary>
|
||||
public static bool HasVisibleStore(FlashSaleRulesDto rules, IReadOnlyCollection<long> visibleStoreIds)
|
||||
{
|
||||
if (visibleStoreIds.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rules.StoreIds.Any(visibleStoreIds.Contains);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析活动展示状态。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否弱化展示。
|
||||
/// </summary>
|
||||
public static bool IsDimmed(string displayStatus)
|
||||
{
|
||||
return string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算活动实体起止时间窗口。
|
||||
/// </summary>
|
||||
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<int> NormalizeWeekDays(string cycleType, IReadOnlyCollection<int>? weekDays)
|
||||
{
|
||||
if (string.Equals(cycleType, CycleTypeOnce, StringComparison.Ordinal))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalized = (weekDays ?? Array.Empty<int>())
|
||||
.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<string> NormalizeChannels(IReadOnlyCollection<string>? channels)
|
||||
{
|
||||
var normalized = (channels ?? Array.Empty<string>())
|
||||
.Select(item => (item ?? string.Empty).Trim().ToLowerInvariant())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 不能为空");
|
||||
}
|
||||
|
||||
if (normalized.Any(item => !AllowedChannels.Contains(item)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "channels 存在非法值");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<long> NormalizeStoreIds(IReadOnlyCollection<long>? storeIds)
|
||||
{
|
||||
var normalized = (storeIds ?? Array.Empty<long>())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "storeIds 不能为空");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FlashSaleProductRuleDto> NormalizeProducts(
|
||||
IReadOnlyCollection<FlashSaleProductRuleDto>? products,
|
||||
int? campaignPerUserLimit)
|
||||
{
|
||||
var normalized = (products ?? Array.Empty<FlashSaleProductRuleDto>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 修改限时折扣活动状态命令处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeFlashSaleCampaignStatusCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ChangeFlashSaleCampaignStatusCommand, FlashSaleDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FlashSaleDetailDto> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 删除限时折扣活动命令处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteFlashSaleCampaignCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeleteFlashSaleCampaignCommand, Unit>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Unit> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动详情查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSaleCampaignDetailQueryHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFlashSaleCampaignDetailQuery, FlashSaleDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FlashSaleDetailDto?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣活动列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSaleCampaignListQueryHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFlashSaleCampaignListQuery, FlashSaleListResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FlashSaleListResultDto> 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<FlashSaleListItemDto>(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<FlashSaleListItemDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣选品分类查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSalePickerCategoriesQueryHandler(
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFlashSalePickerCategoriesQuery, IReadOnlyList<FlashSalePickerCategoryItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FlashSalePickerCategoryItemDto>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 限时折扣选品商品查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSalePickerProductsQueryHandler(
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetFlashSalePickerProductsQuery, IReadOnlyList<FlashSalePickerProductItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FlashSalePickerProductItemDto>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 保存限时折扣活动命令处理器。
|
||||
/// </summary>
|
||||
public sealed class SaveFlashSaleCampaignCommandHandler(
|
||||
IPromotionCampaignRepository promotionCampaignRepository,
|
||||
IProductRepository productRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SaveFlashSaleCampaignCommand, FlashSaleDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<FlashSaleDetailDto> 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<long, int>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询限时折扣活动详情。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSaleCampaignDetailQuery : IRequest<FlashSaleDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 活动 ID。
|
||||
/// </summary>
|
||||
public long CampaignId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询限时折扣活动列表。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSaleCampaignListQuery : IRequest<FlashSaleListResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 可见门店 ID 集合。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<long> VisibleStoreIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态筛选(ongoing/upcoming/ended)。
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询限时折扣选品分类列表。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSalePickerCategoriesQuery : IRequest<IReadOnlyList<FlashSalePickerCategoryItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Coupons.FlashSale.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询限时折扣选品商品列表。
|
||||
/// </summary>
|
||||
public sealed class GetFlashSalePickerProductsQuery : IRequest<IReadOnlyList<FlashSalePickerProductItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作门店 ID。
|
||||
/// </summary>
|
||||
public long OperationStoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID(可空)。
|
||||
/// </summary>
|
||||
public long? CategoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 条数上限。
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
||||
@@ -140,6 +140,15 @@ public interface IProductRepository
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按标识批量读取商品。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Product>> GetByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> productIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量更新商品分类。
|
||||
/// </summary>
|
||||
|
||||
@@ -510,6 +510,29 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Product>> GetByIdsAsync(
|
||||
long tenantId,
|
||||
long storeId,
|
||||
IReadOnlyCollection<long> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user