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>
|
/// </summary>
|
||||||
Task<IReadOnlyList<Product>> SearchPickerAsync(long tenantId, long storeId, long? categoryId, string? keyword, int limit, CancellationToken cancellationToken = default);
|
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>
|
||||||
/// 批量更新商品分类。
|
/// 批量更新商品分类。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -510,6 +510,29 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR
|
|||||||
.ToListAsync(cancellationToken);
|
.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 />
|
/// <inheritdoc />
|
||||||
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
|
public async Task<int> BatchUpdateProductCategoryAsync(long tenantId, long storeId, long categoryId, IReadOnlyCollection<long> productIds, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user