diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs index 03c5630..a7327d2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -62,6 +62,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro /// /// 套餐 ID。 /// 关键词(可选)。 + /// 可选:未来 N 天内到期筛选。 /// 页码(从 1 开始)。 /// 每页大小。 /// 取消标记。 @@ -72,6 +73,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro public async Task>> Tenants( long tenantPackageId, [FromQuery] string? keyword, + [FromQuery] int? expiringWithinDays, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) @@ -81,6 +83,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro { TenantPackageId = tenantPackageId, Keyword = keyword, + ExpiringWithinDays = expiringWithinDays, Page = page, PageSize = pageSize }, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs index c613598..cc3f951 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageUsageDto.cs @@ -34,4 +34,19 @@ public sealed class TenantPackageUsageDto /// ARR(Annual Recurring Revenue)粗看:按“当前有效订阅数 × 套餐年付等效价”估算。 /// public decimal Arr { get; init; } + + /// + /// 未来 7 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。 + /// + public int ExpiringTenantCount7Days { get; init; } + + /// + /// 未来 15 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。 + /// + public int ExpiringTenantCount15Days { get; init; } + + /// + /// 未来 30 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。 + /// + public int ExpiringTenantCount30Days { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs index 95b730b..997a0ed 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageTenantsQueryHandler.cs @@ -26,6 +26,8 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx // 2. (空行后) 以当前时间为准筛选“有效订阅” var now = DateTime.UtcNow; + var expiringDays = request.ExpiringWithinDays is > 0 ? request.ExpiringWithinDays : null; + var expiryEnd = expiringDays.HasValue ? now.AddDays(expiringDays.Value) : (DateTime?)null; var offset = (page - 1) * pageSize; // 3. (空行后) 查询总数 + 列表 @@ -41,18 +43,20 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx [ ("packageId", request.TenantPackageId), ("now", now), + ("expiryEnd", expiryEnd), ("keyword", keyword) ], token); // 3.2 (空行后) 查询列表 - var listSql = BuildListSql(); + var listSql = BuildListSql(expiryEnd.HasValue); await using var listCommand = CreateCommand( connection, listSql, [ ("packageId", request.TenantPackageId), ("now", now), + ("expiryEnd", expiryEnd), ("keyword", keyword), ("offset", offset), ("limit", pageSize) @@ -103,12 +107,59 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx and s."Status" = 1 and s."EffectiveFrom" <= @now and s."EffectiveTo" >= @now + and ( + @expiryEnd::timestamp with time zone is null + or s."EffectiveTo" <= @expiryEnd + ) ); """; } - private static string BuildListSql() + private static string BuildListSql(bool orderByExpiryAsc) { + if (orderByExpiryAsc) + { + return """ + select + t."Id" as "TenantId", + t."Code", + t."Name", + t."Status", + t."ContactName", + t."ContactPhone", + s."EffectiveFrom", + s."EffectiveTo" + from public.tenants t + join lateral ( + select s."EffectiveFrom", s."EffectiveTo" + from public.tenant_subscriptions s + where s."DeletedAt" is null + and s."TenantId" = t."Id" + and s."TenantPackageId" = @packageId + and s."Status" = 1 + and s."EffectiveFrom" <= @now + and s."EffectiveTo" >= @now + and ( + @expiryEnd::timestamp with time zone is null + or s."EffectiveTo" <= @expiryEnd + ) + order by s."EffectiveTo" asc + limit 1 + ) s on true + where t."DeletedAt" is null + and ( + @keyword::text is null + or t."Name" ilike ('%' || @keyword::text || '%') + or t."Code" ilike ('%' || @keyword::text || '%') + or coalesce(t."ContactName", '') ilike ('%' || @keyword::text || '%') + or coalesce(t."ContactPhone", '') ilike ('%' || @keyword::text || '%') + ) + order by s."EffectiveTo" asc + offset @offset + limit @limit; + """; + } + return """ select t."Id" as "TenantId", @@ -129,6 +180,10 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx and s."Status" = 1 and s."EffectiveFrom" <= @now and s."EffectiveTo" >= @now + and ( + @expiryEnd::timestamp with time zone is null + or s."EffectiveTo" <= @expiryEnd + ) order by s."EffectiveTo" desc limit 1 ) s on true diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs index 0eb2750..cf91d7f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageUsagesQueryHandler.cs @@ -23,9 +23,12 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe .Distinct() .ToArray(); - // 2. (空行后) 构造 SQL(以当前时间为准统计“有效订阅/使用租户”) + // 2. (空行后) 构造 SQL(以当前时间为准统计“有效订阅/使用租户/到期分布”) var now = DateTime.UtcNow; - var sql = BuildSql(ids, out var parameters, now); + var date7 = now.AddDays(7); + var date15 = now.AddDays(15); + var date30 = now.AddDays(30); + var sql = BuildSql(ids, out var parameters, now, date7, date15, date30); // 3. (空行后) 查询统计结果 return await dapperExecutor.QueryAsync( @@ -47,7 +50,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe ActiveTenantCount = reader.GetInt32(2), TotalSubscriptionCount = reader.GetInt32(3), Mrr = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), - Arr = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5) + Arr = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5), + ExpiringTenantCount7Days = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + ExpiringTenantCount15Days = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + ExpiringTenantCount30Days = reader.IsDBNull(8) ? 0 : reader.GetInt32(8) }); } @@ -56,7 +62,13 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe cancellationToken); } - private static string BuildSql(long[]? ids, out (string Name, object? Value)[] parameters, DateTime now) + private static string BuildSql( + long[]? ids, + out (string Name, object? Value)[] parameters, + DateTime now, + DateTime date7, + DateTime date15, + DateTime date30) { // 1. 基础查询:先按订阅表聚合,再回连套餐表计算 MRR/ARR var builder = new System.Text.StringBuilder(); @@ -66,6 +78,9 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe "TenantPackageId" as "TenantPackageId", count(*) filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveSubscriptionCount", count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveTenantCount", + count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date7) as "ExpiringTenantCount7Days", + count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date15) as "ExpiringTenantCount15Days", + count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date30) as "ExpiringTenantCount30Days", count(*) as "TotalSubscriptionCount" from public.tenant_subscriptions where "DeletedAt" is null @@ -73,7 +88,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe var list = new List<(string Name, object? Value)> { - ("now", now) + ("now", now), + ("date7", date7), + ("date15", date15), + ("date30", date30) }; // 2. (空行后) 可选按套餐 ID 过滤 @@ -105,7 +123,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe s."ActiveTenantCount" as "ActiveTenantCount", s."TotalSubscriptionCount" as "TotalSubscriptionCount", (s."ActiveSubscriptionCount"::numeric * coalesce(p."MonthlyPrice", (p."YearlyPrice" / 12.0), 0))::numeric(18, 2) as "Mrr", - (s."ActiveSubscriptionCount"::numeric * coalesce(p."YearlyPrice", (p."MonthlyPrice" * 12), 0))::numeric(18, 2) as "Arr" + (s."ActiveSubscriptionCount"::numeric * coalesce(p."YearlyPrice", (p."MonthlyPrice" * 12), 0))::numeric(18, 2) as "Arr", + s."ExpiringTenantCount7Days" as "ExpiringTenantCount7Days", + s."ExpiringTenantCount15Days" as "ExpiringTenantCount15Days", + s."ExpiringTenantCount30Days" as "ExpiringTenantCount30Days" from stats s left join public.tenant_packages p on p."Id" = s."TenantPackageId" and p."DeletedAt" is null; """); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs index b16615b..3e05511 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageTenantsQuery.cs @@ -28,5 +28,9 @@ public sealed record GetTenantPackageTenantsQuery : IRequest public int PageSize { get; init; } = 20; -} + /// + /// 可选:未来 N 天内到期筛选(按“当前有效订阅”口径,且到期时间在 now ~ now+N 天内)。 + /// + public int? ExpiringWithinDays { get; init; } +}