feat: 套餐到期分布与到期租户筛选
This commit is contained in:
@@ -62,6 +62,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
|
||||
/// </summary>
|
||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
||||
/// <param name="keyword">关键词(可选)。</param>
|
||||
/// <param name="expiringWithinDays">可选:未来 N 天内到期筛选。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
@@ -72,6 +73,7 @@ public sealed class TenantPackagesController(IMediator mediator) : BaseApiContro
|
||||
public async Task<ApiResponse<PagedResult<TenantPackageTenantDto>>> 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);
|
||||
|
||||
@@ -34,4 +34,19 @@ public sealed class TenantPackageUsageDto
|
||||
/// ARR(Annual Recurring Revenue)粗看:按“当前有效订阅数 × 套餐年付等效价”估算。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
""");
|
||||
|
||||
@@ -28,5 +28,9 @@ public sealed record GetTenantPackageTenantsQuery : IRequest<PagedResult<TenantP
|
||||
/// 每页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可选:未来 N 天内到期筛选(按“当前有效订阅”口径,且到期时间在 now ~ now+N 天内)。
|
||||
/// </summary>
|
||||
public int? ExpiringWithinDays { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user