feat: 套餐到期分布与到期租户筛选
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -34,4 +34,19 @@ public sealed class TenantPackageUsageDto
|
|||||||
/// ARR(Annual Recurring Revenue)粗看:按“当前有效订阅数 × 套餐年付等效价”估算。
|
/// ARR(Annual 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
""");
|
""");
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user