using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Domain.Catalog; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; internal sealed class ScorelineManagementService(DirectContentServiceDependencies dependencies) : DirectContentServiceBase(dependencies), IScorelineManagementService { public async Task> GetScorelineFieldsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ScorelineFields.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword)); } return new CatalogList(await query .OrderBy(item => item.SortOrder) .ThenBy(item => item.FieldName) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertScorelineFieldAsync( DirectContentActor actor, ScorelineFieldCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey); ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName); if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim())) throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid"); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found"); item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.FieldKey = command.FieldKey.Trim(); item.FieldName = command.FieldName.Trim(); item.FieldType = Normalize(command.FieldType) ?? "text"; item.Unit = Normalize(command.Unit); item.IsFilter = command.IsFilter ?? item.IsFilter; item.IsRequired = command.IsRequired ?? item.IsRequired; item.IsVisible = command.IsVisible ?? item.IsVisible; item.IsTrend = command.IsTrend ?? item.IsTrend; item.Options = JsonArrayOrDefault(command.Options); item.Placeholder = Normalize(command.Placeholder); item.Description = Normalize(command.Description); item.SortOrder = command.Order ?? item.SortOrder; if (isNew) dbContext.ScorelineFields.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetScorelineRecordsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); if (filter.MajorId.HasValue) query = query.Where(item => item.MajorId == filter.MajorId.Value); if (filter.Year.HasValue) query = query.Where(item => item.Year == filter.Year.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => (item.SchoolName != null && item.SchoolName.Contains(keyword)) || (item.MajorName != null && item.MajorName.Contains(keyword))); } return new CatalogList(await query .OrderByDescending(item => item.Year) .ThenBy(item => item.SchoolName) .ThenBy(item => item.MajorName) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken)); } public async Task> UpsertScorelineRecordAsync( DirectContentActor actor, ScorelineRecordCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (command.Year is < 1900 or > 3000) throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid"); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.MajorId, "major_not_found", cancellationToken); var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found"); item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId }; item.RegionId = command.RegionId; item.SchoolId = command.SchoolId; item.MajorId = command.MajorId; item.LegacyId = Normalize(command.LegacyId); item.Year = command.Year; item.SchoolName = Normalize(command.SchoolName); item.MajorName = Normalize(command.MajorName); item.FieldValues = JsonObjectOrDefault(command.FieldValues); if (isNew) dbContext.ScorelineRecords.Add(item); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(item); } public async Task> GetScorelineYearsAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (filter.RegionId.HasValue) query = query.Where(item => item.RegionId == filter.RegionId.Value); if (filter.SchoolId.HasValue) query = query.Where(item => item.SchoolId == filter.SchoolId.Value); var years = await query .Select(item => item.Year) .Distinct() .OrderByDescending(year => year) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); return new CatalogList(years); } public async Task> GetScorelineTrendAsync( DirectContentActor actor, AdminLimitFilter filter, CancellationToken cancellationToken = default) { var years = await GetScorelineYearsAsync(actor, filter, cancellationToken); var items = new List(); foreach (var year in years.Items) { var schoolCount = await dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Year == year) .Select(item => item.SchoolId) .Where(id => id.HasValue) .Distinct() .CountAsync(cancellationToken); var majorCount = await dbContext.ScorelineRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Year == year) .Select(item => item.MajorId) .Where(id => id.HasValue) .Distinct() .CountAsync(cancellationToken); items.Add(new ScorelineTrendItem(year, schoolCount, majorCount)); } return new CatalogList(items); } }