refactor: 清理租户API旧模块代码
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Payments.Repositories;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Infrastructure.App.Options;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Services;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 业务主库基础设施注册扩展。
|
||||
/// </summary>
|
||||
public static class AppServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册业务主库 DbContext 与仓储。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<TakeoutAppDbContext>(DatabaseConstants.AppDataSource);
|
||||
services.AddPostgresDbContext<TakeoutLogsDbContext>(DatabaseConstants.LogsDataSource);
|
||||
|
||||
services.AddScoped<IMerchantRepository, EfMerchantRepository>();
|
||||
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
|
||||
services.AddScoped<IStoreRepository, EfStoreRepository>();
|
||||
services.AddScoped<IProductRepository, EfProductRepository>();
|
||||
services.AddScoped<IOrderRepository, EfOrderRepository>();
|
||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||
services.AddScoped<ITenantBillingRepository, TenantBillingRepository>();
|
||||
services.AddScoped<ITenantPaymentRepository, TenantPaymentRepository>();
|
||||
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
|
||||
services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>();
|
||||
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
|
||||
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
|
||||
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
|
||||
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
||||
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
|
||||
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();
|
||||
|
||||
// 1. 账单领域/导出服务
|
||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||
services.AddScoped<IBillingExportService, BillingExportService>();
|
||||
services.AddScoped<IMerchantExportService, MerchantExportService>();
|
||||
|
||||
// 2. (空行后) 门店配置服务
|
||||
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();
|
||||
services.AddScoped<IDeliveryZoneService, DeliveryZoneService>();
|
||||
services.AddScoped<IStoreFeeCalculationService, StoreFeeCalculationService>();
|
||||
services.AddScoped<IStoreSchedulerService, StoreSchedulerService>();
|
||||
|
||||
// 3. (空行后) 初始化配置与种子
|
||||
services.AddOptions<AppSeedOptions>()
|
||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddHostedService<AppDataSeeder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.App.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 业务数据种子配置。
|
||||
/// </summary>
|
||||
public sealed class AppSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置节名称。
|
||||
/// </summary>
|
||||
public const string SectionName = "App:Seed";
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用业务数据种子。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认租户配置。
|
||||
/// </summary>
|
||||
public TenantSeedOptions? DefaultTenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 基础字典分组。
|
||||
/// </summary>
|
||||
public List<DictionarySeedGroupOptions> DictionaryGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数配置。
|
||||
/// </summary>
|
||||
public List<SystemParameterSeedOptions> SystemParameters { get; set; } = new();
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组种子配置。
|
||||
/// </summary>
|
||||
public sealed class DictionarySeedGroupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属租户,不填则使用默认租户或系统租户。
|
||||
/// </summary>
|
||||
public long? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 分组作用域。
|
||||
/// </summary>
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
public List<DictionarySeedItemOptions> Items { get; set; } = new();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项种子配置。
|
||||
/// </summary>
|
||||
public sealed class DictionarySeedItemOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.App.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数种子配置项。
|
||||
/// </summary>
|
||||
public sealed class SystemParameterSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户,null 时使用默认租户或 0。
|
||||
/// </summary>
|
||||
public long? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 参数键。
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 参数值。
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 默认租户种子配置。
|
||||
/// </summary>
|
||||
public sealed class TenantSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 自定义租户标识,不填则自动生成。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户简称。
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string? ShortName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
[MaxLength(32)]
|
||||
public string? ContactPhone { get; set; }
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Entities;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 业务数据种子,确保默认租户与基础字典可重复执行。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化种子服务。
|
||||
/// </remarks>
|
||||
public sealed class AppDataSeeder(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AppDataSeeder> logger,
|
||||
IOptions<AppSeedOptions> options) : IHostedService
|
||||
{
|
||||
private readonly AppSeedOptions _options = options.Value;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
logger.LogInformation("AppSeed 未启用,跳过业务数据初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
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, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
|
||||
logger.LogInformation("AppSeed 完成业务数据初始化");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// 确保默认租户存在。
|
||||
/// </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))
|
||||
{
|
||||
logger.LogInformation("AppSeed 未配置默认租户,跳过租户种子");
|
||||
return null;
|
||||
}
|
||||
|
||||
var code = tenantOptions.Code.Trim();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (existingTenant == null)
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = tenantOptions.TenantId,
|
||||
Code = code,
|
||||
Name = tenantOptions.Name.Trim(),
|
||||
ShortName = tenantOptions.ShortName?.Trim(),
|
||||
ContactName = tenantOptions.ContactName?.Trim(),
|
||||
ContactPhone = tenantOptions.ContactPhone?.Trim(),
|
||||
Status = TenantStatus.Active
|
||||
};
|
||||
|
||||
await dbContext.Tenants.AddAsync(tenant, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("AppSeed 已创建默认租户 {TenantCode}", code);
|
||||
return tenant.Id;
|
||||
}
|
||||
|
||||
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();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.ShortName, tenantOptions.ShortName, StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.ShortName = tenantOptions.ShortName?.Trim();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.ContactName, tenantOptions.ContactName, StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.ContactName = tenantOptions.ContactName?.Trim();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.ContactPhone, tenantOptions.ContactPhone, StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.ContactPhone = tenantOptions.ContactPhone?.Trim();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existingTenant.Status != TenantStatus.Active)
|
||||
{
|
||||
existingTenant.Status = TenantStatus.Active;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
dbContext.Tenants.Update(existingTenant);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("AppSeed 已更新默认租户 {TenantCode}", code);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("AppSeed 默认租户 {TenantCode} 已存在且无需更新", code);
|
||||
}
|
||||
|
||||
return existingTenant.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保系统租户存在(TenantId=0,用于系统级数据归属)。
|
||||
/// </summary>
|
||||
private async Task EnsureSystemTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
using var _ = dbContext.DisableSoftDeleteFilter();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.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";
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.Name, "System", StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.Name = "System";
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existingTenant.Status != TenantStatus.Active)
|
||||
{
|
||||
existingTenant.Status = TenantStatus.Active;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
dbContext.Tenants.Update(existingTenant);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("AppSeed 已更新系统租户 SYSTEM");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = 0,
|
||||
Code = "SYSTEM",
|
||||
Name = "System",
|
||||
Status = TenantStatus.Active
|
||||
};
|
||||
|
||||
await dbContext.Tenants.AddAsync(tenant, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("AppSeed 已创建系统租户 SYSTEM");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保基础字典存在。
|
||||
/// </summary>
|
||||
private async Task EnsureDictionarySeedsAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dictionaryGroups = _options.DictionaryGroups ?? new List<DictionarySeedGroupOptions>();
|
||||
var hasDictionaryGroups = dictionaryGroups.Count > 0;
|
||||
|
||||
if (!hasDictionaryGroups)
|
||||
{
|
||||
logger.LogInformation("AppSeed 未配置基础字典,跳过字典种子");
|
||||
}
|
||||
|
||||
if (hasDictionaryGroups)
|
||||
{
|
||||
foreach (var groupOptions in dictionaryGroups)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(groupOptions.Code) || string.IsNullOrWhiteSpace(groupOptions.Name))
|
||||
{
|
||||
logger.LogWarning("AppSeed 跳过字典分组,Code 或 Name 为空");
|
||||
continue;
|
||||
}
|
||||
|
||||
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
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
group = new DictionaryGroup
|
||||
{
|
||||
Id = 0,
|
||||
TenantId = tenantId,
|
||||
Code = code,
|
||||
Name = groupOptions.Name.Trim(),
|
||||
Scope = groupOptions.Scope,
|
||||
Description = groupOptions.Description?.Trim(),
|
||||
IsEnabled = groupOptions.IsEnabled
|
||||
};
|
||||
|
||||
await dbContext.DictionaryGroups.AddAsync(group, cancellationToken);
|
||||
logger.LogInformation("AppSeed 创建字典分组 {GroupCode} (Tenant: {TenantId})", code, tenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
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();
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(group.Description, groupOptions.Description, StringComparison.Ordinal))
|
||||
{
|
||||
group.Description = groupOptions.Description?.Trim();
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (group.Scope != groupOptions.Scope)
|
||||
{
|
||||
group.Scope = groupOptions.Scope;
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (group.IsEnabled != groupOptions.IsEnabled)
|
||||
{
|
||||
group.IsEnabled = groupOptions.IsEnabled;
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (groupUpdated)
|
||||
{
|
||||
dbContext.DictionaryGroups.Update(group);
|
||||
}
|
||||
}
|
||||
|
||||
await UpsertDictionaryItemsAsync(dbContext, group, groupOptions.Items, tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await EnsureSystemParametersAsync(dbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 确保系统参数以独立表形式可重复种子。
|
||||
/// </summary>
|
||||
private async Task EnsureSystemParametersAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemParameters = _options.SystemParameters ?? new List<SystemParameterSeedOptions>();
|
||||
|
||||
if (systemParameters.Count == 0)
|
||||
{
|
||||
logger.LogInformation("AppSeed 未配置系统参数,跳过系统参数种子");
|
||||
return;
|
||||
}
|
||||
|
||||
var grouped = systemParameters
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && !string.IsNullOrWhiteSpace(x.Value))
|
||||
.GroupBy(x => x.TenantId ?? defaultTenantId ?? 0);
|
||||
|
||||
if (!grouped.Any())
|
||||
{
|
||||
logger.LogInformation("AppSeed 系统参数配置为空,跳过系统参数种子");
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var seed in group)
|
||||
{
|
||||
var key = seed.Key.Trim();
|
||||
var existing = existingParameters.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var parameter = new SystemParameter
|
||||
{
|
||||
Id = 0,
|
||||
TenantId = tenantId,
|
||||
Key = key,
|
||||
Value = seed.Value.Trim(),
|
||||
Description = seed.Description?.Trim(),
|
||||
SortOrder = seed.SortOrder,
|
||||
IsEnabled = seed.IsEnabled
|
||||
};
|
||||
|
||||
await dbContext.SystemParameters.AddAsync(parameter, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Description = seed.Description?.Trim();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existing.SortOrder != seed.SortOrder)
|
||||
{
|
||||
existing.SortOrder = seed.SortOrder;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existing.IsEnabled != seed.IsEnabled)
|
||||
{
|
||||
existing.IsEnabled = seed.IsEnabled;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
dbContext.SystemParameters.Update(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并字典项。
|
||||
/// </summary>
|
||||
private static async Task UpsertDictionaryItemsAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
DictionaryGroup group,
|
||||
IEnumerable<DictionarySeedItemOptions> seedItems,
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 确保分组已持久化以获取正确的主键,避免 FK 约束报错。
|
||||
if (!dbContext.Entry(group).IsKeySet || group.Id == 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var materializedItems = seedItems
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value))
|
||||
.ToList();
|
||||
|
||||
if (materializedItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existingItems = await dbContext.DictionaryItems
|
||||
.Where(x => x.GroupId == group.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var seed in materializedItems)
|
||||
{
|
||||
var key = seed.Key.Trim();
|
||||
var existing = existingItems.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var newItem = new DictionaryItem
|
||||
{
|
||||
Id = 0,
|
||||
TenantId = tenantId,
|
||||
GroupId = group.Id,
|
||||
Key = key,
|
||||
Value = seed.Value.Trim(),
|
||||
Description = seed.Description?.Trim(),
|
||||
SortOrder = seed.SortOrder,
|
||||
IsEnabled = seed.IsEnabled
|
||||
};
|
||||
|
||||
await dbContext.DictionaryItems.AddAsync(newItem, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Description = seed.Description?.Trim();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existing.SortOrder != seed.SortOrder)
|
||||
{
|
||||
existing.SortOrder = seed.SortOrder;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (existing.IsEnabled != seed.IsEnabled)
|
||||
{
|
||||
existing.IsEnabled = seed.IsEnabled;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
dbContext.DictionaryItems.Update(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantBillingStatement"/> EF Core 映射配置。
|
||||
/// </summary>
|
||||
public sealed class TenantBillingStatementConfiguration : IEntityTypeConfiguration<TenantBillingStatement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<TenantBillingStatement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_billing_statements");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// 1. 字段约束
|
||||
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.BillingType).HasConversion<int>();
|
||||
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
|
||||
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
|
||||
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
|
||||
builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY");
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
|
||||
// 2. JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
|
||||
builder.Property(x => x.LineItemsJson).HasColumnType("text");
|
||||
|
||||
// 3. 备注字段
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
// 4. 唯一约束与索引
|
||||
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
|
||||
|
||||
// 5. 性能索引(高频查询:租户+状态+到期日)
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate })
|
||||
.HasDatabaseName("idx_billing_tenant_status_duedate");
|
||||
|
||||
// 6. 逾期扫描索引(仅索引 Pending/Overdue)
|
||||
builder.HasIndex(x => new { x.Status, x.DueDate })
|
||||
.HasDatabaseName("idx_billing_status_duedate")
|
||||
.HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})");
|
||||
|
||||
// 7. 创建时间索引(支持列表倒序)
|
||||
builder.HasIndex(x => x.CreatedAt)
|
||||
.HasDatabaseName("idx_billing_created_at");
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantPayment"/> EF Core 映射配置。
|
||||
/// </summary>
|
||||
public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration<TenantPayment>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<TenantPayment> builder)
|
||||
{
|
||||
builder.ToTable("tenant_payments");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// 1. 字段约束
|
||||
builder.Property(x => x.BillingStatementId).IsRequired();
|
||||
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
|
||||
builder.Property(x => x.Method).HasConversion<int>();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.TransactionNo).HasMaxLength(64);
|
||||
builder.Property(x => x.ProofUrl).HasMaxLength(512);
|
||||
builder.Property(x => x.RefundReason).HasMaxLength(512);
|
||||
builder.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
// 2. 复合索引:租户+账单
|
||||
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
|
||||
|
||||
// 3. 支付记录时间排序索引
|
||||
builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt })
|
||||
.HasDatabaseName("idx_payment_billing_paidat");
|
||||
|
||||
// 4. 交易号索引(部分索引:仅非空)
|
||||
builder.HasIndex(x => x.TransactionNo)
|
||||
.HasDatabaseName("idx_payment_transaction_no")
|
||||
.HasFilter("\"TransactionNo\" IS NOT NULL");
|
||||
}
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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, 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,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询:在当前租户上下文内查询
|
||||
var query = context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
|
||||
|
||||
// 2. 按状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 3. 按日期范围过滤(账单周期)
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
}
|
||||
|
||||
// 4. 排序返回
|
||||
return await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return GetCurrentTenantId() == 0
|
||||
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
|
||||
: context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
|
||||
long tenantId,
|
||||
DateTime periodStart,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.DeletedAt == null
|
||||
&& x.TenantId == tenantId
|
||||
&& x.PeriodStart == periodStart
|
||||
&& x.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 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. (空行后) 系统上下文:逐租户查询逾期账单并合并
|
||||
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)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 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. (空行后) 系统上下文:逐租户查询即将到期账单并合并
|
||||
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)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(IReadOnlyCollection<long> billingIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (billingIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
// 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)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantBillingStatements.Update(bill);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
|
||||
long? tenantId,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
decimal? minAmount,
|
||||
decimal? maxAmount,
|
||||
string? keyword,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
|
||||
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
|
||||
var takePerTenant = normalizedPageNumber * normalizedPageSize;
|
||||
|
||||
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)
|
||||
{
|
||||
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. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
|
||||
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
HashSet<long>? keywordMatchedTenantIds = null;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
var totalCount = 0;
|
||||
var collected = new List<TenantBillingStatement>();
|
||||
foreach (var tid in tenantIds)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
var pageItems = collected
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToList();
|
||||
|
||||
return (pageItems, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantBillingStatistics> GetStatisticsAsync(
|
||||
long? tenantId,
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
string groupBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 统一时间基准与分组方式
|
||||
var now = DateTime.UtcNow;
|
||||
var normalizedGroupBy = NormalizeGroupBy(groupBy);
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
|
||||
// 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
|
||||
{
|
||||
Period = g.Key,
|
||||
Count = g.Count(),
|
||||
TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount),
|
||||
PaidAmount = g.Sum(x => x.AmountPaid)
|
||||
})
|
||||
.OrderBy(x => x.Period)
|
||||
.ToList();
|
||||
|
||||
return new TenantBillingStatistics
|
||||
{
|
||||
TotalAmount = totalAmount,
|
||||
PaidAmount = paidAmount,
|
||||
UnpaidAmount = unpaidAmount,
|
||||
OverdueAmount = overdueAmount,
|
||||
TotalCount = totalCount,
|
||||
PaidCount = paidCount,
|
||||
UnpaidCount = unpaidCount,
|
||||
OverdueCount = overdueCount,
|
||||
TrendData = trend
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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()
|
||||
.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)
|
||||
{
|
||||
return groupBy.Trim() switch
|
||||
{
|
||||
"Week" => "Week",
|
||||
"Month" => "Month",
|
||||
_ => "Day"
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime GetTrendBucket(DateTime periodStart, string groupBy)
|
||||
{
|
||||
var date = periodStart.Date;
|
||||
|
||||
return groupBy switch
|
||||
{
|
||||
"Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
"Week" => GetWeekStart(date),
|
||||
_ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime GetWeekStart(DateTime date)
|
||||
{
|
||||
// 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐)
|
||||
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
|
||||
var daysSinceMonday = (dayOfWeek + 6) % 7;
|
||||
|
||||
// 2. 回退到周一 00:00:00(UTC)
|
||||
var monday = date.AddDays(-daysSinceMonday);
|
||||
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户支付记录仓储实现(EF Core)。
|
||||
/// </summary>
|
||||
public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<TenantPayment> Items, int Total)> SearchPagedAsync(
|
||||
long tenantId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 归一化分页参数
|
||||
var normalizedPage = page <= 0 ? 1 : page;
|
||||
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
|
||||
// 2. 构建查询(按支付时间倒序)
|
||||
var query = context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.TenantId == tenantId
|
||||
&& (x.PaidAt ?? x.CreatedAt) >= from
|
||||
&& (x.PaidAt ?? x.CreatedAt) <= to);
|
||||
|
||||
// 3. 执行分页
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PaidAt ?? x.CreatedAt)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. 返回分页结果
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 仅统计支付成功的记录
|
||||
return await context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.BillingStatementId == billingStatementId
|
||||
&& x.Status == TenantPaymentStatus.Success)
|
||||
.SumAsync(x => x.Amount, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = transactionNo.Trim();
|
||||
|
||||
return context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantPayments.Update(payment);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户可见角色规则仓储实现。
|
||||
/// </summary>
|
||||
public sealed class TenantVisibilityRoleRuleRepository(TakeoutAppDbContext context) : ITenantVisibilityRoleRuleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 按租户获取规则。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>规则实体或 null。</returns>
|
||||
public Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVisibilityRoleRules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVisibilityRoleRules.AddAsync(rule, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantVisibilityRoleRules.Update(rule);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 设计时工厂,供 EF CLI 使用。
|
||||
/// </summary>
|
||||
internal sealed class TakeoutAppDesignTimeDbContextFactory
|
||||
: DesignTimeDbContextFactoryBase<TakeoutAppDbContext>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化业务库设计时上下文工厂。
|
||||
/// </summary>
|
||||
public TakeoutAppDesignTimeDbContextFactory()
|
||||
: base(DatabaseConstants.AppDataSource, "TAKEOUTSAAS_APP_CONNECTION")
|
||||
{
|
||||
}
|
||||
// 创建设计时上下文
|
||||
/// <summary>
|
||||
/// 创建设计时的业务库 DbContext。
|
||||
/// </summary>
|
||||
/// <param name="options">上下文选项。</param>
|
||||
/// <param name="tenantProvider">租户提供器。</param>
|
||||
/// <param name="currentUserAccessor">当前用户访问器。</param>
|
||||
/// <returns>业务库上下文实例。</returns>
|
||||
protected override TakeoutAppDbContext CreateContext(
|
||||
DbContextOptions<TakeoutAppDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
=> new(options, tenantProvider, currentUserAccessor);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Deliveries.Entities;
|
||||
using TakeoutSaaS.Domain.Deliveries.Enums;
|
||||
using TakeoutSaaS.Domain.Deliveries.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 配送聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfDeliveryRepository(TakeoutAppDbContext context) : IDeliveryRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOrder?> FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DeliveryOrders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DeliveryOrder?> FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DeliveryOrders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeliveryEvent>> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var events = await context.DeliveryEvents
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DeliveryOrders.AddAsync(deliveryOrder, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DeliveryEvents.AddAsync(deliveryEvent, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeliveryOrder>> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.DeliveryOrders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (orderId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.OrderId == orderId.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DeliveryOrders.Update(deliveryOrder);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var events = await context.DeliveryEvents
|
||||
.Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (events.Count > 0)
|
||||
{
|
||||
context.DeliveryEvents.RemoveRange(events);
|
||||
}
|
||||
|
||||
var existing = await context.DeliveryOrders
|
||||
.Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.DeliveryOrders.Remove(existing);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
using TakeoutSaaS.Domain.Inventory.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 库存仓储 EF 实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 提供库存与批次的读写能力。
|
||||
/// </remarks>
|
||||
public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<InventoryItem?> FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == inventoryItemId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<InventoryItem?> FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryItems
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<InventoryItem?> GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryItems
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryItems.AddAsync(item, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.InventoryItems.Update(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<InventoryLockRecord?> FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryLockRecords
|
||||
.Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lockRecord.Status = status;
|
||||
context.InventoryLockRecords.Update(lockRecord);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryLockRecord>> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var locks = await context.InventoryLockRecords
|
||||
.Where(x => x.TenantId == tenantId && x.Status == InventoryLockStatus.Locked && x.ExpiresAt != null && x.ExpiresAt <= utcNow)
|
||||
.ToListAsync(cancellationToken);
|
||||
return locks;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatch>> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.InventoryBatches
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId);
|
||||
|
||||
query = strategy == InventoryBatchConsumeStrategy.Fefo
|
||||
? query.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue).ThenBy(x => x.BatchNumber)
|
||||
: query.OrderBy(x => x.BatchNumber);
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatch>> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var batches = await context.InventoryBatches
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
|
||||
.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue)
|
||||
.ThenBy(x => x.BatchNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<InventoryBatch?> GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryBatches
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId && x.BatchNumber == batchNumber)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.InventoryBatches.Update(batch);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 商户类目的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context)
|
||||
: IMerchantCategoryRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantCategory>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await context.MerchantCategories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.OrderBy(x => x.DisplayOrder)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantCategories.AnyAsync(
|
||||
x => x.TenantId == tenantId && x.Name == name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MerchantCategory?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantCategories
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MerchantCategories.Remove(category);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateRangeAsync(IEnumerable<MerchantCategory> categories, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MerchantCategories.UpdateRange(categories);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 商户聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLogsDbContext logsContext) : IMerchantRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Merchant?> FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Merchants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Merchant?> GetForUpdateAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Merchants
|
||||
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Merchants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantStaff>> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staffs = await context.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return staffs;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantStaff>> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staffs = await context.MerchantStaff
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return staffs;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MerchantStaff?> FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantStaff
|
||||
.Where(x => x.TenantId == tenantId && x.Id == staffId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantContract>> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contracts = await context.MerchantContracts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return contracts;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MerchantContract?> FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantContracts
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == contractId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantDocument>> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var documents = await context.MerchantDocuments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MerchantDocument?> FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantDocuments
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == documentId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Merchants.AddAsync(merchant, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantStaff.AddAsync(staff, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MerchantContracts.Update(contract);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.MerchantDocuments.Update(document);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 保存业务库变更
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 2. 保存日志库变更
|
||||
await logsContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Merchants.Update(merchant);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.Merchants
|
||||
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Merchants.Remove(existing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.MerchantStaff
|
||||
.Where(x => x.TenantId == tenantId && x.Id == staffId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.MerchantStaff.Remove(existing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return logsContext.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.MerchantAuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Merchant>> SearchAsync(
|
||||
long tenantId,
|
||||
MerchantStatus? status,
|
||||
OperatingMode? operatingMode,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Merchants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (operatingMode.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.OperatingMode == operatingMode.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.BrandName, $"%{normalized}%") ||
|
||||
EF.Functions.ILike(x.BusinessLicenseNumber ?? string.Empty, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddChangeLogAsync(MerchantChangeLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return logsContext.MerchantChangeLogs.AddAsync(log, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantChangeLog>> GetChangeLogsAsync(
|
||||
long merchantId,
|
||||
long tenantId,
|
||||
string? fieldName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = logsContext.MerchantChangeLogs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fieldName))
|
||||
{
|
||||
query = query.Where(x => x.FieldName == fieldName);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Orders.Entities;
|
||||
using TakeoutSaaS.Domain.Orders.Enums;
|
||||
using TakeoutSaaS.Domain.Orders.Repositories;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 订单聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Order?> FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Orders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == orderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Order?> FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Orders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderNo == orderNo)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Order>> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Orders
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (paymentStatus.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PaymentStatus == paymentStatus.Value);
|
||||
}
|
||||
|
||||
var orders = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<OrderItem>> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await context.OrderItems
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.OrderBy(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<OrderStatusHistory>> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var histories = await context.OrderStatusHistories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return histories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RefundRequest>> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var refunds = await context.RefundRequests
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return refunds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddOrderAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Orders.AddAsync(order, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddItemsAsync(IEnumerable<OrderItem> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.OrderItems.AddRangeAsync(items, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.OrderStatusHistories.AddAsync(history, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.RefundRequests.AddAsync(refund, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Orders.Update(order);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await context.OrderItems
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (items.Count > 0)
|
||||
{
|
||||
context.OrderItems.RemoveRange(items);
|
||||
}
|
||||
|
||||
var histories = await context.OrderStatusHistories
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (histories.Count > 0)
|
||||
{
|
||||
context.OrderStatusHistories.RemoveRange(histories);
|
||||
}
|
||||
|
||||
var refunds = await context.RefundRequests
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (refunds.Count > 0)
|
||||
{
|
||||
context.RefundRequests.RemoveRange(refunds);
|
||||
}
|
||||
|
||||
var existing = await context.Orders
|
||||
.Where(x => x.TenantId == tenantId && x.Id == orderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Orders.Remove(existing);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Payments.Entities;
|
||||
using TakeoutSaaS.Domain.Payments.Enums;
|
||||
using TakeoutSaaS.Domain.Payments.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfPaymentRepository(TakeoutAppDbContext context) : IPaymentRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<PaymentRecord?> FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PaymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == paymentId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PaymentRecord?> FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PaymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PaymentRefundRecord>> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var refunds = await context.PaymentRefundRecords
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return refunds;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PaymentRecords.AddAsync(payment, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.PaymentRefundRecords.AddAsync(refund, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PaymentRecord>> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.PaymentRecords
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.PaymentRecords.Update(payment);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var refunds = await context.PaymentRefundRecords
|
||||
.Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (refunds.Count > 0)
|
||||
{
|
||||
context.PaymentRefundRecords.RemoveRange(refunds);
|
||||
}
|
||||
|
||||
var existing = await context.PaymentRecords
|
||||
.Where(x => x.TenantId == tenantId && x.Id == paymentId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.PaymentRecords.Remove(existing);
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Products.Entities;
|
||||
using TakeoutSaaS.Domain.Products.Enums;
|
||||
using TakeoutSaaS.Domain.Products.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 商品聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Product?> FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Products
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == productId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null)
|
||||
{
|
||||
var query = context.Products
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (storeId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.StoreId == storeId.Value);
|
||||
}
|
||||
|
||||
if (categoryId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.CategoryId == categoryId.Value);
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (updatedAfter.HasValue)
|
||||
{
|
||||
query = query.Where(x => (x.UpdatedAt ?? x.CreatedAt) >= updatedAfter.Value);
|
||||
}
|
||||
|
||||
var products = await query
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var categories = await context.ProductCategories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.ProductCategories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
|
||||
if (onlyEnabled)
|
||||
{
|
||||
query = query.Where(x => x.IsEnabled);
|
||||
}
|
||||
var categories = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return categories;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var skus = await context.ProductSkus
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return skus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductSku>();
|
||||
}
|
||||
var skus = await context.ProductSkus
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return skus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groups = await context.ProductAddonGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAddonGroup>();
|
||||
}
|
||||
var groups = await context.ProductAddonGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groupIds = await context.ProductAddonGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAddonOption>();
|
||||
}
|
||||
|
||||
var options = await context.ProductAddonOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (addonGroupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAddonOption>();
|
||||
}
|
||||
var options = await context.ProductAddonOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groups = await context.ProductAttributeGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAttributeGroup>();
|
||||
}
|
||||
var groups = await context.ProductAttributeGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groupIds = await context.ProductAttributeGroups
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAttributeOption>();
|
||||
}
|
||||
|
||||
var options = await context.ProductAttributeOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (attributeGroupIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductAttributeOption>();
|
||||
}
|
||||
var options = await context.ProductAttributeOptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assets = await context.ProductMediaAssets
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductMediaAsset>();
|
||||
}
|
||||
var assets = await context.ProductMediaAssets
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rules = await context.ProductPricingRules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (productIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<ProductPricingRule>();
|
||||
}
|
||||
var rules = await context.ProductPricingRules
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
return rules;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.ProductCategories.AddAsync(category, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddProductAsync(Product product, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Products.AddAsync(product, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSkusAsync(IEnumerable<ProductSku> skus, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.ProductSkus.AddRangeAsync(skus, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAddonGroupsAsync(IEnumerable<ProductAddonGroup> groups, IEnumerable<ProductAddonOption> options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var addGroupsTask = context.ProductAddonGroups.AddRangeAsync(groups, cancellationToken);
|
||||
var addOptionsTask = context.ProductAddonOptions.AddRangeAsync(options, cancellationToken);
|
||||
return Task.WhenAll(addGroupsTask, addOptionsTask);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAttributeGroupsAsync(IEnumerable<ProductAttributeGroup> groups, IEnumerable<ProductAttributeOption> options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var addGroupsTask = context.ProductAttributeGroups.AddRangeAsync(groups, cancellationToken);
|
||||
var addOptionsTask = context.ProductAttributeOptions.AddRangeAsync(options, cancellationToken);
|
||||
return Task.WhenAll(addGroupsTask, addOptionsTask);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddMediaAssetsAsync(IEnumerable<ProductMediaAsset> assets, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.ProductMediaAssets.AddRangeAsync(assets, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPricingRulesAsync(IEnumerable<ProductPricingRule> rules, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.ProductPricingRules.AddRangeAsync(rules, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Products.Update(product);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RemovePricingRulesAsync(productId, tenantId, cancellationToken);
|
||||
await RemoveMediaAssetsAsync(productId, tenantId, cancellationToken);
|
||||
await RemoveAttributeGroupsAsync(productId, tenantId, cancellationToken);
|
||||
await RemoveAddonGroupsAsync(productId, tenantId, cancellationToken);
|
||||
await RemoveSkusAsync(productId, tenantId, cancellationToken);
|
||||
|
||||
var existing = await context.Products
|
||||
.Where(x => x.TenantId == tenantId && x.Id == productId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Products.Remove(existing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.ProductCategories.Update(category);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.ProductCategories
|
||||
.Where(x => x.TenantId == tenantId && x.Id == categoryId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ProductCategories.Remove(existing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var skus = await context.ProductSkus
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (skus.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ProductSkus.RemoveRange(skus);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groupIds = await context.ProductAddonGroups
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groupIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = await context.ProductAddonOptions
|
||||
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (options.Count > 0)
|
||||
{
|
||||
context.ProductAddonOptions.RemoveRange(options);
|
||||
}
|
||||
|
||||
var groups = await context.ProductAddonGroups
|
||||
.Where(x => groupIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groups.Count > 0)
|
||||
{
|
||||
context.ProductAddonGroups.RemoveRange(groups);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var groupIds = await context.ProductAttributeGroups
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groupIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = await context.ProductAttributeOptions
|
||||
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (options.Count > 0)
|
||||
{
|
||||
context.ProductAttributeOptions.RemoveRange(options);
|
||||
}
|
||||
|
||||
var groups = await context.ProductAttributeGroups
|
||||
.Where(x => groupIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (groups.Count > 0)
|
||||
{
|
||||
context.ProductAttributeGroups.RemoveRange(groups);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assets = await context.ProductMediaAssets
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (assets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ProductMediaAssets.RemoveRange(assets);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rules = await context.ProductPricingRules
|
||||
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ProductPricingRules.RemoveRange(rules);
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 配额包仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuotaPackageRepository
|
||||
{
|
||||
#region 配额包定义
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QuotaPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.QuotaPackages
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<QuotaPackage> Items, int Total)> SearchPagedAsync(
|
||||
TenantQuotaType? quotaType,
|
||||
bool? isActive,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.QuotaPackages.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
|
||||
if (quotaType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.QuotaType == quotaType.Value);
|
||||
}
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.QuotaPackages.AddAsync(quotaPackage, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.QuotaPackages.Update(quotaPackage);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SoftDeleteAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var quotaPackage = await context.QuotaPackages
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (quotaPackage == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
quotaPackage.DeletedAt = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额包购买记录
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
|
||||
long tenantId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaPackagePurchases
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PurchasedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Join(context.QuotaPackages.AsNoTracking(),
|
||||
purchase => purchase.QuotaPackageId,
|
||||
package => package.Id,
|
||||
(purchase, package) => new { Purchase = purchase, Package = package })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items.Select(x => (x.Purchase, x.Package)).ToList(), total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaPackagePurchases.AddAsync(purchase, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用情况
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsage>> GetUsageByTenantAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType? quotaType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (quotaType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.QuotaType == quotaType.Value);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantQuotaUsage?> FindUsageAsync(
|
||||
long tenantId,
|
||||
TenantQuotaType quotaType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantQuotaUsages.Update(usage);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfStatisticsRepository(TakeoutAppDbContext dbContext) : IStatisticsRepository
|
||||
{
|
||||
#region 订阅统计
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExpiringSubscriptionInfo>> GetExpiringSubscriptionsAsync(
|
||||
int daysAhead,
|
||||
bool onlyWithoutAutoRenew,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var targetDate = now.AddDays(daysAhead);
|
||||
|
||||
// 构建基础查询
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.EffectiveTo >= now
|
||||
&& s.EffectiveTo <= targetDate);
|
||||
|
||||
// 如果只查询未开启自动续费的
|
||||
if (onlyWithoutAutoRenew)
|
||||
{
|
||||
query = query.Where(s => !s.AutoRenew);
|
||||
}
|
||||
|
||||
// 连接租户和套餐信息
|
||||
var result = await query
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new ExpiringSubscriptionInfo
|
||||
{
|
||||
Subscription = combined.Subscription,
|
||||
TenantName = combined.Tenant.Name,
|
||||
PackageName = package.Name
|
||||
}
|
||||
)
|
||||
.OrderBy(x => x.Subscription.EffectiveTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 收入统计
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetPaidBillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Status == TenantBillingStatus.Paid)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用排行
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<QuotaUsageRankInfo>> GetQuotaUsageRankingAsync(
|
||||
TenantQuotaType quotaType,
|
||||
int topN,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(q => q.QuotaType == quotaType && q.LimitValue > 0)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
quota => quota.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(quota, tenant) => new QuotaUsageRankInfo
|
||||
{
|
||||
TenantId = quota.TenantId,
|
||||
TenantName = tenant.Name,
|
||||
UsedValue = quota.UsedValue,
|
||||
LimitValue = quota.LimitValue,
|
||||
UsagePercentage = quota.LimitValue > 0 ? (quota.UsedValue / quota.LimitValue * 100) : 0
|
||||
}
|
||||
)
|
||||
.OrderByDescending(x => x.UsagePercentage)
|
||||
.Take(topN)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 门店聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化仓储。
|
||||
/// </remarks>
|
||||
public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Store?> FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Id == storeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Store>> GetByMerchantIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Store>> SearchAsync(
|
||||
long tenantId,
|
||||
long? merchantId,
|
||||
StoreStatus? status,
|
||||
StoreAuditStatus? auditStatus,
|
||||
StoreBusinessStatus? businessStatus,
|
||||
StoreOwnershipType? ownershipType,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (merchantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.MerchantId == merchantId.Value);
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (auditStatus.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AuditStatus == auditStatus.Value);
|
||||
}
|
||||
|
||||
if (businessStatus.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.BusinessStatus == businessStatus.Value);
|
||||
}
|
||||
|
||||
if (ownershipType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.OwnershipType == ownershipType.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var trimmed = keyword.Trim();
|
||||
query = query.Where(x => x.Name.Contains(trimmed) || x.Code.Contains(trimmed));
|
||||
}
|
||||
|
||||
var stores = await query
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return stores;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ExistsStoreWithinDistanceAsync(
|
||||
long merchantId,
|
||||
long tenantId,
|
||||
double longitude,
|
||||
double latitude,
|
||||
double distanceMeters,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 校验距离阈值
|
||||
if (distanceMeters <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. (空行后) 拉取候选坐标
|
||||
var coordinates = await context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.Where(x => x.Longitude.HasValue && x.Latitude.HasValue)
|
||||
.Select(x => new { Longitude = x.Longitude!.Value, Latitude = x.Latitude!.Value })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. (空行后) 计算距离并判断是否命中
|
||||
foreach (var coordinate in coordinates)
|
||||
{
|
||||
var distance = CalculateDistanceMeters(latitude, longitude, coordinate.Latitude, coordinate.Longitude);
|
||||
if (distance <= distanceMeters)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. (空行后) 返回未命中结果
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (merchantIds.Count == 0)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
var query = context.Stores
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
return await query
|
||||
.Where(x => merchantIds.Contains(x.MerchantId))
|
||||
.GroupBy(x => x.MerchantId)
|
||||
.Select(group => new { group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hours = await context.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.DayOfWeek)
|
||||
.ThenBy(x => x.StartTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreFee?> GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreFees
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreFees.AddAsync(storeFee, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateStoreFeeAsync(StoreFee storeFee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreFees.Update(storeFee);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var qualifications = await context.StoreQualifications
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.QualificationType)
|
||||
.ToListAsync(cancellationToken);
|
||||
return qualifications;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreQualification?> FindQualificationByIdAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreQualifications
|
||||
.Where(x => x.TenantId == tenantId && x.Id == qualificationId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreQualifications.AddAsync(qualification, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateQualificationAsync(StoreQualification qualification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreQualifications.Update(qualification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteQualificationAsync(long qualificationId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreQualifications
|
||||
.Where(x => x.TenantId == tenantId && x.Id == qualificationId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreQualifications.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAuditRecordAsync(StoreAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreAuditRecords.AddAsync(record, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreAuditRecord>> GetAuditRecordsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var records = await context.StoreAuditRecords
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
return records;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreBusinessHour?> FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && x.Id == businessHourId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var zones = await context.StoreDeliveryZones
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return zones;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var holidays = await context.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.Date)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return holidays;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreHolidays
|
||||
.Where(x => x.TenantId == tenantId && x.Id == holidayId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreTableArea>> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var areas = await context.StoreTableAreas
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreTableArea?> FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreTableAreas
|
||||
.Where(x => x.TenantId == tenantId && x.Id == areaId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreTable>> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tables = await context.StoreTables
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.TableCode)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreTable?> FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreTables
|
||||
.Where(x => x.TenantId == tenantId && x.Id == tableId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreTable?> FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreTables
|
||||
.Where(x => x.TenantId == tenantId && x.TableCode == tableCode)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StorePickupSetting?> GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StorePickupSettings
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StorePickupSettings.AddAsync(setting, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StorePickupSettings.Update(setting);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StorePickupSlot>> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var slots = await context.StorePickupSlots
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
|
||||
.OrderBy(x => x.StartTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
return slots;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StorePickupSlot?> FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && x.Id == slotId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddPickupSlotsAsync(IEnumerable<StorePickupSlot> slots, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StorePickupSlots.AddRangeAsync(slots, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StorePickupSlots.Update(slot);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<StoreEmployeeShift>> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.StoreEmployeeShifts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.ShiftDate >= from.Value.Date);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.ShiftDate <= to.Value.Date);
|
||||
}
|
||||
|
||||
var shifts = await query
|
||||
.OrderBy(x => x.ShiftDate)
|
||||
.ThenBy(x => x.StartTime)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return shifts;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoreEmployeeShift?> FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreEmployeeShifts
|
||||
.Where(x => x.TenantId == tenantId && x.Id == shiftId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Stores.AddAsync(store, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddBusinessHoursAsync(IEnumerable<StoreBusinessHour> hours, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreBusinessHours.Update(hour);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddDeliveryZonesAsync(IEnumerable<StoreDeliveryZone> zones, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreDeliveryZones.Update(zone);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddHolidaysAsync(IEnumerable<StoreHoliday> holidays, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreHolidays.Update(holiday);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTableAreasAsync(IEnumerable<StoreTableArea> areas, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreTableAreas.Update(area);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTablesAsync(IEnumerable<StoreTable> tables, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreTables.AddRangeAsync(tables, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreTables.Update(table);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddShiftsAsync(IEnumerable<StoreEmployeeShift> shifts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.StoreEmployeeShifts.Update(shift);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreBusinessHours
|
||||
.Where(x => x.TenantId == tenantId && x.Id == businessHourId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreBusinessHours.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreDeliveryZones
|
||||
.Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreDeliveryZones.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreHolidays
|
||||
.Where(x => x.TenantId == tenantId && x.Id == holidayId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreHolidays.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreTableAreas
|
||||
.Where(x => x.TenantId == tenantId && x.Id == areaId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreTableAreas.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreTables
|
||||
.Where(x => x.TenantId == tenantId && x.Id == tableId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreTables.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StorePickupSlots
|
||||
.Where(x => x.TenantId == tenantId && x.Id == slotId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StorePickupSlots.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.StoreEmployeeShifts
|
||||
.Where(x => x.TenantId == tenantId && x.Id == shiftId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
context.StoreEmployeeShifts.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Stores.Update(store);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await context.Stores
|
||||
.Where(x => x.TenantId == tenantId && x.Id == storeId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Stores.Remove(existing);
|
||||
}
|
||||
|
||||
private static double CalculateDistanceMeters(double latitude1, double longitude1, double latitude2, double longitude2)
|
||||
{
|
||||
const double earthRadius = 6371000d;
|
||||
var latRad1 = DegreesToRadians(latitude1);
|
||||
var latRad2 = DegreesToRadians(latitude2);
|
||||
var deltaLat = DegreesToRadians(latitude2 - latitude1);
|
||||
var deltaLon = DegreesToRadians(longitude2 - longitude1);
|
||||
var sinLat = Math.Sin(deltaLat / 2);
|
||||
var sinLon = Math.Sin(deltaLon / 2);
|
||||
var a = sinLat * sinLat + Math.Cos(latRad1) * Math.Cos(latRad2) * sinLon * sinLon;
|
||||
var c = 2 * Math.Asin(Math.Min(1, Math.Sqrt(a)));
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180d);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅管理仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext, TakeoutLogsDbContext logsContext) : ISubscriptionRepository
|
||||
{
|
||||
#region 订阅查询
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantSubscription?> FindByIdAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptions
|
||||
.FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = subscriptionIds.ToList();
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => ids.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
|
||||
SubscriptionSearchFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
|
||||
)
|
||||
.GroupJoin(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.ScheduledPackageId,
|
||||
scheduledPackage => scheduledPackage.Id,
|
||||
(combined, scheduledPackages) => new { combined.Subscription, combined.Tenant, combined.Package, ScheduledPackage = scheduledPackages.FirstOrDefault() }
|
||||
);
|
||||
|
||||
// 2. 应用过滤条件
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.Status == filter.Status.Value);
|
||||
}
|
||||
|
||||
if (filter.TenantPackageId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.TenantPackageId == filter.TenantPackageId.Value);
|
||||
}
|
||||
|
||||
if (filter.TenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.TenantId == filter.TenantId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.TenantKeyword))
|
||||
{
|
||||
var keyword = filter.TenantKeyword.Trim().ToLower();
|
||||
query = query.Where(x => x.Tenant.Name.ToLower().Contains(keyword) || x.Tenant.Code.ToLower().Contains(keyword));
|
||||
}
|
||||
|
||||
if (filter.ExpiringWithinDays.HasValue)
|
||||
{
|
||||
var expiryDate = DateTime.UtcNow.AddDays(filter.ExpiringWithinDays.Value);
|
||||
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo >= DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (filter.AutoRenew.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Subscription.AutoRenew == filter.AutoRenew.Value);
|
||||
}
|
||||
|
||||
// 3. 获取总数
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 4. 排序和分页
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.Subscription.CreatedAt)
|
||||
.Skip((filter.Page - 1) * filter.PageSize)
|
||||
.Take(filter.PageSize)
|
||||
.Select(x => new SubscriptionWithRelations
|
||||
{
|
||||
Subscription = x.Subscription,
|
||||
TenantName = x.Tenant.Name,
|
||||
TenantCode = x.Tenant.Code,
|
||||
PackageName = x.Package.Name,
|
||||
ScheduledPackageName = x.ScheduledPackage != null ? x.ScheduledPackage.Name : null
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SubscriptionDetailInfo?> GetDetailAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await dbContext.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.Id == subscriptionId)
|
||||
.Select(s => new
|
||||
{
|
||||
Subscription = s,
|
||||
Tenant = dbContext.Tenants.FirstOrDefault(t => t.Id == s.TenantId),
|
||||
Package = dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.TenantPackageId),
|
||||
ScheduledPackage = s.ScheduledPackageId.HasValue
|
||||
? dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.ScheduledPackageId)
|
||||
: null
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SubscriptionDetailInfo
|
||||
{
|
||||
Subscription = result.Subscription,
|
||||
TenantName = result.Tenant?.Name ?? "",
|
||||
TenantCode = result.Tenant?.Code ?? "",
|
||||
Package = result.Package,
|
||||
ScheduledPackage = result.ScheduledPackage
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
|
||||
IEnumerable<long> subscriptionIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = subscriptionIds.ToList();
|
||||
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => ids.Contains(s.Id))
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new SubscriptionWithTenant
|
||||
{
|
||||
Subscription = sub,
|
||||
Tenant = tenant
|
||||
}
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
|
||||
DateTime now,
|
||||
DateTime renewalThreshold,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询开启自动续费且即将到期的活跃订阅
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
subscription => subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(subscription, package) => new AutoRenewalCandidate
|
||||
{
|
||||
Subscription = subscription,
|
||||
Package = package
|
||||
});
|
||||
|
||||
// 2. 返回候选列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RenewalReminderCandidate>> FindRenewalReminderCandidatesAsync(
|
||||
DateTime startOfDay,
|
||||
DateTime endOfDay,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询到期落在指定区间的订阅(且未开启自动续费)
|
||||
var query = dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
subscription => subscription.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(subscription, tenant) => new { Subscription = subscription, Tenant = tenant })
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new RenewalReminderCandidate
|
||||
{
|
||||
Subscription = combined.Subscription,
|
||||
Tenant = combined.Tenant,
|
||||
Package = package
|
||||
});
|
||||
|
||||
// 2. 返回候选列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
|
||||
DateTime now,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询已到期仍为 Active 的订阅
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> FindGracePeriodExpiredSubscriptionsAsync(
|
||||
DateTime now,
|
||||
int gracePeriodDays,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询宽限期已结束的订阅
|
||||
return await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 套餐查询
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantPackage?> FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantPackages
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Id == packageId, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅更新
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantSubscriptions.Update(subscription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 订阅历史
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantSubscriptionHistories.Add(history);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SubscriptionHistoryWithPackageNames>> GetHistoryAsync(
|
||||
long subscriptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantSubscriptionHistories
|
||||
.AsNoTracking()
|
||||
.Where(h => h.TenantSubscriptionId == subscriptionId)
|
||||
.OrderByDescending(h => h.CreatedAt)
|
||||
.Select(h => new SubscriptionHistoryWithPackageNames
|
||||
{
|
||||
History = h,
|
||||
FromPackageName = dbContext.TenantPackages
|
||||
.Where(p => p.Id == h.FromPackageId)
|
||||
.Select(p => p.Name)
|
||||
.FirstOrDefault() ?? "",
|
||||
ToPackageName = dbContext.TenantPackages
|
||||
.Where(p => p.Id == h.ToPackageId)
|
||||
.Select(p => p.Name)
|
||||
.FirstOrDefault() ?? ""
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 配额使用
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
|
||||
long tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(q => q.TenantId == tenantId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 通知
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 操作日志
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logsContext.OperationLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 保存业务库变更
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 2. 保存日志库变更
|
||||
await logsContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 公告已读仓储。
|
||||
/// </summary>
|
||||
public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantAnnouncementRead>> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantAnnouncementReads.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.AnnouncementId == announcementId)
|
||||
.OrderBy(x => x.ReadAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<TenantAnnouncementRead>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantAnnouncementRead>> GetByAnnouncementAsync(long tenantId, IEnumerable<long> announcementIds, long? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = announcementIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TenantAnnouncementRead>>(Array.Empty<TenantAnnouncementRead>());
|
||||
}
|
||||
|
||||
var query = context.TenantAnnouncementReads.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && ids.Contains(x.AnnouncementId));
|
||||
|
||||
if (userId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.UserId == userId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(x => x.UserId == null);
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderByDescending(x => x.ReadAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<TenantAnnouncementRead>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantAnnouncementRead?> FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantAnnouncementReads
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 租户公告仓储。
|
||||
/// </summary>
|
||||
public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
|
||||
long tenantId,
|
||||
string? keyword,
|
||||
AnnouncementStatus? status,
|
||||
TenantAnnouncementType? type,
|
||||
bool? isActive,
|
||||
DateTime? effectiveFrom,
|
||||
DateTime? effectiveTo,
|
||||
DateTime? effectiveAt,
|
||||
bool orderByPriority = false,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantAnnouncements.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Title, $"%{normalized}%")
|
||||
|| EF.Functions.ILike(x.Content, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (type.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AnnouncementType == type.Value);
|
||||
}
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = isActive.Value
|
||||
? query.Where(x => x.Status == AnnouncementStatus.Published)
|
||||
: query.Where(x => x.Status != AnnouncementStatus.Published);
|
||||
}
|
||||
|
||||
if (effectiveFrom.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.EffectiveFrom >= effectiveFrom.Value);
|
||||
}
|
||||
|
||||
if (effectiveTo.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.EffectiveTo == null || x.EffectiveTo <= effectiveTo.Value);
|
||||
}
|
||||
|
||||
if (effectiveAt.HasValue)
|
||||
{
|
||||
var at = effectiveAt.Value;
|
||||
query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
|
||||
}
|
||||
|
||||
// 应用排序(如果启用)
|
||||
if (orderByPriority)
|
||||
{
|
||||
query = query.OrderByDescending(x => x.Priority).ThenByDescending(x => x.EffectiveFrom);
|
||||
}
|
||||
|
||||
// 应用限制(如果指定)
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantAnnouncement>> SearchUnreadAsync(
|
||||
long tenantId,
|
||||
long? userId,
|
||||
AnnouncementStatus? status,
|
||||
bool? isActive,
|
||||
DateTime? effectiveAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var announcementQuery = context.TenantAnnouncements.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
announcementQuery = announcementQuery.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
announcementQuery = isActive.Value
|
||||
? announcementQuery.Where(x => x.Status == AnnouncementStatus.Published)
|
||||
: announcementQuery.Where(x => x.Status != AnnouncementStatus.Published);
|
||||
}
|
||||
|
||||
if (effectiveAt.HasValue)
|
||||
{
|
||||
var at = effectiveAt.Value;
|
||||
announcementQuery = announcementQuery.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
|
||||
}
|
||||
|
||||
var readQuery = context.TenantAnnouncementReads.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
readQuery = userId.HasValue
|
||||
? readQuery.Where(x => x.UserId == null || x.UserId == userId.Value)
|
||||
: readQuery.Where(x => x.UserId == null);
|
||||
|
||||
var query = from announcement in announcementQuery
|
||||
join read in readQuery on announcement.Id equals read.AnnouncementId into readGroup
|
||||
where !readGroup.Any()
|
||||
select announcement;
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantAnnouncement?> FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantAnnouncements.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantAnnouncements.Update(announcement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
context.TenantAnnouncements.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF 租户通知仓储。
|
||||
/// </summary>
|
||||
public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantNotification>> SearchAsync(
|
||||
long tenantId,
|
||||
TenantNotificationSeverity? severity,
|
||||
bool? unreadOnly,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.TenantNotifications.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (severity.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Severity == severity.Value);
|
||||
}
|
||||
|
||||
if (unreadOnly == true)
|
||||
{
|
||||
query = query.Where(x => x.ReadAt == null);
|
||||
}
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.SentAt >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.SentAt <= to.Value);
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderByDescending(x => x.SentAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<TenantNotification>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantNotification?> FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantNotifications
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByMetadataAsync(
|
||||
long tenantId,
|
||||
string title,
|
||||
string metadataJson,
|
||||
DateTime sentAfter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantNotifications.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.TenantId == tenantId
|
||||
&& x.Title == title
|
||||
&& x.MetadataJson == metadataJson
|
||||
&& x.SentAt >= sentAfter,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantNotifications.Update(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户套餐仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<TenantPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = context.TenantPackages.AsNoTracking();
|
||||
|
||||
// 2. 关键字过滤
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
// 3. 状态过滤
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
// 4. 排序返回
|
||||
return await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantPackage>> SearchPublicPurchasableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 公共可选购套餐仅返回:已发布 + 对外可见 + 允许新购 + 启用
|
||||
return await context.TenantPackages.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsActive
|
||||
&& x.PublishStatus == TenantPackagePublishStatus.Published
|
||||
&& x.IsPublicVisible
|
||||
&& x.IsAllowNewTenantPurchase)
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPackages.AddAsync(package, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantPackages.Update(package);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
context.TenantPackages.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用历史仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfTenantQuotaUsageHistoryRepository(TakeoutAppDbContext context) : ITenantQuotaUsageHistoryRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantQuotaUsageHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsageHistories.AddAsync(history, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<TenantQuotaUsage?> FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantQuotaUsage>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.OrderBy(x => x.QuotaType)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<TenantQuotaUsage>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantQuotaUsages.Update(usage);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户聚合的 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsDbContext logsContext) : ITenantRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Tenants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<Tenant>();
|
||||
}
|
||||
|
||||
return await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => tenantIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Tenant>> SearchAsync(
|
||||
TenantStatus? status,
|
||||
string? keyword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = context.Tenants.AsNoTracking();
|
||||
|
||||
// 2. 按状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 3. 按关键字过滤
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Name, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.Code, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%") ||
|
||||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{keyword}%"));
|
||||
}
|
||||
|
||||
// 4. 排序返回
|
||||
return await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<Tenant> Items, int Total)> SearchPagedAsync(
|
||||
TenantStatus? status,
|
||||
TenantVerificationStatus? verificationStatus,
|
||||
string? name,
|
||||
string? contactName,
|
||||
string? contactPhone,
|
||||
string? keyword,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.Tenants.AsNoTracking();
|
||||
|
||||
// 1. 按租户状态过滤
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
// 2. 按实名认证状态过滤(未提交视为 Draft)
|
||||
if (verificationStatus.HasValue)
|
||||
{
|
||||
query = from tenant in query
|
||||
join profile in context.TenantVerificationProfiles.AsNoTracking()
|
||||
on tenant.Id equals profile.TenantId into profiles
|
||||
from profile in profiles.DefaultIfEmpty()
|
||||
where (profile == null ? TenantVerificationStatus.Draft : profile.Status) == verificationStatus.Value
|
||||
select tenant;
|
||||
}
|
||||
|
||||
// 3. 按名称/联系人/电话过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var normalizedName = name.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%"));
|
||||
}
|
||||
|
||||
// 4. 按联系人过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(contactName))
|
||||
{
|
||||
var normalizedContactName = contactName.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%"));
|
||||
}
|
||||
|
||||
// 5. 按联系电话过滤(模糊匹配)
|
||||
if (!string.IsNullOrWhiteSpace(contactPhone))
|
||||
{
|
||||
var normalizedContactPhone = contactPhone.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%"));
|
||||
}
|
||||
|
||||
// 6. 兼容关键字查询:名称/编码/联系人/电话
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalizedKeyword = keyword.Trim();
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.Code, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedKeyword}%") ||
|
||||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
|
||||
// 7. 先统计总数,再按创建时间倒序分页
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 8. 查询当前页数据
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.Tenants.Update(tenant);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = code.Trim();
|
||||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化编码
|
||||
var normalized = code.Trim();
|
||||
|
||||
// 2. 查询租户 ID(仅查询未删除且状态正常的租户)
|
||||
return context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Code == normalized && x.DeletedAt == null)
|
||||
.Select(x => (long?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化名称
|
||||
var normalized = name.Trim();
|
||||
|
||||
// 2. 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
|
||||
var query = context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => EF.Functions.ILike(x.Name, normalized));
|
||||
|
||||
// 3. 更新场景排除自身
|
||||
if (excludeTenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeTenantId.Value);
|
||||
}
|
||||
|
||||
// 4. 判断是否存在
|
||||
return query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = phone.Trim();
|
||||
return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<long?> FindTenantIdByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化手机号
|
||||
var normalized = phone.Trim();
|
||||
// 2. 查询租户 ID
|
||||
return context.Tenants.AsNoTracking()
|
||||
.Where(x => x.ContactPhone == normalized)
|
||||
.Select(x => (long?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVerificationProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantVerificationProfile>();
|
||||
}
|
||||
|
||||
// 2. 批量查询实名资料
|
||||
return await context.TenantVerificationProfiles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询现有实名资料
|
||||
var existing = await context.TenantVerificationProfiles
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
// 2. 不存在则新增
|
||||
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 存在则更新当前值
|
||||
profile.Id = existing.Id;
|
||||
context.Entry(existing).CurrentValues.SetValues(profile);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
|
||||
IReadOnlyCollection<long> tenantIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. tenantIds 为空直接返回
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantSubscription>();
|
||||
}
|
||||
|
||||
// 2. 批量查询订阅数据
|
||||
return await context.TenantSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantSubscriptions.Update(subscription);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantSubscriptionHistories
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveFrom)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return logsContext.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.TenantAuditLogs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 保存业务库变更
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 2. 保存日志库变更
|
||||
await logsContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 账单领域服务实现。
|
||||
/// </summary>
|
||||
public sealed class BillingDomainService(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IIdGenerator idGenerator) : IBillingDomainService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
|
||||
TenantSubscription subscription,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
|
||||
// 1. 校验幂等:同一周期开始时间只能存在一张未取消账单
|
||||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
|
||||
subscription.TenantId,
|
||||
subscription.EffectiveFrom,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new InvalidOperationException("该订阅周期的账单已存在。");
|
||||
}
|
||||
|
||||
// 2. 查询套餐价格信息
|
||||
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
|
||||
if (package is null)
|
||||
{
|
||||
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
|
||||
}
|
||||
|
||||
// 3. 选择价格(简化规则:优先按年/按月)
|
||||
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
|
||||
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
|
||||
if (!amountDue.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
|
||||
}
|
||||
|
||||
// 4. 生成账单明细
|
||||
var lineItems = new List<BillingLineItem>
|
||||
{
|
||||
BillingLineItem.Create(
|
||||
itemType: "Subscription",
|
||||
description: $"套餐 {package.Name} 订阅费用",
|
||||
quantity: 1,
|
||||
unitPrice: amountDue.Value)
|
||||
};
|
||||
|
||||
// 5. 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
return new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = subscription.TenantId,
|
||||
StatementNo = GenerateStatementNo(),
|
||||
BillingType = BillingType.Subscription,
|
||||
SubscriptionId = subscription.Id,
|
||||
PeriodStart = subscription.EffectiveFrom,
|
||||
PeriodEnd = subscription.EffectiveTo,
|
||||
AmountDue = amountDue.Value,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = now.AddDays(7),
|
||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
||||
Notes = subscription.Notes
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
|
||||
long tenantId,
|
||||
QuotaPackage quotaPackage,
|
||||
int quantity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quotaPackage);
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。");
|
||||
}
|
||||
|
||||
// 1. 计算金额
|
||||
var amountDue = quotaPackage.Price * quantity;
|
||||
|
||||
// 2. 生成账单明细
|
||||
var lineItems = new List<BillingLineItem>
|
||||
{
|
||||
BillingLineItem.Create(
|
||||
itemType: "QuotaPurchase",
|
||||
description: $"配额包 {quotaPackage.Name} × {quantity}",
|
||||
quantity: quantity,
|
||||
unitPrice: quotaPackage.Price)
|
||||
};
|
||||
|
||||
// 3. 构建账单实体
|
||||
var now = DateTime.UtcNow;
|
||||
var billing = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = tenantId,
|
||||
StatementNo = GenerateStatementNo(),
|
||||
BillingType = BillingType.QuotaPurchase,
|
||||
SubscriptionId = null,
|
||||
PeriodStart = now,
|
||||
PeriodEnd = now,
|
||||
AmountDue = amountDue,
|
||||
DiscountAmount = 0m,
|
||||
TaxAmount = 0m,
|
||||
AmountPaid = 0m,
|
||||
Currency = "CNY",
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = now.AddDays(7),
|
||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
||||
Notes = quotaPackage.Description
|
||||
};
|
||||
|
||||
return Task.FromResult(billing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateStatementNo()
|
||||
{
|
||||
// 1. 账单号格式:BILL-{yyyyMMdd}-{序号}
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
|
||||
// 2. 使用雪花 ID 作为全局递增序号,确保分布式唯一
|
||||
var sequence = idGenerator.NextId();
|
||||
return $"BILL-{date}-{sequence}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
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 updated = 0;
|
||||
foreach (var billing in overdueBillings)
|
||||
{
|
||||
if (billing.Status != TenantBillingStatus.Pending)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
billing.MarkAsOverdue();
|
||||
billing.OverdueNotifiedAt ??= processedAt;
|
||||
billing.UpdatedAt = processedAt;
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updated++;
|
||||
}
|
||||
|
||||
// 3. (空行后) 持久化
|
||||
if (updated > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount)
|
||||
{
|
||||
return baseAmount - discountAmount + taxAmount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanProcessPayment(TenantBillingStatement billing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(billing);
|
||||
|
||||
return billing.Status switch
|
||||
{
|
||||
TenantBillingStatus.Pending => true,
|
||||
TenantBillingStatus.Overdue => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 账单导出服务实现(Excel/PDF/CSV)。
|
||||
/// </summary>
|
||||
public sealed class BillingExportService : IBillingExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化导出服务并配置 QuestPDF 许可证。
|
||||
/// </summary>
|
||||
public BillingExportService()
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(billings);
|
||||
|
||||
// 1. 创建工作簿与工作表
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.Worksheets.Add("Billings");
|
||||
|
||||
// 2. 写入表头
|
||||
var headers = new[]
|
||||
{
|
||||
"Id", "TenantId", "StatementNo", "BillingType", "Status",
|
||||
"PeriodStart", "PeriodEnd", "AmountDue", "DiscountAmount", "TaxAmount", "TotalAmount",
|
||||
"AmountPaid", "Currency", "DueDate", "Notes", "LineItemsJson"
|
||||
};
|
||||
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
worksheet.Cell(1, i + 1).Value = headers[i];
|
||||
}
|
||||
|
||||
// 3. 写入数据行
|
||||
for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var billing = billings[rowIndex];
|
||||
var totalAmount = billing.CalculateTotalAmount();
|
||||
var r = rowIndex + 2;
|
||||
|
||||
worksheet.Cell(r, 1).Value = billing.Id;
|
||||
worksheet.Cell(r, 2).Value = billing.TenantId;
|
||||
worksheet.Cell(r, 3).Value = billing.StatementNo;
|
||||
worksheet.Cell(r, 4).Value = billing.BillingType.ToString();
|
||||
worksheet.Cell(r, 5).Value = billing.Status.ToString();
|
||||
worksheet.Cell(r, 6).Value = billing.PeriodStart.ToString("O", CultureInfo.InvariantCulture);
|
||||
worksheet.Cell(r, 7).Value = billing.PeriodEnd.ToString("O", CultureInfo.InvariantCulture);
|
||||
worksheet.Cell(r, 8).Value = billing.AmountDue;
|
||||
worksheet.Cell(r, 9).Value = billing.DiscountAmount;
|
||||
worksheet.Cell(r, 10).Value = billing.TaxAmount;
|
||||
worksheet.Cell(r, 11).Value = totalAmount;
|
||||
worksheet.Cell(r, 12).Value = billing.AmountPaid;
|
||||
worksheet.Cell(r, 13).Value = billing.Currency;
|
||||
worksheet.Cell(r, 14).Value = billing.DueDate.ToString("O", CultureInfo.InvariantCulture);
|
||||
worksheet.Cell(r, 15).Value = billing.Notes ?? string.Empty;
|
||||
worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty;
|
||||
}
|
||||
|
||||
// 4. 自动调整列宽并输出
|
||||
worksheet.Columns().AdjustToContents();
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return Task.FromResult(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(billings);
|
||||
|
||||
// 1. 生成 PDF 文档(避免复杂表格,按条目输出)
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(20);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
column.Spacing(6);
|
||||
|
||||
// 2. 标题
|
||||
column.Item().Text("Billings Export").FontSize(16).SemiBold();
|
||||
|
||||
// 3. 逐条输出
|
||||
for (var i = 0; i < billings.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var b = billings[i];
|
||||
var total = b.CalculateTotalAmount();
|
||||
|
||||
column.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(item =>
|
||||
{
|
||||
item.Spacing(2);
|
||||
item.Item().Text($"StatementNo: {b.StatementNo}");
|
||||
item.Item().Text($"TenantId: {b.TenantId} BillingType: {b.BillingType} Status: {b.Status}");
|
||||
item.Item().Text($"Period: {b.PeriodStart:yyyy-MM-dd} ~ {b.PeriodEnd:yyyy-MM-dd} DueDate: {b.DueDate:yyyy-MM-dd}");
|
||||
item.Item().Text($"AmountDue: {b.AmountDue:0.##} Discount: {b.DiscountAmount:0.##} Tax: {b.TaxAmount:0.##}");
|
||||
item.Item().Text($"Total: {total:0.##} Paid: {b.AmountPaid:0.##} Currency: {b.Currency}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(b.Notes))
|
||||
{
|
||||
item.Item().Text($"Notes: {b.Notes}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 4. 输出字节
|
||||
var bytes = document.GeneratePdf();
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(billings);
|
||||
|
||||
// 1. 使用 UTF-8 BOM,便于 Excel 直接打开
|
||||
await using var stream = new MemoryStream();
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
|
||||
|
||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true
|
||||
};
|
||||
|
||||
await using var csv = new CsvWriter(writer, config);
|
||||
|
||||
// 2. 写入表头
|
||||
csv.WriteField("Id");
|
||||
csv.WriteField("TenantId");
|
||||
csv.WriteField("StatementNo");
|
||||
csv.WriteField("BillingType");
|
||||
csv.WriteField("Status");
|
||||
csv.WriteField("PeriodStart");
|
||||
csv.WriteField("PeriodEnd");
|
||||
csv.WriteField("AmountDue");
|
||||
csv.WriteField("DiscountAmount");
|
||||
csv.WriteField("TaxAmount");
|
||||
csv.WriteField("TotalAmount");
|
||||
csv.WriteField("AmountPaid");
|
||||
csv.WriteField("Currency");
|
||||
csv.WriteField("DueDate");
|
||||
csv.WriteField("Notes");
|
||||
csv.WriteField("LineItemsJson");
|
||||
await csv.NextRecordAsync();
|
||||
|
||||
// 3. 写入数据行
|
||||
foreach (var b in billings)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var total = b.CalculateTotalAmount();
|
||||
|
||||
csv.WriteField(b.Id);
|
||||
csv.WriteField(b.TenantId);
|
||||
csv.WriteField(b.StatementNo);
|
||||
csv.WriteField(b.BillingType.ToString());
|
||||
csv.WriteField(b.Status.ToString());
|
||||
csv.WriteField(b.PeriodStart.ToString("O", CultureInfo.InvariantCulture));
|
||||
csv.WriteField(b.PeriodEnd.ToString("O", CultureInfo.InvariantCulture));
|
||||
csv.WriteField(b.AmountDue);
|
||||
csv.WriteField(b.DiscountAmount);
|
||||
csv.WriteField(b.TaxAmount);
|
||||
csv.WriteField(total);
|
||||
csv.WriteField(b.AmountPaid);
|
||||
csv.WriteField(b.Currency);
|
||||
csv.WriteField(b.DueDate.ToString("O", CultureInfo.InvariantCulture));
|
||||
csv.WriteField(b.Notes ?? string.Empty);
|
||||
csv.WriteField(b.LineItemsJson ?? string.Empty);
|
||||
|
||||
await csv.NextRecordAsync();
|
||||
}
|
||||
|
||||
// 4. Flush 并返回字节
|
||||
await writer.FlushAsync(cancellationToken);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 配送范围检测服务实现。
|
||||
/// </summary>
|
||||
public sealed class DeliveryZoneService : IDeliveryZoneService
|
||||
{
|
||||
private const double CoordinateTolerance = 1e-6;
|
||||
|
||||
/// <inheritdoc />
|
||||
public StoreDeliveryCheckResultDto CheckPointInZones(
|
||||
IReadOnlyList<StoreDeliveryZone> zones,
|
||||
double longitude,
|
||||
double latitude)
|
||||
{
|
||||
// 1. 无配送区域直接返回
|
||||
if (zones is null || zones.Count == 0)
|
||||
{
|
||||
return new StoreDeliveryCheckResultDto { InRange = false };
|
||||
}
|
||||
// 2. (空行后) 逐个检测多边形命中
|
||||
foreach (var zone in zones)
|
||||
{
|
||||
if (!TryReadPolygons(zone.PolygonGeoJson, out var polygons))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var polygon in polygons)
|
||||
{
|
||||
if (!IsPointInPolygon(polygon, longitude, latitude))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return new StoreDeliveryCheckResultDto
|
||||
{
|
||||
InRange = true,
|
||||
DeliveryZoneId = zone.Id,
|
||||
DeliveryZoneName = zone.ZoneName
|
||||
};
|
||||
}
|
||||
}
|
||||
// 3. (空行后) 未命中任何区域
|
||||
return new StoreDeliveryCheckResultDto { InRange = false };
|
||||
}
|
||||
|
||||
private static bool TryReadPolygons(string geoJson, out List<Polygon> polygons)
|
||||
{
|
||||
polygons = [];
|
||||
if (string.IsNullOrWhiteSpace(geoJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(geoJson);
|
||||
if (!TryReadPolygons(document.RootElement, polygons))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return polygons.Count > 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPolygons(JsonElement root, ICollection<Polygon> polygons)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var inner = root.GetString();
|
||||
if (string.IsNullOrWhiteSpace(inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
using var innerDocument = JsonDocument.Parse(inner);
|
||||
return TryReadPolygons(innerDocument.RootElement, polygons);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(root, "type", out var typeElement)
|
||||
|| typeElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var type = typeElement.GetString();
|
||||
if (string.Equals(type, "FeatureCollection", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(root, "features", out var featuresElement)
|
||||
|| featuresElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var featureElement in featuresElement.EnumerateArray())
|
||||
{
|
||||
if (featureElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!TryGetPropertyIgnoreCase(featureElement, "geometry", out var geometryElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TryReadPolygons(geometryElement, polygons);
|
||||
}
|
||||
return polygons.Count > 0;
|
||||
}
|
||||
|
||||
if (string.Equals(type, "Feature", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(root, "geometry", out var geometryElement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return TryReadPolygons(geometryElement, polygons);
|
||||
}
|
||||
|
||||
if (string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!TryReadPolygonFromCoordinates(coordinatesElement, out var polygon))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
polygons.Add(polygon);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(type, "MultiPolygon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement)
|
||||
|| coordinatesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var polygonElement in coordinatesElement.EnumerateArray())
|
||||
{
|
||||
if (!TryReadPolygonFromCoordinates(polygonElement, out var polygon))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
polygons.Add(polygon);
|
||||
}
|
||||
return polygons.Count > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryReadPolygonFromCoordinates(JsonElement coordinatesElement, out Polygon polygon)
|
||||
{
|
||||
polygon = default!;
|
||||
if (!TryReadRings(coordinatesElement, out var rings) || rings.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var outer = rings[0];
|
||||
if (outer.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var holes = rings.Count > 1 ? rings.Skip(1).ToList() : [];
|
||||
polygon = new Polygon(outer, holes);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadRings(JsonElement element, out List<List<Point>> rings)
|
||||
{
|
||||
rings = [];
|
||||
if (element.ValueKind != JsonValueKind.Array || element.GetArrayLength() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsPositionArray(element[0]))
|
||||
{
|
||||
if (!TryReadRing(element, out var ring))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rings.Add(ring);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var ringElement in element.EnumerateArray())
|
||||
{
|
||||
if (!TryReadRing(ringElement, out var ring))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
rings.Add(ring);
|
||||
}
|
||||
|
||||
return rings.Count > 0;
|
||||
}
|
||||
|
||||
private static bool TryReadRing(JsonElement ringElement, out List<Point> ring)
|
||||
{
|
||||
ring = [];
|
||||
if (ringElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var pointElement in ringElement.EnumerateArray())
|
||||
{
|
||||
if (!TryReadPosition(pointElement, out var point))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ring.Add(point);
|
||||
}
|
||||
|
||||
if (ring.Count >= 2 && AreSamePoint(ring[0], ring[^1]))
|
||||
{
|
||||
ring.RemoveAt(ring.Count - 1);
|
||||
}
|
||||
|
||||
return ring.Count >= 3;
|
||||
}
|
||||
|
||||
private static bool TryReadPosition(JsonElement pointElement, out Point point)
|
||||
{
|
||||
point = default;
|
||||
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetCoordinate(pointElement[0], out var longitude)
|
||||
|| !TryGetCoordinate(pointElement[1], out var latitude))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
point = new Point(longitude, latitude);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetCoordinate(JsonElement element, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return element.TryGetDouble(out value);
|
||||
}
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPositionArray(JsonElement element)
|
||||
=> element.ValueKind == JsonValueKind.Array
|
||||
&& element.GetArrayLength() >= 2
|
||||
&& TryGetCoordinate(element[0], out _)
|
||||
&& TryGetCoordinate(element[1], out _);
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPointInPolygon(Polygon polygon, double x, double y)
|
||||
{
|
||||
if (!IsPointInRing(polygon.Outer, x, y))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var hole in polygon.Holes)
|
||||
{
|
||||
if (IsPointInRing(hole, x, y))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPointInRing(IReadOnlyList<Point> ring, double x, double y)
|
||||
{
|
||||
if (IsPointOnBoundary(ring, x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var inside = false;
|
||||
for (var i = 0; i < ring.Count; i++)
|
||||
{
|
||||
var j = i == 0 ? ring.Count - 1 : i - 1;
|
||||
var xi = ring[i].Longitude;
|
||||
var yi = ring[i].Latitude;
|
||||
var xj = ring[j].Longitude;
|
||||
var yj = ring[j].Latitude;
|
||||
var intersects = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi);
|
||||
if (intersects)
|
||||
{
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static bool IsPointOnBoundary(IReadOnlyList<Point> ring, double x, double y)
|
||||
{
|
||||
for (var i = 0; i < ring.Count; i++)
|
||||
{
|
||||
var j = i == ring.Count - 1 ? 0 : i + 1;
|
||||
if (IsPointOnSegment(ring[i], ring[j], x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPointOnSegment(Point start, Point end, double x, double y)
|
||||
{
|
||||
var cross = (end.Longitude - start.Longitude) * (y - start.Latitude)
|
||||
- (end.Latitude - start.Latitude) * (x - start.Longitude);
|
||||
if (Math.Abs(cross) > CoordinateTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dot = (x - start.Longitude) * (x - end.Longitude)
|
||||
+ (y - start.Latitude) * (y - end.Latitude);
|
||||
return dot <= CoordinateTolerance;
|
||||
}
|
||||
|
||||
private static bool AreSamePoint(Point first, Point second)
|
||||
=> Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance
|
||||
&& Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance;
|
||||
|
||||
private readonly record struct Point(double Longitude, double Latitude);
|
||||
|
||||
private sealed record Polygon(IReadOnlyList<Point> Outer, IReadOnlyList<IReadOnlyList<Point>> Holes);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// GeoJSON 校验服务实现。
|
||||
/// </summary>
|
||||
public sealed class GeoJsonValidationService : IGeoJsonValidationService
|
||||
{
|
||||
private const double CoordinateTolerance = 1e-6;
|
||||
|
||||
/// <inheritdoc />
|
||||
public GeoJsonValidationResult ValidatePolygon(string geoJson)
|
||||
{
|
||||
// 1. 基础校验
|
||||
if (string.IsNullOrWhiteSpace(geoJson))
|
||||
{
|
||||
return BuildInvalid("GeoJSON 不能为空");
|
||||
}
|
||||
// 2. (空行后) 解析与验证结构
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(geoJson);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return BuildInvalid("GeoJSON 格式错误");
|
||||
}
|
||||
if (!root.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return BuildInvalid("GeoJSON 缺少 type");
|
||||
}
|
||||
var type = typeElement.GetString();
|
||||
if (!string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildInvalid("仅支持 Polygon 类型");
|
||||
}
|
||||
if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return BuildInvalid("GeoJSON 缺少 coordinates");
|
||||
}
|
||||
if (coordinatesElement.GetArrayLength() == 0)
|
||||
{
|
||||
return BuildInvalid("GeoJSON coordinates 为空");
|
||||
}
|
||||
var ringElement = coordinatesElement[0];
|
||||
if (ringElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return BuildInvalid("GeoJSON 坐标格式错误");
|
||||
}
|
||||
var points = new List<Point>();
|
||||
foreach (var pointElement in ringElement.EnumerateArray())
|
||||
{
|
||||
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
|
||||
{
|
||||
return BuildInvalid("坐标点格式错误");
|
||||
}
|
||||
if (!pointElement[0].TryGetDouble(out var longitude) || !pointElement[1].TryGetDouble(out var latitude))
|
||||
{
|
||||
return BuildInvalid("坐标点必须为数值");
|
||||
}
|
||||
points.Add(new Point(longitude, latitude));
|
||||
}
|
||||
if (points.Count < 3)
|
||||
{
|
||||
return BuildInvalid("多边形至少需要 3 个点");
|
||||
}
|
||||
var distinctCount = CountDistinct(points);
|
||||
if (distinctCount < 3)
|
||||
{
|
||||
return BuildInvalid("多边形坐标点不足");
|
||||
}
|
||||
var normalized = Normalize(points, out var normalizedJson);
|
||||
if (normalized.Count < 4)
|
||||
{
|
||||
return BuildInvalid("多边形至少需要 4 个点(含闭合点)");
|
||||
}
|
||||
if (HasSelfIntersection(normalized))
|
||||
{
|
||||
return BuildInvalid("多边形存在自相交");
|
||||
}
|
||||
return new GeoJsonValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
NormalizedGeoJson = normalizedJson
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BuildInvalid("GeoJSON 解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
private static GeoJsonValidationResult BuildInvalid(string message) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = message
|
||||
};
|
||||
|
||||
private static int CountDistinct(IReadOnlyList<Point> points)
|
||||
{
|
||||
var distinct = new List<Point>();
|
||||
foreach (var point in points)
|
||||
{
|
||||
if (distinct.Any(existing => AreSamePoint(existing, point)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
distinct.Add(point);
|
||||
}
|
||||
return distinct.Count;
|
||||
}
|
||||
|
||||
private static List<Point> Normalize(IReadOnlyList<Point> points, out string? normalizedJson)
|
||||
{
|
||||
var normalized = new List<Point>(points);
|
||||
if (!AreSamePoint(normalized[0], normalized[^1]))
|
||||
{
|
||||
normalized.Add(normalized[0]);
|
||||
normalizedJson = BuildGeoJson(normalized);
|
||||
return normalized;
|
||||
}
|
||||
normalizedJson = null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string BuildGeoJson(IReadOnlyList<Point> points)
|
||||
{
|
||||
var coordinates = points
|
||||
.Select(point => new[] { point.Longitude, point.Latitude })
|
||||
.ToArray();
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
{ "type", "Polygon" },
|
||||
{ "coordinates", new[] { coordinates } }
|
||||
};
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private static bool HasSelfIntersection(IReadOnlyList<Point> points)
|
||||
{
|
||||
var segmentCount = points.Count - 1;
|
||||
for (var i = 0; i < segmentCount; i++)
|
||||
{
|
||||
var a1 = points[i];
|
||||
var a2 = points[i + 1];
|
||||
for (var j = i + 1; j < segmentCount; j++)
|
||||
{
|
||||
if (Math.Abs(i - j) <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (i == 0 && j == segmentCount - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var b1 = points[j];
|
||||
var b2 = points[j + 1];
|
||||
if (SegmentsIntersect(a1, a2, b1, b2))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool SegmentsIntersect(Point p1, Point q1, Point p2, Point q2)
|
||||
{
|
||||
var o1 = Orientation(p1, q1, p2);
|
||||
var o2 = Orientation(p1, q1, q2);
|
||||
var o3 = Orientation(p2, q2, p1);
|
||||
var o4 = Orientation(p2, q2, q1);
|
||||
|
||||
if (o1 != o2 && o3 != o4)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (o1 == 0 && OnSegment(p1, p2, q1))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (o2 == 0 && OnSegment(p1, q2, q1))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (o3 == 0 && OnSegment(p2, p1, q2))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (o4 == 0 && OnSegment(p2, q1, q2))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int Orientation(Point p, Point q, Point r)
|
||||
{
|
||||
var value = (q.Latitude - p.Latitude) * (r.Longitude - q.Longitude)
|
||||
- (q.Longitude - p.Longitude) * (r.Latitude - q.Latitude);
|
||||
if (Math.Abs(value) <= CoordinateTolerance)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return value > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
private static bool OnSegment(Point p, Point q, Point r)
|
||||
=> q.Longitude <= Math.Max(p.Longitude, r.Longitude) + CoordinateTolerance
|
||||
&& q.Longitude >= Math.Min(p.Longitude, r.Longitude) - CoordinateTolerance
|
||||
&& q.Latitude <= Math.Max(p.Latitude, r.Latitude) + CoordinateTolerance
|
||||
&& q.Latitude >= Math.Min(p.Latitude, r.Latitude) - CoordinateTolerance;
|
||||
|
||||
private static bool AreSamePoint(Point first, Point second)
|
||||
=> Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance
|
||||
&& Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance;
|
||||
|
||||
private readonly record struct Point(double Longitude, double Latitude);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 商户导出服务实现(PDF)。
|
||||
/// </summary>
|
||||
public sealed class MerchantExportService : IMerchantExportService
|
||||
{
|
||||
public MerchantExportService()
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ExportToPdfAsync(
|
||||
Merchant merchant,
|
||||
string? tenantName,
|
||||
IReadOnlyList<Store> stores,
|
||||
IReadOnlyList<MerchantAuditLog> auditLogs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(merchant);
|
||||
|
||||
var safeStores = stores ?? Array.Empty<Store>();
|
||||
var safeAuditLogs = auditLogs ?? Array.Empty<MerchantAuditLog>();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(24);
|
||||
page.DefaultTextStyle(x => x.FontSize(10));
|
||||
|
||||
page.Content().Column(column =>
|
||||
{
|
||||
column.Spacing(10);
|
||||
column.Item().Text("Merchant Export").FontSize(16).SemiBold();
|
||||
|
||||
column.Item().Element(section => BuildBasicSection(section, merchant, tenantName));
|
||||
column.Item().Element(section => BuildStoresSection(section, safeStores, cancellationToken));
|
||||
column.Item().Element(section => BuildAuditSection(section, safeAuditLogs, cancellationToken));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Task.FromResult(document.GeneratePdf());
|
||||
}
|
||||
|
||||
private static void BuildBasicSection(IContainer container, Merchant merchant, string? tenantName)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Basic Information").SemiBold();
|
||||
column.Item().Text($"Merchant: {merchant.BrandName}");
|
||||
column.Item().Text($"Tenant: {tenantName ?? "-"} (ID: {merchant.TenantId})");
|
||||
column.Item().Text($"Operating Mode: {ResolveOperatingMode(merchant.OperatingMode)}");
|
||||
column.Item().Text($"Status: {merchant.Status}");
|
||||
column.Item().Text($"Frozen: {(merchant.IsFrozen ? "Yes" : "No")}");
|
||||
column.Item().Text($"License Number: {merchant.BusinessLicenseNumber ?? "-"}");
|
||||
column.Item().Text($"Legal Representative: {merchant.LegalPerson ?? "-"}");
|
||||
column.Item().Text($"Registered Address: {merchant.Address ?? "-"}");
|
||||
column.Item().Text($"Contact Phone: {merchant.ContactPhone}");
|
||||
column.Item().Text($"Contact Email: {merchant.ContactEmail ?? "-"}");
|
||||
column.Item().Text($"Approved At: {FormatDateTime(merchant.ApprovedAt)}");
|
||||
column.Item().Text($"Approved By: {merchant.ApprovedBy?.ToString() ?? "-"}");
|
||||
});
|
||||
}
|
||||
|
||||
private static void BuildStoresSection(IContainer container, IReadOnlyList<Store> stores, CancellationToken cancellationToken)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Stores").SemiBold();
|
||||
|
||||
if (stores.Count == 0)
|
||||
{
|
||||
column.Item().Text("No stores.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < stores.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var store = stores[i];
|
||||
column.Item().Text($"{i + 1}. {store.Name} | {ResolveStoreStatus(store.Status)} | {store.Address ?? "-"} | {store.Phone ?? "-"}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void BuildAuditSection(IContainer container, IReadOnlyList<MerchantAuditLog> auditLogs, CancellationToken cancellationToken)
|
||||
{
|
||||
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
|
||||
{
|
||||
column.Spacing(4);
|
||||
column.Item().Text("Audit History").SemiBold();
|
||||
|
||||
if (auditLogs.Count == 0)
|
||||
{
|
||||
column.Item().Text("No audit records.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < auditLogs.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var log = auditLogs[i];
|
||||
var title = string.IsNullOrWhiteSpace(log.Title) ? log.Action.ToString() : log.Title;
|
||||
column.Item().Text($"{i + 1}. {title} | {log.OperatorName ?? "-"} | {FormatDateTime(log.CreatedAt)}");
|
||||
if (!string.IsNullOrWhiteSpace(log.Description))
|
||||
{
|
||||
column.Item().Text($" {log.Description}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveOperatingMode(OperatingMode? mode)
|
||||
=> mode switch
|
||||
{
|
||||
OperatingMode.SameEntity => "SameEntity",
|
||||
OperatingMode.DifferentEntity => "DifferentEntity",
|
||||
_ => "-"
|
||||
};
|
||||
|
||||
private static string ResolveStoreStatus(StoreStatus status)
|
||||
=> status switch
|
||||
{
|
||||
StoreStatus.Closed => "Closed",
|
||||
StoreStatus.Preparing => "Preparing",
|
||||
StoreStatus.Operating => "Operating",
|
||||
StoreStatus.Suspended => "Suspended",
|
||||
_ => status.ToString()
|
||||
};
|
||||
|
||||
private static string FormatDateTime(DateTime? value)
|
||||
=> value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "-";
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System.Globalization;
|
||||
using TakeoutSaaS.Application.App.Stores;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 门店费用计算服务实现。
|
||||
/// </summary>
|
||||
public sealed class StoreFeeCalculationService : IStoreFeeCalculationService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public StoreFeeCalculationResultDto Calculate(StoreFee fee, StoreFeeCalculationRequestDto request)
|
||||
{
|
||||
// 1. 计算起送费满足情况
|
||||
var minimum = fee.MinimumOrderAmount;
|
||||
if (request.OrderAmount < minimum)
|
||||
{
|
||||
var shortfall = minimum - request.OrderAmount;
|
||||
var message = $"还差{shortfall.ToString("0.##", CultureInfo.InvariantCulture)}元起送";
|
||||
return new StoreFeeCalculationResultDto
|
||||
{
|
||||
OrderAmount = request.OrderAmount,
|
||||
MinimumOrderAmount = minimum,
|
||||
MeetsMinimum = false,
|
||||
Shortfall = shortfall,
|
||||
DeliveryFee = 0m,
|
||||
PackagingFee = 0m,
|
||||
PackagingFeeMode = fee.PackagingFeeMode,
|
||||
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||
TotalFee = 0m,
|
||||
TotalAmount = request.OrderAmount,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
// 2. (空行后) 计算配送费
|
||||
var deliveryFee = fee.BaseDeliveryFee;
|
||||
if (fee.FreeDeliveryThreshold.HasValue && request.OrderAmount >= fee.FreeDeliveryThreshold.Value)
|
||||
{
|
||||
deliveryFee = 0m;
|
||||
}
|
||||
|
||||
// 3. (空行后) 计算打包费
|
||||
var packagingFee = 0m;
|
||||
IReadOnlyList<StoreFeeCalculationBreakdownDto>? breakdown = null;
|
||||
if (fee.PackagingFeeMode == PackagingFeeMode.Fixed)
|
||||
{
|
||||
if (fee.OrderPackagingFeeMode == OrderPackagingFeeMode.Tiered)
|
||||
{
|
||||
var tiers = StoreFeeTierHelper.Deserialize(fee.PackagingFeeTiersJson);
|
||||
if (tiers.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "阶梯打包费配置缺失");
|
||||
}
|
||||
packagingFee = ResolveTieredFee(request.OrderAmount, tiers);
|
||||
}
|
||||
else
|
||||
{
|
||||
packagingFee = fee.FixedPackagingFee;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.Items.Count == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "商品计费模式必须提供商品列表");
|
||||
}
|
||||
var list = new List<StoreFeeCalculationBreakdownDto>(request.Items.Count);
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var subtotal = item.PackagingFee * item.Quantity;
|
||||
packagingFee += subtotal;
|
||||
list.Add(new StoreFeeCalculationBreakdownDto
|
||||
{
|
||||
SkuId = item.SkuId,
|
||||
Quantity = item.Quantity,
|
||||
UnitFee = item.PackagingFee,
|
||||
Subtotal = subtotal
|
||||
});
|
||||
}
|
||||
breakdown = list;
|
||||
}
|
||||
|
||||
// 4. (空行后) 汇总结果
|
||||
var totalFee = deliveryFee + packagingFee;
|
||||
var totalAmount = request.OrderAmount + totalFee;
|
||||
return new StoreFeeCalculationResultDto
|
||||
{
|
||||
OrderAmount = request.OrderAmount,
|
||||
MinimumOrderAmount = minimum,
|
||||
MeetsMinimum = true,
|
||||
DeliveryFee = deliveryFee,
|
||||
PackagingFee = packagingFee,
|
||||
PackagingFeeMode = fee.PackagingFeeMode,
|
||||
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
|
||||
PackagingFeeBreakdown = breakdown,
|
||||
TotalFee = totalFee,
|
||||
TotalAmount = totalAmount
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ResolveTieredFee(decimal orderAmount, IReadOnlyList<StoreFeeTierDto> tiers)
|
||||
{
|
||||
foreach (var tier in tiers)
|
||||
{
|
||||
if (orderAmount < tier.MinPrice)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!tier.MaxPrice.HasValue || orderAmount <= tier.MaxPrice.Value)
|
||||
{
|
||||
return tier.Fee;
|
||||
}
|
||||
}
|
||||
|
||||
return tiers[^1].Fee;
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店定时任务服务实现。
|
||||
/// </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
|
||||
.Where(store => store.DeletedAt == null
|
||||
&& store.AuditStatus == StoreAuditStatus.Activated
|
||||
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (stores.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. (空行后) 读取营业时段与休息日
|
||||
var storeIds = stores.Select(store => store.Id).ToArray();
|
||||
var hours = await context.StoreBusinessHours
|
||||
.AsNoTracking()
|
||||
.Where(hour => storeIds.Contains(hour.StoreId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var today = now.Date;
|
||||
var holidays = await context.StoreHolidays
|
||||
.AsNoTracking()
|
||||
.Where(holiday => storeIds.Contains(holiday.StoreId)
|
||||
&& holiday.Date <= today
|
||||
&& (holiday.EndDate == null || holiday.EndDate >= today))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. (空行后) 构造查找表
|
||||
var hoursLookup = hours
|
||||
.GroupBy(hour => hour.StoreId)
|
||||
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreBusinessHour>)group.ToList());
|
||||
var holidayLookup = holidays
|
||||
.GroupBy(holiday => holiday.StoreId)
|
||||
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreHoliday>)group.ToList());
|
||||
|
||||
// 4. (空行后) 判定状态并更新
|
||||
var updated = 0;
|
||||
foreach (var store in stores)
|
||||
{
|
||||
// 4.1 跳过强制关闭门店
|
||||
if (store.BusinessStatus == StoreBusinessStatus.ForceClosed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4.2 (空行后) 尊重手动歇业原因
|
||||
if (store.ClosureReason.HasValue && store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4.3 (空行后) 计算营业状态
|
||||
var storeHolidays = holidayLookup.TryGetValue(store.Id, out var matched) ? matched : [];
|
||||
var nowTime = now.TimeOfDay;
|
||||
var isHolidayClosed = storeHolidays.Any(holiday =>
|
||||
holiday.OverrideType == OverrideType.Closed && IsWithinHolidayTime(holiday, nowTime));
|
||||
var hasModifiedHours = storeHolidays.Any(holiday => holiday.OverrideType == OverrideType.ModifiedHours);
|
||||
var isModifiedOpen = hasModifiedHours && storeHolidays.Any(holiday =>
|
||||
holiday.OverrideType == OverrideType.ModifiedHours && IsWithinHolidayTime(holiday, nowTime));
|
||||
var isTemporaryOpen = storeHolidays.Any(holiday =>
|
||||
holiday.OverrideType == OverrideType.TemporaryOpen && IsWithinHolidayTime(holiday, nowTime));
|
||||
var hasHours = hoursLookup.TryGetValue(store.Id, out var storeHours) && storeHours.Count > 0;
|
||||
var isOpen = false;
|
||||
if (isHolidayClosed)
|
||||
{
|
||||
isOpen = false;
|
||||
}
|
||||
else if (hasModifiedHours)
|
||||
{
|
||||
isOpen = isModifiedOpen;
|
||||
}
|
||||
else
|
||||
{
|
||||
isOpen = hasHours && IsWithinBusinessHours(storeHours ?? [], now);
|
||||
if (!isOpen && isTemporaryOpen)
|
||||
{
|
||||
isOpen = true;
|
||||
}
|
||||
}
|
||||
if (isOpen)
|
||||
{
|
||||
if (store.BusinessStatus != StoreBusinessStatus.Open)
|
||||
{
|
||||
store.BusinessStatus = StoreBusinessStatus.Open;
|
||||
store.ClosureReason = null;
|
||||
store.ClosureReasonText = null;
|
||||
updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4.4 (空行后) 非营业时段切换为休息
|
||||
if (store.BusinessStatus != StoreBusinessStatus.Resting || store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
|
||||
{
|
||||
store.BusinessStatus = StoreBusinessStatus.Resting;
|
||||
store.ClosureReason = StoreClosureReason.OutOfBusinessHours;
|
||||
store.ClosureReasonText = "非营业时间自动休息";
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. (空行后) 保存变更并记录日志
|
||||
if (updated > 0)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
logger.LogInformation("定时任务:营业状态自动切换完成,更新 {UpdatedCount} 家门店", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
var expiredStoreIds = await context.StoreQualifications
|
||||
.AsNoTracking()
|
||||
.Where(qualification => qualification.DeletedAt == null
|
||||
&& qualification.ExpiresAt.HasValue
|
||||
&& qualification.ExpiresAt.Value < today)
|
||||
.Select(qualification => qualification.StoreId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (expiredStoreIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. (空行后) 加载门店并更新状态
|
||||
var stores = await context.Stores
|
||||
.Where(store => expiredStoreIds.Contains(store.Id)
|
||||
&& store.DeletedAt == null
|
||||
&& store.AuditStatus == StoreAuditStatus.Activated
|
||||
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var updated = 0;
|
||||
foreach (var store in stores)
|
||||
{
|
||||
// 2.1 跳过已标记过期门店
|
||||
if (store.BusinessStatus == StoreBusinessStatus.Resting && store.ClosureReason == StoreClosureReason.LicenseExpired)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2.2 (空行后) 设置资质过期状态
|
||||
store.BusinessStatus = StoreBusinessStatus.Resting;
|
||||
store.ClosureReason = StoreClosureReason.LicenseExpired;
|
||||
store.ClosureReasonText = "证照过期自动休息";
|
||||
updated++;
|
||||
}
|
||||
|
||||
// 3. (空行后) 保存变更并记录日志
|
||||
if (updated > 0)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
logger.LogInformation("定时任务:资质过期检查完成,更新 {UpdatedCount} 家门店", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private static bool IsWithinBusinessHours(IReadOnlyList<StoreBusinessHour> hours, DateTime now)
|
||||
{
|
||||
// 1. 提取当前时间
|
||||
var day = now.DayOfWeek;
|
||||
var time = now.TimeOfDay;
|
||||
|
||||
foreach (var hour in hours)
|
||||
{
|
||||
if (hour.HourType == BusinessHourType.Closed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (hour.StartTime == hour.EndTime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (hour.StartTime < hour.EndTime)
|
||||
{
|
||||
if (hour.DayOfWeek == day && time >= hour.StartTime && time < hour.EndTime)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var nextDay = NextDay(hour.DayOfWeek);
|
||||
if (hour.DayOfWeek == day && time >= hour.StartTime)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (nextDay == day && time < hour.EndTime)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWithinHolidayTime(StoreHoliday holiday, TimeSpan time)
|
||||
{
|
||||
if (holiday.IsAllDay)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!holiday.StartTime.HasValue || !holiday.EndTime.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return time >= holiday.StartTime.Value && time < holiday.EndTime.Value;
|
||||
}
|
||||
|
||||
private static DayOfWeek NextDay(DayOfWeek day)
|
||||
{
|
||||
var next = (int)day + 1;
|
||||
return next > 6 ? DayOfWeek.Sunday : (DayOfWeek)next;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user