From 1b28fa6db415bb60e85a8e99e541ba1811e2358f Mon Sep 17 00:00:00 2001
From: MSuMshk <2039814060@qq.com>
Date: Tue, 3 Mar 2026 10:10:41 +0800
Subject: [PATCH] feat(marketing): implement marketing calendar backend
---
TakeoutSaaS.Docs | 2 +-
.../Contracts/Marketing/CalendarContracts.cs | 190 +++
.../MarketingCalendarController.cs | 171 +++
.../Dto/MarketingCalendarOverviewDto.cs | 397 ++++++
...etMarketingCalendarOverviewQueryHandler.cs | 1085 +++++++++++++++++
.../GetMarketingCalendarOverviewQuery.cs | 25 +
.../Repositories/IPunchCardRepository.cs | 8 +
.../App/Repositories/EfPunchCardRepository.cs | 14 +
8 files changed, 1891 insertions(+), 1 deletion(-)
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CalendarContracts.cs
create mode 100644 src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCalendarController.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Dto/MarketingCalendarOverviewDto.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Handlers/GetMarketingCalendarOverviewQueryHandler.cs
create mode 100644 src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Queries/GetMarketingCalendarOverviewQuery.cs
diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs
index 315fec7..c98e4ba 160000
--- a/TakeoutSaaS.Docs
+++ b/TakeoutSaaS.Docs
@@ -1 +1 @@
-Subproject commit 315fec77b6a226f74a4d8bbd8bd264179135b9b6
+Subproject commit c98e4ba3c49edc90031118f14ea8e26a48ff6509
diff --git a/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CalendarContracts.cs b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CalendarContracts.cs
new file mode 100644
index 0000000..9afcbd6
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Contracts/Marketing/CalendarContracts.cs
@@ -0,0 +1,190 @@
+namespace TakeoutSaaS.TenantApi.Contracts.Marketing;
+
+///
+/// 营销日历总览查询请求。
+///
+public sealed class MarketingCalendarOverviewRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public string StoreId { get; set; } = string.Empty;
+
+ ///
+ /// 年份。
+ ///
+ public int Year { get; set; }
+
+ ///
+ /// 月份(1-12)。
+ ///
+ public int Month { get; set; }
+}
+
+///
+/// 营销日历总览响应。
+///
+public sealed class MarketingCalendarOverviewResponse
+{
+ public string Month { get; set; } = string.Empty;
+
+ public int Year { get; set; }
+
+ public int MonthValue { get; set; }
+
+ public string MonthStartDate { get; set; } = string.Empty;
+
+ public string MonthEndDate { get; set; } = string.Empty;
+
+ public int TodayDay { get; set; }
+
+ public List Days { get; set; } = [];
+
+ public List Legends { get; set; } = [];
+
+ public MarketingCalendarStatsResponse Stats { get; set; } = new();
+
+ public MarketingCalendarConflictBannerResponse? ConflictBanner { get; set; }
+
+ public List Conflicts { get; set; } = [];
+
+ public List Activities { get; set; } = [];
+}
+
+public sealed class MarketingCalendarDayResponse
+{
+ public int Day { get; set; }
+
+ public bool IsWeekend { get; set; }
+
+ public bool IsToday { get; set; }
+}
+
+public sealed class MarketingCalendarLegendResponse
+{
+ public string Type { get; set; } = string.Empty;
+
+ public string Label { get; set; } = string.Empty;
+
+ public string Color { get; set; } = string.Empty;
+}
+
+public sealed class MarketingCalendarStatsResponse
+{
+ public int TotalActivityCount { get; set; }
+
+ public int OngoingCount { get; set; }
+
+ public int MaxConcurrentCount { get; set; }
+
+ public decimal EstimatedDiscountAmount { get; set; }
+}
+
+public sealed class MarketingCalendarActivityResponse
+{
+ public string ActivityId { get; set; } = string.Empty;
+
+ public string SourceType { get; set; } = string.Empty;
+
+ public string SourceId { get; set; } = string.Empty;
+
+ public string CalendarType { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+
+ public string Color { get; set; } = string.Empty;
+
+ public string Summary { get; set; } = string.Empty;
+
+ public string DisplayStatus { get; set; } = string.Empty;
+
+ public bool IsDimmed { get; set; }
+
+ public string StartDate { get; set; } = string.Empty;
+
+ public string EndDate { get; set; } = string.Empty;
+
+ public decimal EstimatedDiscountAmount { get; set; }
+
+ public List Bars { get; set; } = [];
+
+ public MarketingCalendarActivityDetailResponse Detail { get; set; } = new();
+}
+
+public sealed class MarketingCalendarActivityBarResponse
+{
+ public string BarId { get; set; } = string.Empty;
+
+ public int StartDay { get; set; }
+
+ public int EndDay { get; set; }
+
+ public string Label { get; set; } = string.Empty;
+
+ public bool IsMilestone { get; set; }
+
+ public bool IsDimmed { get; set; }
+}
+
+public sealed class MarketingCalendarActivityDetailResponse
+{
+ public string ModuleName { get; set; } = string.Empty;
+
+ public string Description { get; set; } = string.Empty;
+
+ public List Fields { get; set; } = [];
+}
+
+public sealed class MarketingCalendarDetailFieldResponse
+{
+ public string Label { get; set; } = string.Empty;
+
+ public string Value { get; set; } = string.Empty;
+}
+
+public sealed class MarketingCalendarConflictBannerResponse
+{
+ public string ConflictId { get; set; } = string.Empty;
+
+ public int StartDay { get; set; }
+
+ public int EndDay { get; set; }
+
+ public int ActivityCount { get; set; }
+
+ public int MaxConcurrentCount { get; set; }
+
+ public int ConflictCount { get; set; }
+}
+
+public sealed class MarketingCalendarConflictResponse
+{
+ public string ConflictId { get; set; } = string.Empty;
+
+ public int StartDay { get; set; }
+
+ public int EndDay { get; set; }
+
+ public int ActivityCount { get; set; }
+
+ public int MaxConcurrentCount { get; set; }
+
+ public List ActivityIds { get; set; } = [];
+
+ public List Activities { get; set; } = [];
+}
+
+public sealed class MarketingCalendarConflictActivityResponse
+{
+ public string ActivityId { get; set; } = string.Empty;
+
+ public string CalendarType { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+
+ public string Summary { get; set; } = string.Empty;
+
+ public string Color { get; set; } = string.Empty;
+
+ public string DisplayStatus { get; set; } = string.Empty;
+}
diff --git a/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCalendarController.cs b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCalendarController.cs
new file mode 100644
index 0000000..90e8bdb
--- /dev/null
+++ b/src/Api/TakeoutSaaS.TenantApi/Controllers/MarketingCalendarController.cs
@@ -0,0 +1,171 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
+using TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
+using TakeoutSaaS.Application.App.Stores.Services;
+using TakeoutSaaS.Infrastructure.App.Persistence;
+using TakeoutSaaS.Module.Authorization.Attributes;
+using TakeoutSaaS.Shared.Abstractions.Results;
+using TakeoutSaaS.Shared.Web.Api;
+using TakeoutSaaS.TenantApi.Contracts.Marketing;
+
+namespace TakeoutSaaS.TenantApi.Controllers;
+
+///
+/// 营销中心营销日历。
+///
+[ApiVersion("1.0")]
+[Authorize]
+[Route("api/tenant/v{version:apiVersion}/marketing/calendar")]
+public sealed class MarketingCalendarController(
+ IMediator mediator,
+ TakeoutAppDbContext dbContext,
+ StoreContextService storeContextService)
+ : BaseApiController
+{
+ private const string ViewPermission = "tenant:marketing:calendar:view";
+ private const string ManagePermission = "tenant:marketing:calendar:manage";
+
+ ///
+ /// 获取营销日历总览。
+ ///
+ [HttpGet("overview")]
+ [PermissionAuthorize(ViewPermission, ManagePermission)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ public async Task> Overview(
+ [FromQuery] MarketingCalendarOverviewRequest request,
+ CancellationToken cancellationToken)
+ {
+ var storeId = StoreApiHelpers.ParseRequiredSnowflake(request.StoreId, nameof(request.StoreId));
+ await EnsureStoreAccessibleAsync(storeId, cancellationToken);
+
+ var result = await mediator.Send(new GetMarketingCalendarOverviewQuery
+ {
+ StoreId = storeId,
+ Year = request.Year,
+ Month = request.Month
+ }, cancellationToken);
+
+ return ApiResponse.Ok(MapOverview(result));
+ }
+
+ private async Task EnsureStoreAccessibleAsync(long storeId, CancellationToken cancellationToken)
+ {
+ var (tenantId, merchantId) = StoreApiHelpers.GetTenantMerchantContext(storeContextService);
+ await StoreApiHelpers.EnsureStoreAccessibleAsync(dbContext, tenantId, merchantId, storeId, cancellationToken);
+ }
+
+ private static MarketingCalendarOverviewResponse MapOverview(MarketingCalendarOverviewDto source)
+ {
+ return new MarketingCalendarOverviewResponse
+ {
+ Month = source.Month,
+ Year = source.Year,
+ MonthValue = source.MonthValue,
+ MonthStartDate = StoreApiHelpers.ToDateOnly(source.MonthStartDate),
+ MonthEndDate = StoreApiHelpers.ToDateOnly(source.MonthEndDate),
+ TodayDay = source.TodayDay,
+ Days = source.Days
+ .Select(item => new MarketingCalendarDayResponse
+ {
+ Day = item.Day,
+ IsWeekend = item.IsWeekend,
+ IsToday = item.IsToday
+ })
+ .ToList(),
+ Legends = source.Legends
+ .Select(item => new MarketingCalendarLegendResponse
+ {
+ Type = item.Type,
+ Label = item.Label,
+ Color = item.Color
+ })
+ .ToList(),
+ Stats = new MarketingCalendarStatsResponse
+ {
+ TotalActivityCount = source.Stats.TotalActivityCount,
+ OngoingCount = source.Stats.OngoingCount,
+ MaxConcurrentCount = source.Stats.MaxConcurrentCount,
+ EstimatedDiscountAmount = source.Stats.EstimatedDiscountAmount
+ },
+ ConflictBanner = source.ConflictBanner is null
+ ? null
+ : new MarketingCalendarConflictBannerResponse
+ {
+ ConflictId = source.ConflictBanner.ConflictId,
+ StartDay = source.ConflictBanner.StartDay,
+ EndDay = source.ConflictBanner.EndDay,
+ ActivityCount = source.ConflictBanner.ActivityCount,
+ MaxConcurrentCount = source.ConflictBanner.MaxConcurrentCount,
+ ConflictCount = source.ConflictBanner.ConflictCount
+ },
+ Conflicts = source.Conflicts
+ .Select(MapConflict)
+ .ToList(),
+ Activities = source.Activities
+ .Select(MapActivity)
+ .ToList()
+ };
+ }
+
+ private static MarketingCalendarActivityResponse MapActivity(MarketingCalendarActivityDto source)
+ {
+ return new MarketingCalendarActivityResponse
+ {
+ ActivityId = source.ActivityId,
+ SourceType = source.SourceType,
+ SourceId = source.SourceId,
+ CalendarType = source.CalendarType,
+ Name = source.Name,
+ Color = source.Color,
+ Summary = source.Summary,
+ DisplayStatus = source.DisplayStatus,
+ IsDimmed = source.IsDimmed,
+ StartDate = StoreApiHelpers.ToDateOnly(source.StartDate),
+ EndDate = StoreApiHelpers.ToDateOnly(source.EndDate),
+ EstimatedDiscountAmount = source.EstimatedDiscountAmount,
+ Bars = source.Bars.Select(item => new MarketingCalendarActivityBarResponse
+ {
+ BarId = item.BarId,
+ StartDay = item.StartDay,
+ EndDay = item.EndDay,
+ Label = item.Label,
+ IsMilestone = item.IsMilestone,
+ IsDimmed = item.IsDimmed
+ }).ToList(),
+ Detail = new MarketingCalendarActivityDetailResponse
+ {
+ ModuleName = source.Detail.ModuleName,
+ Description = source.Detail.Description,
+ Fields = source.Detail.Fields.Select(item => new MarketingCalendarDetailFieldResponse
+ {
+ Label = item.Label,
+ Value = item.Value
+ }).ToList()
+ }
+ };
+ }
+
+ private static MarketingCalendarConflictResponse MapConflict(MarketingCalendarConflictDto source)
+ {
+ return new MarketingCalendarConflictResponse
+ {
+ ConflictId = source.ConflictId,
+ StartDay = source.StartDay,
+ EndDay = source.EndDay,
+ ActivityCount = source.ActivityCount,
+ MaxConcurrentCount = source.MaxConcurrentCount,
+ ActivityIds = source.ActivityIds.ToList(),
+ Activities = source.Activities.Select(item => new MarketingCalendarConflictActivityResponse
+ {
+ ActivityId = item.ActivityId,
+ CalendarType = item.CalendarType,
+ Name = item.Name,
+ Summary = item.Summary,
+ Color = item.Color,
+ DisplayStatus = item.DisplayStatus
+ }).ToList()
+ };
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Dto/MarketingCalendarOverviewDto.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Dto/MarketingCalendarOverviewDto.cs
new file mode 100644
index 0000000..014b9d0
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Dto/MarketingCalendarOverviewDto.cs
@@ -0,0 +1,397 @@
+namespace TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
+
+///
+/// 营销日历总览。
+///
+public sealed class MarketingCalendarOverviewDto
+{
+ ///
+ /// 月份标识(yyyy-MM)。
+ ///
+ public string Month { get; init; } = string.Empty;
+
+ ///
+ /// 年份。
+ ///
+ public int Year { get; init; }
+
+ ///
+ /// 月份(1-12)。
+ ///
+ public int MonthValue { get; init; }
+
+ ///
+ /// 月初日期(UTC)。
+ ///
+ public DateTime MonthStartDate { get; init; }
+
+ ///
+ /// 月末日期(UTC)。
+ ///
+ public DateTime MonthEndDate { get; init; }
+
+ ///
+ /// 今天所在日(不在当月则为 0)。
+ ///
+ public int TodayDay { get; init; }
+
+ ///
+ /// 日期头列表。
+ ///
+ public IReadOnlyList Days { get; init; } = [];
+
+ ///
+ /// 图例。
+ ///
+ public IReadOnlyList Legends { get; init; } = [];
+
+ ///
+ /// 顶部统计。
+ ///
+ public MarketingCalendarStatsDto Stats { get; init; } = new();
+
+ ///
+ /// 冲突横幅。
+ ///
+ public MarketingCalendarConflictBannerDto? ConflictBanner { get; init; }
+
+ ///
+ /// 冲突区间。
+ ///
+ public IReadOnlyList Conflicts { get; init; } = [];
+
+ ///
+ /// 活动列表。
+ ///
+ public IReadOnlyList Activities { get; init; } = [];
+}
+
+///
+/// 日期头。
+///
+public sealed class MarketingCalendarDayDto
+{
+ ///
+ /// 日(1-31)。
+ ///
+ public int Day { get; init; }
+
+ ///
+ /// 是否周末。
+ ///
+ public bool IsWeekend { get; init; }
+
+ ///
+ /// 是否今日。
+ ///
+ public bool IsToday { get; init; }
+}
+
+///
+/// 图例。
+///
+public sealed class MarketingCalendarLegendDto
+{
+ ///
+ /// 图例类型。
+ ///
+ public string Type { get; init; } = string.Empty;
+
+ ///
+ /// 图例名称。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 图例颜色。
+ ///
+ public string Color { get; init; } = string.Empty;
+}
+
+///
+/// 顶部统计。
+///
+public sealed class MarketingCalendarStatsDto
+{
+ ///
+ /// 本月活动数。
+ ///
+ public int TotalActivityCount { get; init; }
+
+ ///
+ /// 进行中活动数。
+ ///
+ public int OngoingCount { get; init; }
+
+ ///
+ /// 最大并行活动数。
+ ///
+ public int MaxConcurrentCount { get; init; }
+
+ ///
+ /// 本月预计优惠金额。
+ ///
+ public decimal EstimatedDiscountAmount { get; init; }
+}
+
+///
+/// 活动。
+///
+public sealed class MarketingCalendarActivityDto
+{
+ ///
+ /// 活动唯一键(跨模块)。
+ ///
+ public string ActivityId { get; init; } = string.Empty;
+
+ ///
+ /// 来源模块(full_reduction/flash_sale/seckill/coupon/punch_card)。
+ ///
+ public string SourceType { get; init; } = string.Empty;
+
+ ///
+ /// 来源标识。
+ ///
+ public string SourceId { get; init; } = string.Empty;
+
+ ///
+ /// 日历类型(reduce/gift/second_half/flash_sale/seckill/coupon/punch_card)。
+ ///
+ public string CalendarType { get; init; } = string.Empty;
+
+ ///
+ /// 活动名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 活动颜色。
+ ///
+ public string Color { get; init; } = string.Empty;
+
+ ///
+ /// 活动摘要。
+ ///
+ public string Summary { get; init; } = string.Empty;
+
+ ///
+ /// 展示状态(ongoing/upcoming/ended/disabled)。
+ ///
+ public string DisplayStatus { get; init; } = string.Empty;
+
+ ///
+ /// 是否弱化。
+ ///
+ public bool IsDimmed { get; init; }
+
+ ///
+ /// 活动开始日期(UTC)。
+ ///
+ public DateTime StartDate { get; init; }
+
+ ///
+ /// 活动结束日期(UTC)。
+ ///
+ public DateTime EndDate { get; init; }
+
+ ///
+ /// 预计优惠金额。
+ ///
+ public decimal EstimatedDiscountAmount { get; init; }
+
+ ///
+ /// 活动条。
+ ///
+ public IReadOnlyList Bars { get; init; } = [];
+
+ ///
+ /// 二级抽屉详情。
+ ///
+ public MarketingCalendarActivityDetailDto Detail { get; init; } = new();
+}
+
+///
+/// 活动条。
+///
+public sealed class MarketingCalendarActivityBarDto
+{
+ ///
+ /// 条标识。
+ ///
+ public string BarId { get; init; } = string.Empty;
+
+ ///
+ /// 开始日(1-31)。
+ ///
+ public int StartDay { get; init; }
+
+ ///
+ /// 结束日(1-31)。
+ ///
+ public int EndDay { get; init; }
+
+ ///
+ /// 条文案。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 是否里程碑。
+ ///
+ public bool IsMilestone { get; init; }
+
+ ///
+ /// 是否弱化。
+ ///
+ public bool IsDimmed { get; init; }
+}
+
+///
+/// 活动详情。
+///
+public sealed class MarketingCalendarActivityDetailDto
+{
+ ///
+ /// 模块名称。
+ ///
+ public string ModuleName { get; init; } = string.Empty;
+
+ ///
+ /// 详情描述。
+ ///
+ public string Description { get; init; } = string.Empty;
+
+ ///
+ /// 明细字段。
+ ///
+ public IReadOnlyList Fields { get; init; } = [];
+}
+
+///
+/// 详情字段。
+///
+public sealed class MarketingCalendarDetailFieldDto
+{
+ ///
+ /// 标签。
+ ///
+ public string Label { get; init; } = string.Empty;
+
+ ///
+ /// 值。
+ ///
+ public string Value { get; init; } = string.Empty;
+}
+
+///
+/// 冲突横幅。
+///
+public sealed class MarketingCalendarConflictBannerDto
+{
+ ///
+ /// 冲突标识。
+ ///
+ public string ConflictId { get; init; } = string.Empty;
+
+ ///
+ /// 开始日。
+ ///
+ public int StartDay { get; init; }
+
+ ///
+ /// 结束日。
+ ///
+ public int EndDay { get; init; }
+
+ ///
+ /// 同时进行活动数。
+ ///
+ public int ActivityCount { get; init; }
+
+ ///
+ /// 最大并行活动数。
+ ///
+ public int MaxConcurrentCount { get; init; }
+
+ ///
+ /// 冲突区间数。
+ ///
+ public int ConflictCount { get; init; }
+}
+
+///
+/// 冲突区间。
+///
+public sealed class MarketingCalendarConflictDto
+{
+ ///
+ /// 冲突标识。
+ ///
+ public string ConflictId { get; init; } = string.Empty;
+
+ ///
+ /// 开始日。
+ ///
+ public int StartDay { get; init; }
+
+ ///
+ /// 结束日。
+ ///
+ public int EndDay { get; init; }
+
+ ///
+ /// 同时进行活动数。
+ ///
+ public int ActivityCount { get; init; }
+
+ ///
+ /// 最大并行活动数。
+ ///
+ public int MaxConcurrentCount { get; init; }
+
+ ///
+ /// 活动标识集合。
+ ///
+ public IReadOnlyList ActivityIds { get; init; } = [];
+
+ ///
+ /// 冲突活动摘要。
+ ///
+ public IReadOnlyList Activities { get; init; } = [];
+}
+
+///
+/// 冲突活动摘要。
+///
+public sealed class MarketingCalendarConflictActivityDto
+{
+ ///
+ /// 活动唯一键。
+ ///
+ public string ActivityId { get; init; } = string.Empty;
+
+ ///
+ /// 日历类型。
+ ///
+ public string CalendarType { get; init; } = string.Empty;
+
+ ///
+ /// 活动名称。
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// 活动摘要。
+ ///
+ public string Summary { get; init; } = string.Empty;
+
+ ///
+ /// 活动颜色。
+ ///
+ public string Color { get; init; } = string.Empty;
+
+ ///
+ /// 展示状态。
+ ///
+ public string DisplayStatus { get; init; } = string.Empty;
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Handlers/GetMarketingCalendarOverviewQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Handlers/GetMarketingCalendarOverviewQueryHandler.cs
new file mode 100644
index 0000000..3b29a02
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Handlers/GetMarketingCalendarOverviewQueryHandler.cs
@@ -0,0 +1,1085 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
+using TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
+using TakeoutSaaS.Application.App.Coupons.FlashSale;
+using TakeoutSaaS.Application.App.Coupons.FlashSale.Dto;
+using TakeoutSaaS.Application.App.Coupons.FullReduction;
+using TakeoutSaaS.Application.App.Coupons.FullReduction.Dto;
+using TakeoutSaaS.Application.App.Coupons.PunchCard;
+using TakeoutSaaS.Application.App.Coupons.Seckill;
+using TakeoutSaaS.Application.App.Coupons.Seckill.Dto;
+using TakeoutSaaS.Domain.Coupons.Entities;
+using TakeoutSaaS.Domain.Coupons.Enums;
+using TakeoutSaaS.Domain.Coupons.Repositories;
+using TakeoutSaaS.Shared.Abstractions.Constants;
+using TakeoutSaaS.Shared.Abstractions.Exceptions;
+using TakeoutSaaS.Shared.Abstractions.Tenancy;
+
+namespace TakeoutSaaS.Application.App.Coupons.Calendar.Handlers;
+
+public sealed class GetMarketingCalendarOverviewQueryHandler(
+ IPromotionCampaignRepository promotionCampaignRepository,
+ ICouponRepository couponRepository,
+ IPunchCardRepository punchCardRepository,
+ ITenantProvider tenantProvider)
+ : IRequestHandler
+{
+ private const string SourceTypeCoupon = "coupon";
+ private const string SourceTypeFlashSale = "flash_sale";
+ private const string SourceTypeFullReduction = "full_reduction";
+ private const string SourceTypePunchCard = "punch_card";
+ private const string SourceTypeSeckill = "seckill";
+
+ private const string CalendarTypeCoupon = "coupon";
+ private const string CalendarTypeFlashSale = "flash_sale";
+ private const string CalendarTypeGift = "gift";
+ private const string CalendarTypePunchCard = "punch_card";
+ private const string CalendarTypeReduce = "reduce";
+ private const string CalendarTypeSecondHalf = "second_half";
+ private const string CalendarTypeSeckill = "seckill";
+
+ private const string DisplayStatusDisabled = "disabled";
+ private const string DisplayStatusEnded = "ended";
+ private const string DisplayStatusOngoing = "ongoing";
+ private const string DisplayStatusUpcoming = "upcoming";
+
+ public async Task Handle(
+ GetMarketingCalendarOverviewQuery request,
+ CancellationToken cancellationToken)
+ {
+ if (request.Year is < 2000 or > 2100)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "year 参数不合法");
+ }
+
+ if (request.Month is < 1 or > 12)
+ {
+ throw new BusinessException(ErrorCodes.BadRequest, "month 参数不合法");
+ }
+
+ var tenantId = tenantProvider.GetCurrentTenantId();
+ var monthStart = new DateTime(request.Year, request.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ var monthEnd = monthStart.AddMonths(1).AddDays(-1);
+ var daysInMonth = DateTime.DaysInMonth(request.Year, request.Month);
+ var nowUtc = DateTime.UtcNow;
+
+ var activities = new List(64);
+ activities.AddRange(await BuildFullReductionAsync(tenantId, request.StoreId, monthStart, monthEnd, daysInMonth, nowUtc, cancellationToken));
+ activities.AddRange(await BuildFlashSaleAsync(tenantId, request.StoreId, monthStart, monthEnd, daysInMonth, nowUtc, cancellationToken));
+ activities.AddRange(await BuildSeckillAsync(tenantId, request.StoreId, monthStart, monthEnd, daysInMonth, nowUtc, cancellationToken));
+ activities.AddRange(await BuildCouponAsync(tenantId, request.StoreId, monthStart, monthEnd, daysInMonth, nowUtc, cancellationToken));
+ activities.AddRange(await BuildPunchCardAsync(tenantId, request.StoreId, monthStart, monthEnd, daysInMonth, nowUtc, cancellationToken));
+
+ var visible = activities
+ .Where(item => item.CoveredDays.Count > 0)
+ .OrderBy(item => item.TypeOrder)
+ .ThenBy(item => item.Activity.StartDate)
+ .ThenBy(item => item.Activity.Name, StringComparer.Ordinal)
+ .ThenBy(item => item.Activity.ActivityId, StringComparer.Ordinal)
+ .ToList();
+
+ var activityMap = visible.ToDictionary(item => item.Activity.ActivityId, item => item.Activity);
+ var dayBuckets = BuildDayBuckets(daysInMonth, visible);
+ var conflicts = BuildConflicts(dayBuckets, activityMap);
+ var banner = BuildConflictBanner(conflicts);
+
+ var estimatedDiscountAmount = decimal.Round(
+ visible.Sum(item => item.Activity.EstimatedDiscountAmount),
+ 2,
+ MidpointRounding.AwayFromZero);
+
+ return new MarketingCalendarOverviewDto
+ {
+ Month = $"{request.Year:D4}-{request.Month:D2}",
+ Year = request.Year,
+ MonthValue = request.Month,
+ MonthStartDate = monthStart,
+ MonthEndDate = monthEnd,
+ TodayDay = nowUtc.Year == request.Year && nowUtc.Month == request.Month ? nowUtc.Day : 0,
+ Days = BuildMonthDays(monthStart, daysInMonth, nowUtc),
+ Legends = BuildLegends(),
+ Stats = new MarketingCalendarStatsDto
+ {
+ TotalActivityCount = visible.Count,
+ OngoingCount = visible.Count(item => string.Equals(item.Activity.DisplayStatus, DisplayStatusOngoing, StringComparison.Ordinal)),
+ MaxConcurrentCount = dayBuckets.Count == 0 ? 0 : dayBuckets.Values.Max(item => item.Count),
+ EstimatedDiscountAmount = estimatedDiscountAmount
+ },
+ ConflictBanner = banner,
+ Conflicts = conflicts,
+ Activities = visible.Select(item => item.Activity).ToList()
+ };
+ }
+
+ private async Task> BuildFullReductionAsync(
+ long tenantId,
+ long storeId,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(tenantId, PromotionType.FullReduction, cancellationToken);
+ if (campaigns.Count == 0)
+ {
+ return [];
+ }
+
+ var results = new List(campaigns.Count);
+ foreach (var campaign in campaigns)
+ {
+ var rules = FullReductionMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
+ if (!FullReductionMapping.HasVisibleStore(rules, [storeId]))
+ {
+ continue;
+ }
+
+ var calendarType = rules.ActivityType switch
+ {
+ "reduce" => CalendarTypeReduce,
+ "gift" => CalendarTypeGift,
+ "second_half" => CalendarTypeSecondHalf,
+ _ => throw new BusinessException(ErrorCodes.BadRequest, "满减活动类型数据不合法")
+ };
+
+ var displayStatus = FullReductionMapping.ResolveDisplayStatus(campaign, nowUtc);
+ var summary = BuildFullReductionSummary(rules);
+ var isDimmed = FullReductionMapping.IsDimmed(displayStatus);
+ var activityId = $"{SourceTypeFullReduction}:{campaign.Id}";
+ var (bars, coveredDays) = BuildRangeBars(
+ activityId,
+ NormalizeUtcDate(campaign.StartAt),
+ NormalizeUtcDate(campaign.EndAt),
+ monthStart,
+ monthEnd,
+ daysInMonth,
+ summary,
+ isDimmed);
+
+ if (coveredDays.Count == 0)
+ {
+ continue;
+ }
+
+ var estimate = decimal.Round(Math.Max(0m, rules.Metrics.DiscountTotalAmount), 2, MidpointRounding.AwayFromZero);
+ results.Add(CreateAggregate(
+ ResolveTypeOrder(calendarType),
+ coveredDays,
+ new MarketingCalendarActivityDto
+ {
+ ActivityId = activityId,
+ SourceType = SourceTypeFullReduction,
+ SourceId = campaign.Id.ToString(),
+ CalendarType = calendarType,
+ Name = campaign.Name,
+ Color = ResolveColor(calendarType),
+ Summary = summary,
+ DisplayStatus = displayStatus,
+ IsDimmed = isDimmed,
+ StartDate = NormalizeUtcDate(campaign.StartAt),
+ EndDate = NormalizeUtcDate(campaign.EndAt),
+ EstimatedDiscountAmount = estimate,
+ Bars = bars,
+ Detail = new MarketingCalendarActivityDetailDto
+ {
+ ModuleName = "满减活动",
+ Description = "按规则触发减免优惠。",
+ Fields =
+ [
+ new MarketingCalendarDetailFieldDto { Label = "活动类型", Value = ResolveCalendarTypeLabel(calendarType) },
+ new MarketingCalendarDetailFieldDto { Label = "活动时间", Value = $"{NormalizeUtcDate(campaign.StartAt):yyyy-MM-dd} ~ {NormalizeUtcDate(campaign.EndAt):yyyy-MM-dd}" },
+ new MarketingCalendarDetailFieldDto { Label = "适用门店数", Value = rules.StoreIds.Count.ToString() },
+ new MarketingCalendarDetailFieldDto { Label = "预计优惠", Value = FormatCurrency(estimate) }
+ ]
+ }
+ }));
+ }
+
+ return results;
+ }
+
+ private async Task> BuildFlashSaleAsync(
+ long tenantId,
+ long storeId,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(tenantId, PromotionType.FlashSale, cancellationToken);
+ if (campaigns.Count == 0)
+ {
+ return [];
+ }
+
+ var results = new List(campaigns.Count);
+ foreach (var campaign in campaigns)
+ {
+ var rules = FlashSaleMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
+ if (!FlashSaleMapping.HasVisibleStore(rules, [storeId]))
+ {
+ continue;
+ }
+
+ var displayStatus = FlashSaleMapping.ResolveDisplayStatus(campaign, rules, nowUtc);
+ var isDimmed = FlashSaleMapping.IsDimmed(displayStatus);
+ var summary = BuildFlashSaleSummary(rules);
+ var activityId = $"{SourceTypeFlashSale}:{campaign.Id}";
+ var bars = new List();
+ var coveredDays = new HashSet();
+ if (string.Equals(rules.CycleType, "recurring", StringComparison.Ordinal) && rules.WeekDays.Count > 0)
+ {
+ var startDate = NormalizeUtcDate(campaign.StartAt);
+ var endDate = NormalizeUtcDate(campaign.EndAt);
+ for (var day = 1; day <= daysInMonth; day++)
+ {
+ var currentDate = monthStart.AddDays(day - 1);
+ if (currentDate < startDate || currentDate > endDate)
+ {
+ continue;
+ }
+
+ if (!rules.WeekDays.Contains(ToWeekDayValue(currentDate.DayOfWeek)))
+ {
+ continue;
+ }
+
+ bars.Add(new MarketingCalendarActivityBarDto
+ {
+ BarId = $"{activityId}:bar:{bars.Count + 1}",
+ StartDay = day,
+ EndDay = day,
+ Label = string.Empty,
+ IsMilestone = true,
+ IsDimmed = isDimmed
+ });
+ coveredDays.Add(day);
+ }
+ }
+ else
+ {
+ var range = BuildRangeBars(
+ activityId,
+ NormalizeUtcDate(campaign.StartAt),
+ NormalizeUtcDate(campaign.EndAt),
+ monthStart,
+ monthEnd,
+ daysInMonth,
+ summary,
+ isDimmed);
+ bars.AddRange(range.Bars);
+ coveredDays.UnionWith(range.CoveredDays);
+ }
+
+ if (coveredDays.Count == 0)
+ {
+ continue;
+ }
+
+ var estimate = decimal.Round(Math.Max(0m, rules.Metrics.DiscountTotalAmount), 2, MidpointRounding.AwayFromZero);
+ results.Add(CreateAggregate(
+ ResolveTypeOrder(CalendarTypeFlashSale),
+ coveredDays,
+ new MarketingCalendarActivityDto
+ {
+ ActivityId = activityId,
+ SourceType = SourceTypeFlashSale,
+ SourceId = campaign.Id.ToString(),
+ CalendarType = CalendarTypeFlashSale,
+ Name = campaign.Name,
+ Color = ResolveColor(CalendarTypeFlashSale),
+ Summary = summary,
+ DisplayStatus = displayStatus,
+ IsDimmed = isDimmed,
+ StartDate = NormalizeUtcDate(campaign.StartAt),
+ EndDate = NormalizeUtcDate(campaign.EndAt),
+ EstimatedDiscountAmount = estimate,
+ Bars = bars,
+ Detail = new MarketingCalendarActivityDetailDto
+ {
+ ModuleName = "限时折扣",
+ Description = "按时间段对指定商品执行折扣。",
+ Fields =
+ [
+ new MarketingCalendarDetailFieldDto { Label = "活动周期", Value = string.Equals(rules.CycleType, "recurring", StringComparison.Ordinal) ? "循环活动" : "单次活动" },
+ new MarketingCalendarDetailFieldDto { Label = "活动日期", Value = $"{NormalizeUtcDate(campaign.StartAt):yyyy-MM-dd} ~ {NormalizeUtcDate(campaign.EndAt):yyyy-MM-dd}" },
+ new MarketingCalendarDetailFieldDto { Label = "参与商品数", Value = rules.Products.Count.ToString() },
+ new MarketingCalendarDetailFieldDto { Label = "预计优惠", Value = FormatCurrency(estimate) }
+ ]
+ }
+ }));
+ }
+
+ return results;
+ }
+
+ private async Task> BuildSeckillAsync(
+ long tenantId,
+ long storeId,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ var campaigns = await promotionCampaignRepository.GetByPromotionTypeAsync(tenantId, PromotionType.Seckill, cancellationToken);
+ if (campaigns.Count == 0)
+ {
+ return [];
+ }
+
+ var results = new List(campaigns.Count);
+ foreach (var campaign in campaigns)
+ {
+ var rules = SeckillMapping.DeserializeRules(campaign.RulesJson, campaign.Id);
+ if (!SeckillMapping.HasVisibleStore(rules, [storeId]))
+ {
+ continue;
+ }
+
+ var displayStatus = SeckillMapping.ResolveDisplayStatus(campaign, rules, nowUtc);
+ var isDimmed = SeckillMapping.IsDimmed(displayStatus);
+ var summary = BuildSeckillSummary(rules);
+ var estimate = ComputeSeckillEstimatedDiscount(rules.Products);
+ var activityId = $"{SourceTypeSeckill}:{campaign.Id}";
+ var (bars, coveredDays) = BuildRangeBars(
+ activityId,
+ NormalizeUtcDate(campaign.StartAt),
+ NormalizeUtcDate(campaign.EndAt),
+ monthStart,
+ monthEnd,
+ daysInMonth,
+ summary,
+ isDimmed);
+
+ if (coveredDays.Count == 0)
+ {
+ continue;
+ }
+
+ results.Add(CreateAggregate(
+ ResolveTypeOrder(CalendarTypeSeckill),
+ coveredDays,
+ new MarketingCalendarActivityDto
+ {
+ ActivityId = activityId,
+ SourceType = SourceTypeSeckill,
+ SourceId = campaign.Id.ToString(),
+ CalendarType = CalendarTypeSeckill,
+ Name = campaign.Name,
+ Color = ResolveColor(CalendarTypeSeckill),
+ Summary = summary,
+ DisplayStatus = displayStatus,
+ IsDimmed = isDimmed,
+ StartDate = NormalizeUtcDate(campaign.StartAt),
+ EndDate = NormalizeUtcDate(campaign.EndAt),
+ EstimatedDiscountAmount = estimate,
+ Bars = bars,
+ Detail = new MarketingCalendarActivityDetailDto
+ {
+ ModuleName = "秒杀活动",
+ Description = "按场次执行限量秒杀。",
+ Fields =
+ [
+ new MarketingCalendarDetailFieldDto { Label = "活动类型", Value = string.Equals(rules.ActivityType, "hourly", StringComparison.Ordinal) ? "整点秒杀" : "限时秒杀" },
+ new MarketingCalendarDetailFieldDto { Label = "活动日期", Value = $"{NormalizeUtcDate(campaign.StartAt):yyyy-MM-dd} ~ {NormalizeUtcDate(campaign.EndAt):yyyy-MM-dd}" },
+ new MarketingCalendarDetailFieldDto { Label = "参与商品数", Value = rules.Products.Count.ToString() },
+ new MarketingCalendarDetailFieldDto { Label = "预计优惠", Value = FormatCurrency(estimate) }
+ ]
+ }
+ }));
+ }
+
+ return results;
+ }
+
+ private async Task> BuildCouponAsync(
+ long tenantId,
+ long storeId,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ var templates = await couponRepository.GetTemplatesAsync(tenantId, cancellationToken);
+ if (templates.Count == 0)
+ {
+ return [];
+ }
+
+ var results = new List(templates.Count);
+ foreach (var template in templates)
+ {
+ var storeScope = CouponTemplateMapping.DeserializeStoreScope(template.StoreScopeJson, template.Id);
+ if (!storeScope.StoreIds.Contains(storeId))
+ {
+ continue;
+ }
+
+ var channels = CouponTemplateMapping.DeserializeChannels(template.ChannelsJson, template.Id);
+ var displayStatus = CouponTemplateMapping.ResolveDisplayStatus(template, nowUtc);
+ var isDimmed = CouponTemplateMapping.IsDimmedDisplayStatus(displayStatus);
+ var summary = BuildCouponSummary(template);
+ var estimate = ComputeCouponEstimatedDiscount(template);
+ var activityId = $"{SourceTypeCoupon}:{template.Id}";
+
+ DateTime startDate;
+ DateTime endDate;
+ if (template.ValidFrom.HasValue && template.ValidTo.HasValue)
+ {
+ startDate = NormalizeUtcDate(template.ValidFrom.Value);
+ endDate = NormalizeUtcDate(template.ValidTo.Value);
+ }
+ else
+ {
+ startDate = monthStart;
+ endDate = monthEnd;
+ }
+
+ var (bars, coveredDays) = BuildRangeBars(
+ activityId,
+ startDate,
+ endDate,
+ monthStart,
+ monthEnd,
+ daysInMonth,
+ summary,
+ isDimmed);
+ if (coveredDays.Count == 0)
+ {
+ continue;
+ }
+
+ results.Add(CreateAggregate(
+ ResolveTypeOrder(CalendarTypeCoupon),
+ coveredDays,
+ new MarketingCalendarActivityDto
+ {
+ ActivityId = activityId,
+ SourceType = SourceTypeCoupon,
+ SourceId = template.Id.ToString(),
+ CalendarType = CalendarTypeCoupon,
+ Name = template.Name,
+ Color = ResolveColor(CalendarTypeCoupon),
+ Summary = summary,
+ DisplayStatus = displayStatus,
+ IsDimmed = isDimmed,
+ StartDate = startDate,
+ EndDate = endDate,
+ EstimatedDiscountAmount = estimate,
+ Bars = bars,
+ Detail = new MarketingCalendarActivityDetailDto
+ {
+ ModuleName = "优惠券",
+ Description = "按券模板规则领取与核销。",
+ Fields =
+ [
+ new MarketingCalendarDetailFieldDto { Label = "券类型", Value = ResolveCouponTypeLabel(template.CouponType) },
+ new MarketingCalendarDetailFieldDto { Label = "券规则", Value = summary },
+ new MarketingCalendarDetailFieldDto { Label = "有效期", Value = ResolveCouponValidityText(template) },
+ new MarketingCalendarDetailFieldDto { Label = "渠道", Value = FormatChannels(channels) },
+ new MarketingCalendarDetailFieldDto { Label = "预计优惠", Value = FormatCurrency(estimate) }
+ ]
+ }
+ }));
+ }
+
+ return results;
+ }
+
+ private async Task> BuildPunchCardAsync(
+ long tenantId,
+ long storeId,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ DateTime nowUtc,
+ CancellationToken cancellationToken)
+ {
+ var templates = await punchCardRepository.GetTemplatesByStoreAsync(tenantId, storeId, cancellationToken);
+ if (templates.Count == 0)
+ {
+ return [];
+ }
+
+ var templateIds = templates.Select(item => item.Id).ToList();
+ var aggregateMap = await punchCardRepository.GetTemplateAggregateByTemplateIdsAsync(
+ tenantId,
+ storeId,
+ templateIds,
+ cancellationToken);
+
+ var results = new List(templates.Count);
+ foreach (var template in templates)
+ {
+ var soldCount = aggregateMap.TryGetValue(template.Id, out var aggregate) ? aggregate.SoldCount : 0;
+ var activeCount = aggregateMap.TryGetValue(template.Id, out var activeAggregate) ? activeAggregate.ActiveCount : 0;
+ var displayStatus = ResolvePunchCardDisplayStatus(template, nowUtc);
+ var isDimmed = string.Equals(displayStatus, DisplayStatusDisabled, StringComparison.Ordinal)
+ || string.Equals(displayStatus, DisplayStatusEnded, StringComparison.Ordinal);
+ var summary = BuildPunchCardSummary(template);
+ var estimate = ComputePunchCardEstimatedDiscount(template, soldCount);
+ var activityId = $"{SourceTypePunchCard}:{template.Id}";
+
+ DateTime startDate;
+ DateTime endDate;
+ if (template.ValidityType == PunchCardValidityType.DateRange && template.ValidFrom.HasValue && template.ValidTo.HasValue)
+ {
+ startDate = NormalizeUtcDate(template.ValidFrom.Value);
+ endDate = NormalizeUtcDate(template.ValidTo.Value);
+ }
+ else
+ {
+ startDate = monthStart;
+ endDate = monthEnd;
+ }
+
+ var (bars, coveredDays) = BuildRangeBars(
+ activityId,
+ startDate,
+ endDate,
+ monthStart,
+ monthEnd,
+ daysInMonth,
+ summary,
+ isDimmed);
+ if (coveredDays.Count == 0)
+ {
+ continue;
+ }
+
+ results.Add(CreateAggregate(
+ ResolveTypeOrder(CalendarTypePunchCard),
+ coveredDays,
+ new MarketingCalendarActivityDto
+ {
+ ActivityId = activityId,
+ SourceType = SourceTypePunchCard,
+ SourceId = template.Id.ToString(),
+ CalendarType = CalendarTypePunchCard,
+ Name = template.Name,
+ Color = ResolveColor(CalendarTypePunchCard),
+ Summary = summary,
+ DisplayStatus = displayStatus,
+ IsDimmed = isDimmed,
+ StartDate = startDate,
+ EndDate = endDate,
+ EstimatedDiscountAmount = estimate,
+ Bars = bars,
+ Detail = new MarketingCalendarActivityDetailDto
+ {
+ ModuleName = "次卡管理",
+ Description = "次卡模板上架后可持续售卖和核销。",
+ Fields =
+ [
+ new MarketingCalendarDetailFieldDto { Label = "售价", Value = FormatCurrency(template.SalePrice) },
+ new MarketingCalendarDetailFieldDto { Label = "总次数", Value = $"{template.TotalTimes}次" },
+ new MarketingCalendarDetailFieldDto { Label = "有效期", Value = PunchCardMapping.BuildValiditySummary(template) },
+ new MarketingCalendarDetailFieldDto { Label = "已售", Value = soldCount.ToString() },
+ new MarketingCalendarDetailFieldDto { Label = "使用中", Value = activeCount.ToString() }
+ ]
+ }
+ }));
+ }
+
+ return results;
+ }
+
+ private static Dictionary> BuildDayBuckets(
+ int daysInMonth,
+ IReadOnlyCollection activities)
+ {
+ var buckets = new Dictionary>(daysInMonth);
+ for (var day = 1; day <= daysInMonth; day++)
+ {
+ buckets[day] = [];
+ }
+
+ foreach (var activity in activities)
+ {
+ foreach (var day in activity.CoveredDays)
+ {
+ if (day is >= 1 and <= 31 && buckets.ContainsKey(day))
+ {
+ buckets[day].Add(activity.Activity.ActivityId);
+ }
+ }
+ }
+
+ return buckets;
+ }
+
+ private static IReadOnlyList BuildConflicts(
+ IReadOnlyDictionary> dayBuckets,
+ IReadOnlyDictionary activityMap)
+ {
+ if (dayBuckets.Count == 0)
+ {
+ return [];
+ }
+
+ var maxDay = dayBuckets.Keys.Max();
+ var results = new List();
+ var day = 1;
+ while (day <= maxDay)
+ {
+ if (!dayBuckets.TryGetValue(day, out var daily) || daily.Count < 2)
+ {
+ day++;
+ continue;
+ }
+
+ var startDay = day;
+ var maxConcurrent = 0;
+ var activityIds = new HashSet(StringComparer.Ordinal);
+ while (day <= maxDay &&
+ dayBuckets.TryGetValue(day, out var current) &&
+ current.Count >= 2)
+ {
+ maxConcurrent = Math.Max(maxConcurrent, current.Count);
+ foreach (var activityId in current)
+ {
+ activityIds.Add(activityId);
+ }
+
+ day++;
+ }
+
+ var endDay = day - 1;
+ var sortedIds = activityIds.OrderBy(item => item, StringComparer.Ordinal).ToList();
+ var summaries = sortedIds
+ .Where(activityMap.ContainsKey)
+ .Select(item => activityMap[item])
+ .Select(item => new MarketingCalendarConflictActivityDto
+ {
+ ActivityId = item.ActivityId,
+ CalendarType = item.CalendarType,
+ Name = item.Name,
+ Summary = item.Summary,
+ Color = item.Color,
+ DisplayStatus = item.DisplayStatus
+ })
+ .ToList();
+
+ results.Add(new MarketingCalendarConflictDto
+ {
+ ConflictId = $"conflict:{startDay:D2}-{endDay:D2}",
+ StartDay = startDay,
+ EndDay = endDay,
+ ActivityCount = maxConcurrent,
+ MaxConcurrentCount = maxConcurrent,
+ ActivityIds = sortedIds,
+ Activities = summaries
+ });
+ }
+
+ return results;
+ }
+
+ private static MarketingCalendarConflictBannerDto? BuildConflictBanner(IReadOnlyList conflicts)
+ {
+ if (conflicts.Count == 0)
+ {
+ return null;
+ }
+
+ var selected = conflicts
+ .OrderByDescending(item => item.MaxConcurrentCount)
+ .ThenByDescending(item => item.EndDay - item.StartDay + 1)
+ .ThenBy(item => item.StartDay)
+ .First();
+
+ return new MarketingCalendarConflictBannerDto
+ {
+ ConflictId = selected.ConflictId,
+ StartDay = selected.StartDay,
+ EndDay = selected.EndDay,
+ ActivityCount = selected.ActivityCount,
+ MaxConcurrentCount = selected.MaxConcurrentCount,
+ ConflictCount = conflicts.Count
+ };
+ }
+
+ private static IReadOnlyList BuildMonthDays(DateTime monthStart, int daysInMonth, DateTime nowUtc)
+ {
+ var result = new List(daysInMonth);
+ var isCurrentMonth = monthStart.Year == nowUtc.Year && monthStart.Month == nowUtc.Month;
+ for (var day = 1; day <= daysInMonth; day++)
+ {
+ var date = monthStart.AddDays(day - 1);
+ result.Add(new MarketingCalendarDayDto
+ {
+ Day = day,
+ IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
+ IsToday = isCurrentMonth && day == nowUtc.Day
+ });
+ }
+
+ return result;
+ }
+
+ private static IReadOnlyList BuildLegends()
+ {
+ return
+ [
+ new MarketingCalendarLegendDto { Type = CalendarTypeReduce, Label = "满减", Color = ResolveColor(CalendarTypeReduce) },
+ new MarketingCalendarLegendDto { Type = CalendarTypeGift, Label = "满赠", Color = ResolveColor(CalendarTypeGift) },
+ new MarketingCalendarLegendDto { Type = CalendarTypeSecondHalf, Label = "第二份半价", Color = ResolveColor(CalendarTypeSecondHalf) },
+ new MarketingCalendarLegendDto { Type = CalendarTypeFlashSale, Label = "限时折扣", Color = ResolveColor(CalendarTypeFlashSale) },
+ new MarketingCalendarLegendDto { Type = CalendarTypeSeckill, Label = "秒杀", Color = ResolveColor(CalendarTypeSeckill) },
+ new MarketingCalendarLegendDto { Type = CalendarTypeCoupon, Label = "优惠券", Color = ResolveColor(CalendarTypeCoupon) },
+ new MarketingCalendarLegendDto { Type = CalendarTypePunchCard, Label = "次卡", Color = ResolveColor(CalendarTypePunchCard) }
+ ];
+ }
+
+ private static (IReadOnlyList Bars, HashSet CoveredDays) BuildRangeBars(
+ string activityId,
+ DateTime startDate,
+ DateTime endDate,
+ DateTime monthStart,
+ DateTime monthEnd,
+ int daysInMonth,
+ string label,
+ bool isDimmed)
+ {
+ if (!TryClipRange(startDate, endDate, monthStart, monthEnd, out var startDay, out var endDay))
+ {
+ return ([], []);
+ }
+
+ var bars = new List(1)
+ {
+ new()
+ {
+ BarId = $"{activityId}:bar:1",
+ StartDay = startDay,
+ EndDay = endDay,
+ Label = label,
+ IsMilestone = false,
+ IsDimmed = isDimmed
+ }
+ };
+
+ var coveredDays = Enumerable.Range(startDay, Math.Max(1, endDay - startDay + 1))
+ .Where(day => day <= daysInMonth)
+ .ToHashSet();
+
+ return (bars, coveredDays);
+ }
+
+ private static bool TryClipRange(DateTime startDate, DateTime endDate, DateTime monthStart, DateTime monthEnd, out int startDay, out int endDay)
+ {
+ var normalizedStart = NormalizeUtcDate(startDate);
+ var normalizedEnd = NormalizeUtcDate(endDate);
+ if (normalizedStart > normalizedEnd)
+ {
+ (normalizedStart, normalizedEnd) = (normalizedEnd, normalizedStart);
+ }
+
+ var clippedStart = normalizedStart < monthStart ? monthStart : normalizedStart;
+ var clippedEnd = normalizedEnd > monthEnd ? monthEnd : normalizedEnd;
+ if (clippedStart > clippedEnd)
+ {
+ startDay = 0;
+ endDay = 0;
+ return false;
+ }
+
+ startDay = clippedStart.Day;
+ endDay = clippedEnd.Day;
+ return true;
+ }
+
+ private static DateTime NormalizeUtcDate(DateTime value)
+ {
+ return value.Kind switch
+ {
+ DateTimeKind.Utc => value.Date,
+ DateTimeKind.Local => value.ToUniversalTime().Date,
+ _ => DateTime.SpecifyKind(value.Date, DateTimeKind.Utc)
+ };
+ }
+
+ private static CalendarActivityAggregate CreateAggregate(
+ int typeOrder,
+ HashSet coveredDays,
+ MarketingCalendarActivityDto activity)
+ {
+ return new CalendarActivityAggregate
+ {
+ TypeOrder = typeOrder,
+ CoveredDays = coveredDays,
+ Activity = activity
+ };
+ }
+
+ private static int ResolveTypeOrder(string calendarType)
+ {
+ return calendarType switch
+ {
+ CalendarTypeReduce => 10,
+ CalendarTypeGift => 20,
+ CalendarTypeSecondHalf => 30,
+ CalendarTypeFlashSale => 40,
+ CalendarTypeSeckill => 50,
+ CalendarTypeCoupon => 60,
+ CalendarTypePunchCard => 70,
+ _ => 999
+ };
+ }
+
+ private static string ResolveColor(string calendarType)
+ {
+ return calendarType switch
+ {
+ CalendarTypeReduce => "#3b82f6",
+ CalendarTypeGift => "#22c55e",
+ CalendarTypeSecondHalf => "#f59e0b",
+ CalendarTypeFlashSale => "#8b5cf6",
+ CalendarTypeSeckill => "#ef4444",
+ CalendarTypeCoupon => "#ec4899",
+ CalendarTypePunchCard => "#06b6d4",
+ _ => "#64748b"
+ };
+ }
+
+ private static string ResolveCalendarTypeLabel(string calendarType)
+ {
+ return calendarType switch
+ {
+ CalendarTypeReduce => "满减",
+ CalendarTypeGift => "满赠",
+ CalendarTypeSecondHalf => "第二份半价",
+ CalendarTypeFlashSale => "限时折扣",
+ CalendarTypeSeckill => "秒杀",
+ CalendarTypeCoupon => "优惠券",
+ CalendarTypePunchCard => "次卡",
+ _ => "活动"
+ };
+ }
+
+ private static string ResolveCouponTypeLabel(CouponType couponType)
+ {
+ return couponType switch
+ {
+ CouponType.AmountOff => "满减券",
+ CouponType.Percentage => "折扣券",
+ CouponType.Cash => "现金券",
+ CouponType.DeliveryFee => "免配送费券",
+ CouponType.Gift => "礼品券",
+ _ => "优惠券"
+ };
+ }
+
+ private static string ResolveCouponValidityText(CouponTemplate template)
+ {
+ if (template.ValidFrom.HasValue && template.ValidTo.HasValue)
+ {
+ return $"{NormalizeUtcDate(template.ValidFrom.Value):yyyy-MM-dd} ~ {NormalizeUtcDate(template.ValidTo.Value):yyyy-MM-dd}";
+ }
+
+ if (template.RelativeValidDays.HasValue && template.RelativeValidDays.Value > 0)
+ {
+ return $"领取后{template.RelativeValidDays.Value}天内有效";
+ }
+
+ return "长期有效";
+ }
+
+ private static string BuildFullReductionSummary(FullReductionRulesDto rules)
+ {
+ return rules.ActivityType switch
+ {
+ "reduce" => string.Join(" / ", rules.ReduceTiers.OrderBy(item => item.MeetAmount).Take(3).Select(item => $"满{item.MeetAmount:0.##}减{item.ReduceAmount:0.##}")),
+ "gift" when rules.GiftRule is not null => $"买{rules.GiftRule.BuyQuantity}赠{rules.GiftRule.GiftQuantity}",
+ "second_half" when rules.SecondHalfRule is not null => rules.SecondHalfRule.DiscountType switch
+ {
+ "half" => "第2份5折",
+ "sixty" => "第2份6折",
+ "seventy" => "第2份7折",
+ "free" => "第2份免单",
+ _ => "第二份半价"
+ },
+ _ => "满减活动"
+ };
+ }
+
+ private static string BuildFlashSaleSummary(FlashSaleRulesDto rules)
+ {
+ if (string.Equals(rules.CycleType, "recurring", StringComparison.Ordinal) && rules.WeekDays.Count > 0)
+ {
+ return $"每周{string.Join("、", rules.WeekDays.OrderBy(item => item).Select(ToWeekDayText))}限时折扣";
+ }
+
+ if (rules.Products.Count > 0)
+ {
+ return rules.Products.Count == 1
+ ? $"{rules.Products[0].Name}限时折扣"
+ : $"{rules.Products[0].Name}等{rules.Products.Count}款限时折扣";
+ }
+
+ return "限时折扣";
+ }
+
+ private static string BuildSeckillSummary(SeckillRulesDto rules)
+ {
+ if (rules.Products.Count > 0)
+ {
+ return $"¥{rules.Products[0].SeckillPrice:0.##} {rules.Products[0].Name}";
+ }
+
+ return string.Equals(rules.ActivityType, "hourly", StringComparison.Ordinal) ? "整点秒杀" : "秒杀活动";
+ }
+
+ private static string BuildCouponSummary(CouponTemplate template)
+ {
+ var prefix = template.MinimumSpend.HasValue && template.MinimumSpend.Value > 0
+ ? $"满{template.MinimumSpend.Value:0.##}"
+ : string.Empty;
+
+ return template.CouponType switch
+ {
+ CouponType.AmountOff => string.IsNullOrWhiteSpace(prefix) ? $"减{template.Value:0.##}" : $"{prefix}减{template.Value:0.##}",
+ CouponType.Cash => string.IsNullOrWhiteSpace(prefix) ? $"减{template.Value:0.##}" : $"{prefix}减{template.Value:0.##}",
+ CouponType.Percentage => $"{template.Value:0.##}折优惠",
+ CouponType.DeliveryFee => "免配送费",
+ CouponType.Gift => "礼品兑换券",
+ _ => "优惠券"
+ };
+ }
+
+ private static string BuildPunchCardSummary(PunchCardTemplate template)
+ {
+ var baseText = $"¥{template.SalePrice:0.##} / {template.TotalTimes}次";
+ return template.ValidityType == PunchCardValidityType.Days
+ ? $"{baseText}(长期在售)"
+ : baseText;
+ }
+
+ private static decimal ComputeSeckillEstimatedDiscount(IReadOnlyList products)
+ {
+ var total = products.Sum(item =>
+ {
+ var diff = item.OriginalPrice - item.SeckillPrice;
+ return diff > 0 && item.SoldCount > 0 ? diff * item.SoldCount : 0m;
+ });
+
+ return decimal.Round(total, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal ComputeCouponEstimatedDiscount(CouponTemplate template)
+ {
+ if (template.ClaimedQuantity <= 0)
+ {
+ return 0m;
+ }
+
+ var couponAmount = template.CouponType switch
+ {
+ CouponType.AmountOff => Math.Max(0m, template.Value),
+ CouponType.Cash => Math.Max(0m, template.Value),
+ CouponType.DeliveryFee => Math.Max(0m, template.Value),
+ _ => 0m
+ };
+
+ return decimal.Round(couponAmount * template.ClaimedQuantity, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static decimal ComputePunchCardEstimatedDiscount(PunchCardTemplate template, int soldCount)
+ {
+ if (!template.OriginalPrice.HasValue || soldCount <= 0)
+ {
+ return 0m;
+ }
+
+ var diff = template.OriginalPrice.Value - template.SalePrice;
+ return diff <= 0 ? 0m : decimal.Round(diff * soldCount, 2, MidpointRounding.AwayFromZero);
+ }
+
+ private static string ResolvePunchCardDisplayStatus(PunchCardTemplate template, DateTime nowUtc)
+ {
+ if (template.Status == PunchCardStatus.Disabled)
+ {
+ return DisplayStatusDisabled;
+ }
+
+ if (template.ValidityType == PunchCardValidityType.DateRange && template.ValidFrom.HasValue && template.ValidTo.HasValue)
+ {
+ var today = NormalizeUtcDate(nowUtc);
+ var validFrom = NormalizeUtcDate(template.ValidFrom.Value);
+ var validTo = NormalizeUtcDate(template.ValidTo.Value);
+ if (today < validFrom)
+ {
+ return DisplayStatusUpcoming;
+ }
+
+ if (today > validTo)
+ {
+ return DisplayStatusEnded;
+ }
+ }
+
+ return DisplayStatusOngoing;
+ }
+
+ private static int ToWeekDayValue(DayOfWeek dayOfWeek)
+ {
+ return dayOfWeek switch
+ {
+ DayOfWeek.Monday => 1,
+ DayOfWeek.Tuesday => 2,
+ DayOfWeek.Wednesday => 3,
+ DayOfWeek.Thursday => 4,
+ DayOfWeek.Friday => 5,
+ DayOfWeek.Saturday => 6,
+ DayOfWeek.Sunday => 7,
+ _ => 0
+ };
+ }
+
+ private static string ToWeekDayText(int value)
+ {
+ return value switch
+ {
+ 1 => "周一",
+ 2 => "周二",
+ 3 => "周三",
+ 4 => "周四",
+ 5 => "周五",
+ 6 => "周六",
+ 7 => "周日",
+ _ => "-"
+ };
+ }
+
+ private static string FormatChannels(IReadOnlyCollection channels)
+ {
+ if (channels.Count == 0)
+ {
+ return "-";
+ }
+
+ return string.Join("、", channels.Select(item => item switch
+ {
+ "delivery" => "外卖",
+ "pickup" => "自提",
+ "dine_in" => "堂食",
+ _ => item
+ }));
+ }
+
+ private static string FormatCurrency(decimal value)
+ {
+ return $"¥{value:0.##}";
+ }
+
+ private sealed class CalendarActivityAggregate
+ {
+ public required int TypeOrder { get; init; }
+ public required HashSet CoveredDays { get; init; }
+ public required MarketingCalendarActivityDto Activity { get; init; }
+ }
+}
diff --git a/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Queries/GetMarketingCalendarOverviewQuery.cs b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Queries/GetMarketingCalendarOverviewQuery.cs
new file mode 100644
index 0000000..a7218e7
--- /dev/null
+++ b/src/Application/TakeoutSaaS.Application/App/Coupons/Calendar/Queries/GetMarketingCalendarOverviewQuery.cs
@@ -0,0 +1,25 @@
+using MediatR;
+using TakeoutSaaS.Application.App.Coupons.Calendar.Dto;
+
+namespace TakeoutSaaS.Application.App.Coupons.Calendar.Queries;
+
+///
+/// 查询营销日历总览。
+///
+public sealed class GetMarketingCalendarOverviewQuery : IRequest
+{
+ ///
+ /// 门店 ID。
+ ///
+ public long StoreId { get; init; }
+
+ ///
+ /// 年份。
+ ///
+ public int Year { get; init; }
+
+ ///
+ /// 月份(1-12)。
+ ///
+ public int Month { get; init; }
+}
diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPunchCardRepository.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPunchCardRepository.cs
index fcda2d3..6091ed8 100644
--- a/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPunchCardRepository.cs
+++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Repositories/IPunchCardRepository.cs
@@ -38,6 +38,14 @@ public interface IPunchCardRepository
IReadOnlyCollection templateIds,
CancellationToken cancellationToken = default);
+ ///
+ /// 查询单门店全部次卡模板。
+ ///
+ Task> GetTemplatesByStoreAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default);
+
///
/// 按模板批量统计售卖与在用信息。
///
diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPunchCardRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPunchCardRepository.cs
index 8735ff5..97c7ae1 100644
--- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPunchCardRepository.cs
+++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPunchCardRepository.cs
@@ -92,6 +92,20 @@ public sealed class EfPunchCardRepository(TakeoutAppDbContext context) : IPunchC
.ToListAsync(cancellationToken);
}
+ ///
+ public async Task> GetTemplatesByStoreAsync(
+ long tenantId,
+ long storeId,
+ CancellationToken cancellationToken = default)
+ {
+ return await context.PunchCardTemplates
+ .AsNoTracking()
+ .Where(item => item.TenantId == tenantId && item.StoreId == storeId)
+ .OrderByDescending(item => item.UpdatedAt ?? item.CreatedAt)
+ .ThenByDescending(item => item.Id)
+ .ToListAsync(cancellationToken);
+ }
+
///
public async Task> GetTemplateAggregateByTemplateIdsAsync(
long tenantId,