feat: 套餐到期分布与到期租户筛选

This commit is contained in:
2025-12-15 21:46:20 +08:00
parent 7ff66cd8e7
commit 2ed814fbe7
5 changed files with 107 additions and 9 deletions

View File

@@ -62,6 +62,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
/// </summary> /// </summary>
/// <param name="tenantPackageId">套餐 ID。</param> /// <param name="tenantPackageId">套餐 ID。</param>
/// <param name="keyword">关键词(可选)。</param> /// <param name="keyword">关键词(可选)。</param>
/// <param name="expiringWithinDays">可选:未来 N 天内到期筛选。</param>
/// <param name="page">页码(从 1 开始)。</param> /// <param name="page">页码(从 1 开始)。</param>
/// <param name="pageSize">每页大小。</param> /// <param name="pageSize">每页大小。</param>
/// <param name="cancellationToken">取消标记。</param> /// <param name="cancellationToken">取消标记。</param>
@@ -72,6 +73,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
public async Task<ApiResponse<PagedResult<TenantPackageTenantDto>>> Tenants( public async Task<ApiResponse<PagedResult<TenantPackageTenantDto>>> Tenants(
long tenantPackageId, long tenantPackageId,
[FromQuery] string? keyword, [FromQuery] string? keyword,
[FromQuery] int? expiringWithinDays,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -81,6 +83,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
{ {
TenantPackageId = tenantPackageId, TenantPackageId = tenantPackageId,
Keyword = keyword, Keyword = keyword,
ExpiringWithinDays = expiringWithinDays,
Page = page, Page = page,
PageSize = pageSize PageSize = pageSize
}, cancellationToken); }, cancellationToken);

View File

@@ -34,4 +34,19 @@ public sealed class TenantPackageUsageDto
/// ARRAnnual Recurring Revenue粗看按“当前有效订阅数 × 套餐年付等效价”估算。 /// ARRAnnual Recurring Revenue粗看按“当前有效订阅数 × 套餐年付等效价”估算。
/// </summary> /// </summary>
public decimal Arr { get; init; } public decimal Arr { get; init; }
/// <summary>
/// 未来 7 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount7Days { get; init; }
/// <summary>
/// 未来 15 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount15Days { get; init; }
/// <summary>
/// 未来 30 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount30Days { get; init; }
} }

View File

@@ -26,6 +26,8 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
// 2. (空行后) 以当前时间为准筛选“有效订阅” // 2. (空行后) 以当前时间为准筛选“有效订阅”
var now = DateTime.UtcNow; 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; var offset = (page - 1) * pageSize;
// 3. (空行后) 查询总数 + 列表 // 3. (空行后) 查询总数 + 列表
@@ -41,18 +43,20 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
[ [
("packageId", request.TenantPackageId), ("packageId", request.TenantPackageId),
("now", now), ("now", now),
("expiryEnd", expiryEnd),
("keyword", keyword) ("keyword", keyword)
], ],
token); token);
// 3.2 (空行后) 查询列表 // 3.2 (空行后) 查询列表
var listSql = BuildListSql(); var listSql = BuildListSql(expiryEnd.HasValue);
await using var listCommand = CreateCommand( await using var listCommand = CreateCommand(
connection, connection,
listSql, listSql,
[ [
("packageId", request.TenantPackageId), ("packageId", request.TenantPackageId),
("now", now), ("now", now),
("expiryEnd", expiryEnd),
("keyword", keyword), ("keyword", keyword),
("offset", offset), ("offset", offset),
("limit", pageSize) ("limit", pageSize)
@@ -103,12 +107,59 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
and s."Status" = 1 and s."Status" = 1
and s."EffectiveFrom" <= @now and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @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 """ return """
select select
t."Id" as "TenantId", t."Id" as "TenantId",
@@ -129,6 +180,10 @@ public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperEx
and s."Status" = 1 and s."Status" = 1
and s."EffectiveFrom" <= @now and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now and s."EffectiveTo" >= @now
and (
@expiryEnd::timestamp with time zone is null
or s."EffectiveTo" <= @expiryEnd
)
order by s."EffectiveTo" desc order by s."EffectiveTo" desc
limit 1 limit 1
) s on true ) s on true

View File

@@ -23,9 +23,12 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
.Distinct() .Distinct()
.ToArray(); .ToArray();
// 2. (空行后) 构造 SQL以当前时间为准统计“有效订阅/使用租户”) // 2. (空行后) 构造 SQL以当前时间为准统计“有效订阅/使用租户/到期分布”)
var now = DateTime.UtcNow; 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. (空行后) 查询统计结果 // 3. (空行后) 查询统计结果
return await dapperExecutor.QueryAsync( return await dapperExecutor.QueryAsync(
@@ -47,7 +50,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
ActiveTenantCount = reader.GetInt32(2), ActiveTenantCount = reader.GetInt32(2),
TotalSubscriptionCount = reader.GetInt32(3), TotalSubscriptionCount = reader.GetInt32(3),
Mrr = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), 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); 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 // 1. 基础查询:先按订阅表聚合,再回连套餐表计算 MRR/ARR
var builder = new System.Text.StringBuilder(); var builder = new System.Text.StringBuilder();
@@ -66,6 +78,9 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
"TenantPackageId" as "TenantPackageId", "TenantPackageId" as "TenantPackageId",
count(*) filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveSubscriptionCount", 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) 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" count(*) as "TotalSubscriptionCount"
from public.tenant_subscriptions from public.tenant_subscriptions
where "DeletedAt" is null where "DeletedAt" is null
@@ -73,7 +88,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
var list = new List<(string Name, object? Value)> var list = new List<(string Name, object? Value)>
{ {
("now", now) ("now", now),
("date7", date7),
("date15", date15),
("date30", date30)
}; };
// 2. (空行后) 可选按套餐 ID 过滤 // 2. (空行后) 可选按套餐 ID 过滤
@@ -105,7 +123,10 @@ public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExe
s."ActiveTenantCount" as "ActiveTenantCount", s."ActiveTenantCount" as "ActiveTenantCount",
s."TotalSubscriptionCount" as "TotalSubscriptionCount", 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."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 from stats s
left join public.tenant_packages p on p."Id" = s."TenantPackageId" and p."DeletedAt" is null; left join public.tenant_packages p on p."Id" = s."TenantPackageId" and p."DeletedAt" is null;
"""); """);

View File

@@ -28,5 +28,9 @@ public sealed record GetTenantPackageTenantsQuery : IRequest<PagedResult<TenantP
/// 每页大小。 /// 每页大小。
/// </summary> /// </summary>
public int PageSize { get; init; } = 20; public int PageSize { get; init; } = 20;
}
/// <summary>
/// 可选:未来 N 天内到期筛选(按“当前有效订阅”口径,且到期时间在 now ~ now+N 天内)。
/// </summary>
public int? ExpiringWithinDays { get; init; }
}