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,