fix: 统一逐租户上下文执行
This commit is contained in:
@@ -9,6 +9,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
@@ -38,10 +39,11 @@ public sealed class AppDataSeeder(
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var appDbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var dictionaryDbContext = scope.ServiceProvider.GetRequiredService<DictionaryDbContext>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
await EnsureSystemTenantAsync(appDbContext, cancellationToken);
|
||||
var defaultTenantId = await EnsureDefaultTenantAsync(appDbContext, cancellationToken);
|
||||
await EnsureDictionarySeedsAsync(dictionaryDbContext, defaultTenantId, cancellationToken);
|
||||
await EnsureDictionarySeedsAsync(dictionaryDbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
|
||||
logger.LogInformation("AppSeed 完成业务数据初始化");
|
||||
}
|
||||
@@ -54,6 +56,7 @@ public sealed class AppDataSeeder(
|
||||
/// </summary>
|
||||
private async Task<long?> EnsureDefaultTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
using var _ = dbContext.DisableSoftDeleteFilter();
|
||||
var tenantOptions = _options.DefaultTenant;
|
||||
if (tenantOptions == null || string.IsNullOrWhiteSpace(tenantOptions.Code) || string.IsNullOrWhiteSpace(tenantOptions.Name))
|
||||
{
|
||||
@@ -63,7 +66,6 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var code = tenantOptions.Code.Trim();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (existingTenant == null)
|
||||
@@ -87,6 +89,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existingTenant.DeletedAt.HasValue)
|
||||
{
|
||||
existingTenant.DeletedAt = null;
|
||||
existingTenant.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.Name, tenantOptions.Name, StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.Name = tenantOptions.Name.Trim();
|
||||
@@ -136,14 +145,21 @@ public sealed class AppDataSeeder(
|
||||
/// </summary>
|
||||
private async Task EnsureSystemTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
using var _ = dbContext.DisableSoftDeleteFilter();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == 0, cancellationToken);
|
||||
|
||||
if (existingTenant != null)
|
||||
{
|
||||
// 1. (空行后) 若历史数据仍为 PLATFORM,则自动修正为 SYSTEM
|
||||
var updated = false;
|
||||
|
||||
if (existingTenant.DeletedAt.HasValue)
|
||||
{
|
||||
existingTenant.DeletedAt = null;
|
||||
existingTenant.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
if (!string.Equals(existingTenant.Code, "SYSTEM", StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.Code = "SYSTEM";
|
||||
@@ -188,7 +204,11 @@ public sealed class AppDataSeeder(
|
||||
/// <summary>
|
||||
/// 确保基础字典存在。
|
||||
/// </summary>
|
||||
private async Task EnsureDictionarySeedsAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
|
||||
private async Task EnsureDictionarySeedsAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dictionaryGroups = _options.DictionaryGroups ?? new List<DictionarySeedGroupOptions>();
|
||||
var hasDictionaryGroups = dictionaryGroups.Count > 0;
|
||||
@@ -211,9 +231,10 @@ public sealed class AppDataSeeder(
|
||||
var tenantId = groupOptions.TenantId ?? defaultTenantId ?? 0;
|
||||
var code = groupOptions.Code.Trim();
|
||||
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var group = await dbContext.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Code == code, cancellationToken);
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
@@ -235,6 +256,13 @@ public sealed class AppDataSeeder(
|
||||
{
|
||||
var groupUpdated = false;
|
||||
|
||||
if (group.DeletedAt.HasValue)
|
||||
{
|
||||
group.DeletedAt = null;
|
||||
group.DeletedBy = null;
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(group.Name, groupOptions.Name, StringComparison.Ordinal))
|
||||
{
|
||||
group.Name = groupOptions.Name.Trim();
|
||||
@@ -269,7 +297,7 @@ public sealed class AppDataSeeder(
|
||||
}
|
||||
}
|
||||
|
||||
await EnsureSystemParametersAsync(dbContext, defaultTenantId, cancellationToken);
|
||||
await EnsureSystemParametersAsync(dbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -277,7 +305,11 @@ public sealed class AppDataSeeder(
|
||||
/// <summary>
|
||||
/// 确保系统参数以独立表形式可重复种子。
|
||||
/// </summary>
|
||||
private async Task EnsureSystemParametersAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
|
||||
private async Task EnsureSystemParametersAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemParameters = _options.SystemParameters ?? new List<SystemParameterSeedOptions>();
|
||||
|
||||
@@ -300,9 +332,9 @@ public sealed class AppDataSeeder(
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var existingParameters = await dbContext.SystemParameters
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var seed in group)
|
||||
@@ -329,6 +361,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existing.DeletedAt.HasValue)
|
||||
{
|
||||
existing.DeletedAt = null;
|
||||
existing.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Value = seed.Value.Trim();
|
||||
@@ -387,7 +426,6 @@ public sealed class AppDataSeeder(
|
||||
}
|
||||
|
||||
var existingItems = await dbContext.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.GroupId == group.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -416,6 +454,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existing.DeletedAt.HasValue)
|
||||
{
|
||||
existing.DeletedAt = null;
|
||||
existing.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Value = seed.Value.Trim();
|
||||
|
||||
@@ -3,14 +3,25 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户账单仓储实现(EF Core)。
|
||||
/// </summary>
|
||||
public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository
|
||||
public sealed class TenantBillingRepository(TakeoutAppDbContext context, ITenantContextAccessor tenantContextAccessor) : ITenantBillingRepository
|
||||
{
|
||||
private long GetCurrentTenantId()
|
||||
=> tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
|
||||
private Task<List<long>> GetActiveTenantIdsAsync(CancellationToken cancellationToken)
|
||||
=> context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
|
||||
long tenantId,
|
||||
@@ -19,9 +30,8 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
DateTime? to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除
|
||||
// 1. 构建基础查询:在当前租户上下文内查询
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
|
||||
|
||||
@@ -52,7 +62,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
|
||||
}
|
||||
@@ -63,7 +72,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
@@ -73,10 +81,11 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
|
||||
return GetCurrentTenantId() == 0
|
||||
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
|
||||
: context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -86,7 +95,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.DeletedAt == null
|
||||
@@ -101,16 +109,45 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
// 1. 以当前 UTC 时间作为逾期判断基准
|
||||
var now = DateTime.UtcNow;
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 2. (空行后) 当前租户:仅查询本租户逾期账单
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue)
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
// 2. (空行后) 系统上下文:逐租户查询逾期账单并合并
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -119,24 +156,53 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
// 1. 计算到期窗口
|
||||
var now = DateTime.UtcNow;
|
||||
var dueTo = now.AddDays(daysAhead);
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 2. (空行后) 当前租户:仅查询本租户即将到期账单
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 仅查询待支付账单
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
// 2. (空行后) 系统上下文:逐租户查询即将到期账单并合并
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
@@ -151,13 +217,41 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
// 1. 忽略全局过滤器以支持系统任务跨租户导出/批量操作
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && billingIds.Contains(x.Id))
|
||||
// 1. 系统上下文:逐租户查找匹配账单;租户上下文:仅返回本租户账单
|
||||
var ids = billingIds.Distinct().ToArray();
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
|
||||
.OrderByDescending(x => x.PeriodStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(x => x.PeriodStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -192,74 +286,100 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(系统任务跨租户查询,忽略过滤器)
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
|
||||
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
|
||||
var takePerTenant = normalizedPageNumber * normalizedPageSize;
|
||||
|
||||
// 2. 按租户过滤(可选)
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 1. 当前租户:仅查询本租户数据(tenantId 为空则默认当前租户)
|
||||
var effectiveTenantId = tenantId ?? currentTenantId;
|
||||
var query = BuildTenantQuery(effectiveTenantId, status, from, to, minAmount, maxAmount, keyword);
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
// 2. (空行后) 系统上下文:可按指定 tenantId 查询;tenantId 为空则跨租户聚合分页
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == tenantId.Value);
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId.Value, "billing"))
|
||||
{
|
||||
var query = BuildTenantQuery(tenantId.Value, status, from, to, minAmount, maxAmount, keyword);
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按状态过滤(可选)
|
||||
if (status.HasValue)
|
||||
// 3. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
|
||||
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
// 4. 按日期范围过滤(账单周期)
|
||||
if (from.HasValue)
|
||||
HashSet<long>? keywordMatchedTenantIds = null;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
var matched = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%"))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
keywordMatchedTenantIds = matched.Count == 0 ? null : matched.ToHashSet();
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
var totalCount = 0;
|
||||
var collected = new List<TenantBillingStatement>();
|
||||
foreach (var tid in tenantIds)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
|
||||
{
|
||||
var tenantQuery = BuildTenantQuery(tid, status, from, to, minAmount, maxAmount, keyword: null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
// 3.1 租户名命中时视为匹配全部账单,否则按账单号筛选
|
||||
var tenantNameMatched = keywordMatchedTenantIds is not null && keywordMatchedTenantIds.Contains(tid);
|
||||
if (!tenantNameMatched)
|
||||
{
|
||||
tenantQuery = tenantQuery.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
}
|
||||
|
||||
totalCount += await tenantQuery.CountAsync(cancellationToken);
|
||||
|
||||
var topItems = await tenantQuery
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Take(takePerTenant)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
collected.AddRange(topItems);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 按金额范围过滤(应付金额,包含边界)
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue >= minAmount.Value);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue <= maxAmount.Value);
|
||||
}
|
||||
|
||||
// 6. 关键字过滤(账单号或租户名)
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
|
||||
query =
|
||||
from b in query
|
||||
join t in context.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null)
|
||||
on b.TenantId equals t.Id
|
||||
where EF.Functions.ILike(b.StatementNo, $"%{normalized}%")
|
||||
|| EF.Functions.ILike(t.Name, $"%{normalized}%")
|
||||
select b;
|
||||
}
|
||||
|
||||
// 7. 统计总数
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 8. 分页查询
|
||||
var items = await query
|
||||
var pageItems = collected
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToList();
|
||||
|
||||
return (items, total);
|
||||
return (pageItems, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -270,45 +390,90 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
string groupBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围)
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& (!tenantId.HasValue || x.TenantId == tenantId.Value)
|
||||
&& x.PeriodStart >= startDate
|
||||
&& x.PeriodEnd <= endDate);
|
||||
|
||||
// 2. 聚合统计(金额统一使用:应付 - 折扣 + 税费)
|
||||
// 1. 统一时间基准与分组方式
|
||||
var now = DateTime.UtcNow;
|
||||
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
|
||||
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
|
||||
var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
|
||||
var overdueAmount = await query
|
||||
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
|
||||
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
|
||||
|
||||
// 3. 数量统计
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
|
||||
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
|
||||
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
|
||||
|
||||
// 4. 趋势统计
|
||||
var normalizedGroupBy = NormalizeGroupBy(groupBy);
|
||||
var trendRaw = await query
|
||||
.Select(x => new
|
||||
{
|
||||
x.PeriodStart,
|
||||
x.AmountDue,
|
||||
x.DiscountAmount,
|
||||
x.TaxAmount,
|
||||
x.AmountPaid
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
|
||||
// 4.1 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
|
||||
var trend = trendRaw
|
||||
// 2. (空行后) 构造待统计租户列表
|
||||
List<long> targetTenantIds;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
targetTenantIds = [tenantId ?? currentTenantId];
|
||||
}
|
||||
else if (tenantId.HasValue)
|
||||
{
|
||||
targetTenantIds = [tenantId.Value];
|
||||
}
|
||||
else
|
||||
{
|
||||
targetTenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (targetTenantIds.Count == 0)
|
||||
{
|
||||
return new TenantBillingStatistics();
|
||||
}
|
||||
|
||||
// 3. (空行后) 拉取统计字段(逐租户上下文执行)
|
||||
var rows = new List<BillingStatisticsRow>();
|
||||
foreach (var tid in targetTenantIds)
|
||||
{
|
||||
if (currentTenantId == 0)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
|
||||
{
|
||||
var tenantRows = await BuildStatisticsQuery(startDate, endDate)
|
||||
.Select(x => new BillingStatisticsRow
|
||||
{
|
||||
PeriodStart = x.PeriodStart,
|
||||
AmountDue = x.AmountDue,
|
||||
DiscountAmount = x.DiscountAmount,
|
||||
TaxAmount = x.TaxAmount,
|
||||
AmountPaid = x.AmountPaid,
|
||||
Status = x.Status,
|
||||
DueDate = x.DueDate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
rows.AddRange(tenantRows);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var tenantRowsDirect = await BuildStatisticsQuery(startDate, endDate)
|
||||
.Where(x => x.TenantId == tid)
|
||||
.Select(x => new BillingStatisticsRow
|
||||
{
|
||||
PeriodStart = x.PeriodStart,
|
||||
AmountDue = x.AmountDue,
|
||||
DiscountAmount = x.DiscountAmount,
|
||||
TaxAmount = x.TaxAmount,
|
||||
AmountPaid = x.AmountPaid,
|
||||
Status = x.Status,
|
||||
DueDate = x.DueDate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
rows.AddRange(tenantRowsDirect);
|
||||
}
|
||||
|
||||
// 4. (空行后) 汇总统计
|
||||
var totalAmount = rows.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount);
|
||||
var paidAmount = rows.Where(x => x.Status == TenantBillingStatus.Paid).Sum(x => x.AmountPaid);
|
||||
var unpaidAmount = rows.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
|
||||
var overdueAmount = rows
|
||||
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
|
||||
.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
|
||||
|
||||
var totalCount = rows.Count;
|
||||
var paidCount = rows.Count(x => x.Status == TenantBillingStatus.Paid);
|
||||
var unpaidCount = rows.Count(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue);
|
||||
var overdueCount = rows.Count(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now);
|
||||
|
||||
// 5. (空行后) 趋势统计
|
||||
var trend = rows
|
||||
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
|
||||
.Select(g => new TenantBillingTrendDataPoint
|
||||
{
|
||||
@@ -337,10 +502,122 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
return GetCurrentTenantId() == 0
|
||||
? FindByIdCrossTenantAsync(billingId, cancellationToken)
|
||||
: context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
}
|
||||
|
||||
private IQueryable<TenantBillingStatement> BuildTenantQuery(
|
||||
long tenantId,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
decimal? minAmount,
|
||||
decimal? maxAmount,
|
||||
string? keyword)
|
||||
{
|
||||
var query = context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
}
|
||||
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue >= minAmount.Value);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue <= maxAmount.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<TenantBillingStatement> BuildStatisticsQuery(DateTime startDate, DateTime endDate)
|
||||
=> context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.PeriodStart >= startDate
|
||||
&& x.PeriodEnd <= endDate);
|
||||
|
||||
private async Task<TenantBillingStatement?> FindByIdCrossTenantAsync(long billingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var billing = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
if (billing != null)
|
||||
{
|
||||
return billing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<TenantBillingStatement?> GetByStatementNoCrossTenantAsync(string statementNo, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var billing = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == statementNo, cancellationToken);
|
||||
if (billing != null)
|
||||
{
|
||||
return billing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BillingStatisticsRow
|
||||
{
|
||||
public required DateTime PeriodStart { get; init; }
|
||||
|
||||
public required decimal AmountDue { get; init; }
|
||||
|
||||
public required decimal DiscountAmount { get; init; }
|
||||
|
||||
public required decimal TaxAmount { get; init; }
|
||||
|
||||
public required decimal AmountPaid { get; init; }
|
||||
|
||||
public required TenantBillingStatus Status { get; init; }
|
||||
|
||||
public required DateTime DueDate { get; init; }
|
||||
}
|
||||
|
||||
private static string NormalizeGroupBy(string groupBy)
|
||||
|
||||
@@ -15,7 +15,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
@@ -27,7 +26,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
// 1. 仅统计支付成功的记录
|
||||
return await context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.BillingStatementId == billingStatementId
|
||||
@@ -39,7 +37,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
|
||||
}
|
||||
@@ -50,7 +47,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
var normalized = transactionNo.Trim();
|
||||
|
||||
return context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.MerchantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
@@ -266,7 +265,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = logsContext.MerchantChangeLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaPackagePurchases
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
|
||||
@@ -129,7 +128,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaUsages
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
@@ -148,7 +146,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
|
||||
}
|
||||
@@ -224,7 +223,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
|
||||
// 2. 批量查询实名资料
|
||||
return await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -235,7 +233,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
{
|
||||
// 1. 查询现有实名资料
|
||||
var existing = await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
@@ -254,7 +251,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
@@ -274,7 +270,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
|
||||
// 2. 批量查询订阅数据
|
||||
return await context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
@@ -285,7 +280,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
|
||||
cancellationToken);
|
||||
@@ -314,7 +308,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantSubscriptionHistories
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveFrom)
|
||||
@@ -331,7 +324,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.TenantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
|
||||
@@ -5,6 +5,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
@@ -13,6 +14,8 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
/// </summary>
|
||||
public sealed class BillingDomainService(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IIdGenerator idGenerator) : IBillingDomainService
|
||||
{
|
||||
@@ -147,15 +150,43 @@ public sealed class BillingDomainService(
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
|
||||
var processedAt = DateTime.UtcNow;
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
|
||||
}
|
||||
|
||||
// 1. (空行后) 系统上下文:逐租户处理,避免跨租户写入
|
||||
var tenants = await tenantRepository.SearchAsync(null, null, cancellationToken);
|
||||
var targets = tenants.Where(x => x.Id > 0).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in targets)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "billing:overdue", tenant.Code))
|
||||
{
|
||||
totalUpdated += await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> ProcessOverdueBillingsSingleTenantAsync(DateTime processedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询当前租户已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
|
||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
||||
if (overdueBillings.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 批量标记逾期(防御性:再次判断 Pending)
|
||||
var processedAt = DateTime.UtcNow;
|
||||
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending)
|
||||
var updated = 0;
|
||||
foreach (var billing in overdueBillings)
|
||||
{
|
||||
@@ -172,7 +203,7 @@ public sealed class BillingDomainService(
|
||||
updated++;
|
||||
}
|
||||
|
||||
// 3. 持久化
|
||||
// 3. (空行后) 持久化
|
||||
if (updated > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
@@ -12,11 +13,42 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
/// </summary>
|
||||
public sealed class StoreSchedulerService(
|
||||
TakeoutAppDbContext context,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ILogger<StoreSchedulerService> logger)
|
||||
: IStoreSchedulerService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
|
||||
var tenants = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
|
||||
{
|
||||
totalUpdated += await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> AutoSwitchBusinessStatusSingleTenantAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取候选门店
|
||||
var stores = await context.Stores
|
||||
@@ -129,6 +161,36 @@ public sealed class StoreSchedulerService(
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
|
||||
var tenants = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
|
||||
{
|
||||
totalUpdated += await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> CheckQualificationExpirySingleTenantAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询过期门店
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
Reference in New Issue
Block a user