diff --git a/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs b/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs index bfe0cb00..883531d7 100644 --- a/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs +++ b/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs @@ -86,11 +86,11 @@ namespace PARR.DAL.IntegrationTests.Integration public async Task GetUnitsIdByJobFilterAsync_WithExistingJobId_ShouldReturnExpectedUnits() { // Arrange - var jobId = _context.Jobs.First(j => j.Name == "TestJob_1").Id; + var job = _context.Jobs.First(j => j.Name == "TestJob_1"); var expectedUnitIds = new[] { TestDataGenerator.UnitAId, TestDataGenerator.UnitBId }; // Act - var result = await _service.GetUnitsIdByJobFilterAsync(jobId, takeCount: 10); + var result = await _service.GetUnitsIdByJobFilterAsync(job, takeCount: 10); // Assert result.Should().NotBeNull() diff --git a/PARR.DAL/Cache/Models/UnitFilterIds.cs b/PARR.DAL/Cache/Models/UnitFilterIds.cs new file mode 100644 index 00000000..e37a010e --- /dev/null +++ b/PARR.DAL/Cache/Models/UnitFilterIds.cs @@ -0,0 +1,18 @@ +using PARR.DAL.Cache.Models.Base; + +namespace PARR.DAL.Cache.Models +{ + public class UnitFilterIds : IBaseCache + { + public required UnitFilterIdsDto Data { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + public required string Source { get; set; } + } + + public class UnitFilterIdsDto + { + public List UnitIds { get; set; } = new(); + } +} diff --git a/PARR.DAL/Cache/Services/Base/RedisCacheService.cs b/PARR.DAL/Cache/Services/Base/RedisCacheService.cs index bcf82be9..38c902be 100644 --- a/PARR.DAL/Cache/Services/Base/RedisCacheService.cs +++ b/PARR.DAL/Cache/Services/Base/RedisCacheService.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Caching.Distributed; -using PARR.DAL.Migrations; using System.Text.Json; namespace PARR.DAL.Cache.Services.Base diff --git a/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs b/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs index 09b2541e..b3a50076 100644 --- a/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs +++ b/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs @@ -11,9 +11,9 @@ namespace PARR.DAL.DomainServices.UnitFilterService /// ID Job /// Максимальное количество Unit'ов (опционально) /// Список ID Unit'ов или null, если Job не найден - Task?> GetUnitsIdByJobFilterAsync( - Guid jobId, - int? takeCount = null); + //Task?> GetUnitsIdByJobFilterAsync( + // Guid jobId, + // int? takeCount = null); /// /// Получает список ID Unit'ов, соответствующих фильтрам Job. diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/FilteredUnitContext.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/FilteredUnitContext.cs new file mode 100644 index 00000000..50bb68c1 --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/FilteredUnitContext.cs @@ -0,0 +1,9 @@ +namespace PARR.DAL.DomainServices.UnitFilterService.Models +{ + internal class FilteredUnitContext + { + public Guid UnitId { get; set; } + public HashSet ValidParentIds { get; set; } = new(); + public HashSet ValidChildIds { get; set; } = new(); + } +} diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs index 36ac1f79..d2e3adef 100644 --- a/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs @@ -30,4 +30,4 @@ /// public List Children { get; set; } = new(); } -} +} \ No newline at end of file diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterServiceOptions.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterServiceOptions.cs new file mode 100644 index 00000000..561f0264 --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterServiceOptions.cs @@ -0,0 +1,7 @@ +namespace PARR.DAL.DomainServices.UnitFilterService.Models +{ + internal class UnitFilterServiceOptions + { + public int LoadBatchSize { get; set; } = 1000; + } +} diff --git a/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs b/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs index 1f456b0b..e29ebca9 100644 --- a/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs +++ b/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PARR.DAL.Cache.Models; using PARR.DAL.Cache.Services.Base; using PARR.DAL.Contracts; using PARR.DAL.DomainServices.UnitFilterService.Models; @@ -7,16 +9,18 @@ using PARR.DAL.Models.Job; using PARR.DAL.Models.Unit; using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; -using System.Collections.Concurrent; +using System.Diagnostics; namespace PARR.DAL.DomainServices.UnitFilterService; internal class UnitFilterService : IUnitFilterService { #if DEBUG - private readonly Guid targetUnitId = Guid.Parse("ca9b4a9f-df74-48e1-95dc-588e36e3d5aa"); + private readonly Guid targetUnitId = Guid.Parse("0e84812c-66ee-46fc-bb72-2afbecf354cb"); #endif + private readonly int batchSize; + private readonly ILogger logger; private readonly IJobService jobService; private readonly IUnitService unitService; @@ -30,7 +34,8 @@ internal class UnitFilterService : IUnitFilterService IUnitService unitService, IUnitInUnitService unitInUnitService, IUnitInValueService unitInValueService, - IRedisCacheService cacheService + IRedisCacheService cacheService, + IOptions options ) { this.logger = logger; @@ -39,45 +44,17 @@ internal class UnitFilterService : IUnitFilterService this.unitInUnitService = unitInUnitService; this.cacheService = cacheService; this.unitInValueService = unitInValueService; + + batchSize = options.Value.LoadBatchSize; } - #region DTO классы - private class UnitInfoDto + + public async Task?> GetUnitsByJobFilterAsync(Guid jobId, int? takeCount = null) { - public Guid Id { get; set; } - public required string Name { get; set; } + var job = await LoadJobWithFiltersAsync(jobId); + return job == null ? null : await GetUnitsByJobFilterAsync(job, takeCount); } - private class UnitDto - { - public Guid Id { get; set; } - public string Name { get; set; } = null!; - public List Values { get; set; } = new(); - public List Parents { get; set; } = new(); - public List Children { get; set; } = new(); - } - - public class CachedUnitIds - { - public List UnitIds { get; set; } = new(); - public DateTimeOffset Timestamp { get; set; } - public string? Source { get; set; } = "UnitFilterService"; - public int Version { get; set; } = 1; - } - - #endregion - - public async Task?> GetUnitsIdByJobFilterAsync(Guid jobId, int? takeCount = null) - { - var job = await jobService - .Get().AsNoTracking() - .Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters) - .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters) - .Include(j => j.Group).ThenInclude(g => g!.GroupType) - .FirstOrDefaultAsync(j => j.Id == jobId); - - return job == null ? null : await GetUnitsIdByJobFilterAsync(job, takeCount); - } public async Task?> GetUnitsIdByJobFilterAsync(Job job, int? takeCount = null) { @@ -85,17 +62,6 @@ internal class UnitFilterService : IUnitFilterService return filteredUnits?.Select(u => u.Id).ToList(); } - public async Task?> GetUnitsByJobFilterAsync(Guid jobId, int? takeCount = null) - { - var job = await jobService - .Get().AsNoTracking() - .Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters) - .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters) - .Include(j => j.Group).ThenInclude(g => g!.GroupType) - .FirstOrDefaultAsync(j => j.Id == jobId); - - return job == null ? null : await GetUnitsByJobFilterAsync(job, takeCount); - } public async Task?> GetUnitsByJobFilterAsync(Job job, int? takeCount = null) { @@ -106,309 +72,615 @@ internal class UnitFilterService : IUnitFilterService if (job.UnitFilters == null || !job.UnitFilters.Any()) throw new ArgumentNullException(nameof(job.UnitFilters), $"Job {job.Id} не содержит UnitFilters"); - // Загружаем имена всех юнитов заранее для оптимизации - var allUnitNames = await LoadAllUnitNamesAsync(job.UnitFilters); + var totalStopwatch = Stopwatch.StartNew(); + logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count); - var maxCount = takeCount ?? int.MaxValue; - var collectedUnits = new ConcurrentBag(); - var filterNumber = 0; + // Собираем все контексты юнитов, прошедших фильтрацию + var allFilteredContexts = new List(); - foreach (var filter in job.UnitFilters) + // Преобразуем в список для индексации + var unitFiltersList = job.UnitFilters.ToList(); + + // Этап 1: Применение основных фильтров (Field, Relationship) на уровне SQL + for (int i = 0; i < unitFiltersList.Count; i++) { - filterNumber++; - if (collectedUnits.Count >= maxCount) break; - var remaining = maxCount - collectedUnits.Count; - if (remaining <= 0) break; + var filter = unitFiltersList[i]; + var filterStopwatch = Stopwatch.StartNew(); try { - logger.LogDebug("Применяем фильтр #{Index} (Id={FilterId})", filterNumber, filter.Id); + logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id); - // 1️ Найти ID юнитов по UnitFilter (Name LIKE) + // 1. Найти ID юнитов по UnitFilter (Name LIKE) var initialUnitIds = await GetUnitIdsFromCacheOrDbAsync(filter); - - logger.LogDebug("Базовый фильтр по Name '{NameFilter}' дал {Count} юнитов", filter.UnitFilter, initialUnitIds.Count); + if (!initialUnitIds.Any()) + { + logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов)", i + 1); + continue; + } #if DEBUG if (initialUnitIds.Contains(targetUnitId)) { - logger.LogDebug("Юнит {TargetUnitId} найден в initialUnitIds.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в initialUnitIds.", targetUnitId); + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} найден в initialUnitIds для фильтра #{Index}", targetUnitId, i + 1); } #endif - if (!initialUnitIds.Any()) + // 2. Применить FieldFilters на уровне SQL + var fieldStopwatch = Stopwatch.StartNew(); + var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters); + fieldStopwatch.Stop(); + + if (!fieldFilteredIds.Any()) { - logger.LogDebug("Фильтр #{Index}: 0 юнитов после UnitFilter. Пропускаем.", filterNumber); + logger.LogDebug("Фильтр #{Index}: 0 юнитов после FieldFilters", i + 1); continue; } - // 2️ Загрузить UnitValues для этих юнитов (только FieldId из FieldFilter) - var fieldFilterFieldIds = filter.FieldFilters.Select(f => f.FieldId).ToHashSet(); - var unitValues = fieldFilterFieldIds.Any() - ? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(initialUnitIds, fieldFilterFieldIds) - : new List(); - - // 3️ Загрузить связи - var parentRelFilters = filter.RelationshipFilters.Where(rf => rf.IsParent).ToList(); - var childRelFilters = filter.RelationshipFilters.Where(rf => !rf.IsParent).ToList(); - - var parentLinks = await unitInUnitService.GetParentLinksByChildIdsAsync(initialUnitIds); - var childLinks = await unitInUnitService.GetChildLinksByParentIdsAsync(initialUnitIds); - - // 4️ ID родителей и детей - var parentUnitIds = parentLinks.Select(l => l.ParentUnitId).ToHashSet(); - var childUnitIds = childLinks.Select(l => l.ChildUnitId).ToHashSet(); - - // 5️ Загрузить родительские и дочерние юниты (Id + Name) - var parentUnits = parentUnitIds.Any() - ? allUnitNames.Where(u => parentUnitIds.Contains(u.Id)).ToList() - : new List(); - - var childUnits = childUnitIds.Any() - ? allUnitNames.Where(u => childUnitIds.Contains(u.Id)).ToList() - : new List(); - - // 6️ UnitValues для родителей и детей (только FieldId из RelationshipFilter) - var relFilterFieldIds = filter.RelationshipFilters.Select(f => f.FieldId).ToHashSet(); - - var parentUnitValues = relFilterFieldIds.Any() && parentUnitIds.Any() - ? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(parentUnitIds, relFilterFieldIds) - : new List(); - - var childUnitValues = relFilterFieldIds.Any() && childUnitIds.Any() - ? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(childUnitIds, relFilterFieldIds) - : new List(); - - // 7️ Сборка DTO - var unitMap = new Dictionary(); - var initialUnits = await unitService.Get().AsNoTracking() - .Where(u => initialUnitIds.Contains(u.Id)) - .Select(u => new { u.Id, u.Name }) - .ToDictionaryAsync(u => u.Id, u => u); - - foreach (var id in initialUnitIds) - { - if (initialUnits.TryGetValue(id, out var unitInfo)) - { - unitMap[id] = new UnitDto { Id = id, Name = unitInfo.Name }; - } - } - - // UnitValues → Unit - foreach (var uv in unitValues) - { - if (unitMap.TryGetValue(uv.UnitId, out var dto)) - { - dto.Values.Add(new UnitValueDto { FieldId = uv.FieldId, Value = uv.Value?.Value }); - } - } - - var parentIdMap = parentUnits.ToDictionary(u => u.Id, u => u.Name); - var childIdMap = childUnits.ToDictionary(u => u.Id, u => u.Name); - - // ParentLinks → Parent - foreach (var link in parentLinks) - { - if (unitMap.TryGetValue(link.ChildUnitId, out var dto)) - { - dto.Parents.Add(new RelatedUnitDto - { - UnitId = link.ParentUnitId, - Name = parentIdMap.GetValueOrDefault(link.ParentUnitId, "") - }); - } - } - - // ChildLinks → Child - foreach (var link in childLinks) - { - if (unitMap.TryGetValue(link.ParentUnitId, out var dto)) - { - dto.Children.Add(new RelatedUnitDto - { - UnitId = link.ChildUnitId, - Name = childIdMap.GetValueOrDefault(link.ChildUnitId, "") - }); - } - } - - // Привязка значений к родителям - var parentValuesMap = parentUnitValues - .GroupBy(v => v.UnitId) - .ToDictionary(g => g.Key, g => g.Select(v => new UnitValueDto { FieldId = v.FieldId, Value = v.Value?.Value }).ToList()); - - foreach (var dto in unitMap.Values) - { - foreach (var parent in dto.Parents) - { - if (parentValuesMap.TryGetValue(parent.UnitId, out var values)) - { - parent.Values.AddRange(values); - } - } - } - - // Привязка значений к детям - var childValuesMap = childUnitValues - .GroupBy(v => v.UnitId) - .ToDictionary(g => g.Key, g => g.Select(v => new UnitValueDto { FieldId = v.FieldId, Value = v.Value?.Value }).ToList()); - - foreach (var dto in unitMap.Values) - { - foreach (var child in dto.Children) - { - if (childValuesMap.TryGetValue(child.UnitId, out var values)) - { - child.Values.AddRange(values); - } - } - } - - // 8️ Применение фильтров - var candidateUnits = unitMap.Values.AsQueryable(); - - foreach (var fieldFilter in filter.FieldFilters) - { - candidateUnits = ApplyFieldFilter(candidateUnits, fieldFilter); - } - #if DEBUG - var candidateListAfterFieldFilter = candidateUnits.ToList(); - if (candidateListAfterFieldFilter.Any(u => u.Id == targetUnitId)) + if (initialUnitIds.Contains(targetUnitId) && !fieldFilteredIds.Contains(targetUnitId)) { - logger.LogDebug("Юнит {TargetUnitId} найден в candidateUnits после FieldFilter.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в candidateUnits после FieldFilter.", targetUnitId); - } - - logger.LogDebug("После FieldFilter осталось {Count} юнитов", candidateListAfterFieldFilter.Count); -#endif - - foreach (var relFilter in filter.RelationshipFilters) - { - candidateUnits = ApplyRelationshipFilterToQuery(candidateUnits, relFilter); - } - -#if DEBUG - var candidateListAfterRelFilter = candidateUnits.ToList(); - if (candidateListAfterRelFilter.Any(u => u.Id == targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в candidateUnits после RelationshipFilter.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в candidateUnits после RelationshipFilter.", targetUnitId); - } - - logger.LogDebug("После RelationshipFilter осталось {Count} юнитов", candidateListAfterRelFilter.Count); -#endif - - // 9️ Umbrella-фильтр - var finalUnits = candidateUnits.AsEnumerable(); - if (job.Group.GroupType.Code == JobGroupTypesEnum.Umbrella) - { - finalUnits = ApplyRelationshipCountFilter(finalUnits, job, filter.RelationshipFilters); - logger.LogDebug("После Umbrella-фильтра осталось {Count} юнитов", finalUnits.Count()); - -#if DEBUG - if (finalUnits.Any(u => u.Id == targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в finalUnits после Umbrella-фильтра.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в finalUnits после Umbrella-фильтра.", targetUnitId); - } -#endif - } - - // 10 Взять DTO и преобразовать в результат - var newUnits = finalUnits - .Select(u => CreateResultDto(u)) - .Take(remaining) - .ToList(); - -#if DEBUG - if (newUnits.Any(u => u.Id == targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в newUnits.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в newUnits.", targetUnitId); + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} ОТФИЛЬТРОВАН на этапе FieldFilters (Фильтр #{Index})", targetUnitId, i + 1); } #endif - foreach (var unit in newUnits) + // 3. Применить RelationshipFilters на уровне SQL -> ВОЗВРАЩАЕТ FilteredUnitContext + var relStopwatch = Stopwatch.StartNew(); + var relationshipFilteredContexts = await ApplyRelationshipFiltersOnDbAsync(fieldFilteredIds, filter.RelationshipFilters); + relStopwatch.Stop(); + + if (!relationshipFilteredContexts.Any()) { - collectedUnits.Add(unit); + logger.LogDebug("Фильтр #{Index}: 0 юнитов после RelationshipFilters", i + 1); + continue; } - logger.LogDebug("Фильтр #{Index}: найдено {Count} Unit'ов. Всего: {Total}", - filterNumber, newUnits.Count, collectedUnits.Count); +#if DEBUG + var targetContext = relationshipFilteredContexts.FirstOrDefault(c => c.UnitId == targetUnitId); + if (targetContext != null) + { + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} прошёл фильтр #{Index}. Родителей: {ParentCount}, Детей: {ChildCount}", + targetUnitId, i + 1, targetContext.ValidParentIds.Count, targetContext.ValidChildIds.Count); + } +#endif + + // Добавляем отфильтрованные контексты в общий набор + allFilteredContexts.AddRange(relationshipFilteredContexts); + + filterStopwatch.Stop(); + logger.LogDebug( + "Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Field: {F}ms, Rel: {R}ms, Total: {T}ms]", + i + 1, + relationshipFilteredContexts.Count, + allFilteredContexts.Count, + fieldStopwatch.ElapsedMilliseconds, + relStopwatch.ElapsedMilliseconds, + filterStopwatch.ElapsedMilliseconds + ); } catch (Exception ex) { - logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} (#{Index}) для Job {JobId}", - filter.Id, filterNumber, job.Id); + logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} для Job {JobId}", + filter.Id, job.Id); } } - var result = collectedUnits.Take(maxCount).ToList(); - logger.LogInformation("Job {JobId}: из {FilterCount} фильтров получено {UnitCount} уникальных Unit'ов", - job.Id, job.UnitFilters.Count, result.Count); + // Убираем дубликаты по UnitId, объединяя связи + var mergedContexts = allFilteredContexts + .GroupBy(c => c.UnitId) + .Select(g => new FilteredUnitContext + { + UnitId = g.Key, + ValidParentIds = new HashSet(g.SelectMany(c => c.ValidParentIds)), + ValidChildIds = new HashSet(g.SelectMany(c => c.ValidChildIds)) + }) + .ToList(); + + logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count); + + // Этап 2: Применение Umbrella-фильтра + var umbrellaFilteredContexts = mergedContexts; + if (job.Group.GroupType.Code == JobGroupTypesEnum.Umbrella) + { + logger.LogDebug("Применяется Umbrella-фильтр: Min={Min}, Max={Max}, IsParent={IsParent}", + job.MinValueRelationships, job.MaxValueRelationships, job.IsParentRelationships); #if DEBUG - if (result.Any(u => u.Id == targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в финальном результате.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в финальном результате.", targetUnitId); - } + var targetBeforeUmbrella = mergedContexts.FirstOrDefault(c => c.UnitId == targetUnitId); + if (targetBeforeUmbrella != null) + { + var count = job.IsParentRelationships == true + ? targetBeforeUmbrella.ValidParentIds.Count + : targetBeforeUmbrella.ValidChildIds.Count; + var passes = count >= (job.MinValueRelationships ?? 0) && count <= (job.MaxValueRelationships ?? int.MaxValue); + + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} перед Umbrella: Count={Count}, Min={Min}, Max={Max}, Passes={Passes}", + targetUnitId, count, job.MinValueRelationships, job.MaxValueRelationships, passes); + } #endif + umbrellaFilteredContexts = ApplyUmbrellaFilterInMemory(mergedContexts, job); + + logger.LogInformation("Этап фильтрации по числу связей завершён: после Umbrella-фильтра осталось {UnitCount} юнитов", umbrellaFilteredContexts.Count); + +#if DEBUG + var targetAfterUmbrella = umbrellaFilteredContexts.FirstOrDefault(c => c.UnitId == targetUnitId); + if (targetBeforeUmbrella != null && targetAfterUmbrella == null) + { + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} ОТФИЛЬТРОВАН на этапе Umbrella", targetUnitId); + } +#endif + } + + // Этап 3: Применение takeCount и загрузка полных данных для результата + var finalContexts = umbrellaFilteredContexts.Take(takeCount ?? int.MaxValue).ToList(); + + var loadStopwatch = Stopwatch.StartNew(); + var result = await LoadFinalResultAsync(finalContexts); + loadStopwatch.Stop(); + + totalStopwatch.Stop(); + logger.LogInformation( + "Job {JobId} завершён за {TotalMs} мс. Найдено {UnitCount} юнитов. [Load: {LoadMs} мс]", + job.Id, + totalStopwatch.ElapsedMilliseconds, + result.Count, + loadStopwatch.ElapsedMilliseconds + ); + return result; } - private async Task> LoadAllUnitNamesAsync(IEnumerable filters) - { - var unitIds = new HashSet(); - foreach (var filter in filters) + private async Task LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default) + { + return await jobService + .Get().AsNoTracking() + .AsSplitQuery() // ← Избегаем предупреждения EF Core о множественных коллекциях + .Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField) + .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField) + .Include(j => j.Group).ThenInclude(g => g!.GroupType) + .FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken); + } + + + /// + /// Применяем фильтры аттрибутов + /// + /// + /// + /// + private async Task> ApplyFieldFiltersOnDbAsync( + List unitIds, + IEnumerable fieldFilters) + { + if (!fieldFilters.Any()) + return unitIds; + + logger.LogDebug("ApplyFieldFiltersOnDbAsync: вход {UnitCount} юнитов, фильтров: {FilterCount}", + unitIds.Count, fieldFilters.Count()); + + var query = unitService.Get().AsNoTracking() + .Where(u => unitIds.Contains(u.Id)); + + int filterIndex = 0; + + foreach (var fieldFilter in fieldFilters) { - var ids = await GetUnitIdsFromCacheOrDbAsync(filter); - foreach (var id in ids) + filterIndex++; + var valueMask = fieldFilter.ValueMask?.Trim(); + if (string.IsNullOrEmpty(valueMask)) + continue; + + // Обработка маски LIKE + bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%"); + bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%"); + string dbValueMask; + if (isStartsWith && isEndsWith) + dbValueMask = $"%{valueMask.Trim('%')}%"; + else if (isStartsWith) + dbValueMask = $"{valueMask.TrimEnd('%')}%"; + else if (isEndsWith) + dbValueMask = $"%{valueMask.TrimStart('%')}"; + else + dbValueMask = valueMask; + + var fieldName = fieldFilter.UnitField?.AihitName ?? $"FieldId={fieldFilter.FieldId}"; + logger.LogDebug(" FieldFilter #{Index}: Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}", + filterIndex, fieldName, dbValueMask, fieldFilter.IsInverse); + + if (fieldFilter.IsInverse) { - unitIds.Add(id); + query = query.Where(u => !u.UnitValues.Any(v => + v.FieldId == fieldFilter.FieldId && + EF.Functions.Like(v.Value.Value, dbValueMask))); + } + else + { + query = query.Where(u => u.UnitValues.Any(v => + v.FieldId == fieldFilter.FieldId && + EF.Functions.Like(v.Value.Value, dbValueMask))); + } + + // Выполняем промежуточный запрос для логирования + var intermediateResult = await query.Select(u => u.Id).ToListAsync(); + logger.LogDebug(" После фильтра #{Index}: осталось {Count} юнитов", filterIndex, intermediateResult.Count); + + // Обновляем query для следующей итерации + query = unitService.Get().AsNoTracking() + .Where(u => intermediateResult.Contains(u.Id)); + } + + var result = await query.Select(u => u.Id).ToListAsync(); + logger.LogDebug("ApplyFieldFiltersOnDbAsync: выход {UnitCount} юнитов", result.Count); + + return result; + } + + + /// + /// Применяем фильтры аттрибутов у связанных ЭК + /// + /// + /// + /// + private async Task> ApplyRelationshipFiltersOnDbAsync( + List unitIds, + IEnumerable relationshipFilters + ) + { + if (!relationshipFilters.Any() || !unitIds.Any()) + { + logger.LogDebug("ApplyRelationshipFiltersOnDbAsync: вход {UnitCount} юнитов, фильтров: 0 → возврат без изменений", + unitIds.Count); + return unitIds.Select(id => new FilteredUnitContext { UnitId = id }).ToList(); + } + + logger.LogDebug("ApplyRelationshipFiltersOnDbAsync: вход {UnitCount} юнитов, фильтров: {FilterCount}", + unitIds.Count, relationshipFilters.Count()); + +#if DEBUG + if (unitIds.Contains(targetUnitId)) + { + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} присутствует во входных данных ApplyRelationshipFiltersOnDbAsync", targetUnitId); + } +#endif + + var parentRelFilters = relationshipFilters.Where(rf => rf.IsParent).ToList(); + var childRelFilters = relationshipFilters.Where(rf => !rf.IsParent).ToList(); + + var resultContexts = unitIds.ToDictionary(id => id, id => new FilteredUnitContext { UnitId = id }); + + // Обработка родительских фильтров + if (parentRelFilters.Any()) + { + logger.LogDebug(" Родительские фильтры ({Count}):", parentRelFilters.Count); + + var allParentLinks = await unitInUnitService.Get() + .AsNoTracking() + .Where(link => unitIds.Contains(link.ChildUnitId)) + .Select(link => new { ChildId = link.ChildUnitId, ParentId = link.ParentUnitId }) + .ToListAsync(); + + var allParentIds = allParentLinks.Select(l => l.ParentId).Distinct().ToList(); + logger.LogDebug(" Найдено {ParentCount} уникальных родителей для {LinkCount} связей", + allParentIds.Count, allParentLinks.Count); + + var validParentIds = new HashSet(allParentIds); + logger.LogDebug(" Начальное количество родителей: {Count}", validParentIds.Count); + + foreach (var relFilter in parentRelFilters) + { + var valueMask = relFilter.ValueMask?.Trim(); + if (string.IsNullOrWhiteSpace(valueMask)) + continue; + + bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%"); + bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%"); + string dbValueMask; + if (isStartsWith && isEndsWith) + dbValueMask = $"%{valueMask.Trim('%')}%"; + else if (isStartsWith) + dbValueMask = $"{valueMask.TrimEnd('%')}%"; + else if (isEndsWith) + dbValueMask = $"%{valueMask.TrimStart('%')}"; + else + dbValueMask = valueMask; + + var fieldName = relFilter.UnitField?.AihitName ?? $"FieldId={relFilter.FieldId}"; + logger.LogDebug(" RelationshipFilter (Parent): Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}, IsFullMatch={IsFullMatch}", + fieldName, dbValueMask, relFilter.IsInverse, relFilter.IsFullMatch); + + var matchingParents = await unitService.Get() + .AsNoTracking() + .Where(u => allParentIds.Contains(u.Id)) + .Where(u => u.UnitValues.Any(v => + v.FieldId == relFilter.FieldId && + EF.Functions.Like(v.Value.Value, dbValueMask))) + .Select(u => u.Id) + .ToListAsync(); + + logger.LogDebug(" Найдено {MatchCount} родителей по маске", matchingParents.Count); + + if (relFilter.IsInverse) + { + validParentIds.ExceptWith(matchingParents); + logger.LogDebug(" IsInverse=true → исключены {ExcludedCount} родителей, осталось {RemainingCount}", + matchingParents.Count, validParentIds.Count); + } + else + { + validParentIds.IntersectWith(matchingParents); + logger.LogDebug(" IsInverse=false → оставлены {KeptCount} родителей", + validParentIds.Count); + } + } + + // Сопоставляем подходящих родителей с их детьми + foreach (var link in allParentLinks) + { + if (validParentIds.Contains(link.ParentId)) + { + resultContexts[link.ChildId].ValidParentIds.Add(link.ParentId); + } + } + + var totalParentsAdded = resultContexts.Values.Sum(c => c.ValidParentIds.Count); + logger.LogDebug(" Добавлено {TotalParents} родительских связей для {UnitCount} юнитов", + totalParentsAdded, resultContexts.Count); + } + + // Обработка дочерних фильтров + if (childRelFilters.Any()) + { + logger.LogDebug(" Дочерние фильтры ({Count}):", childRelFilters.Count); + + var allChildLinks = await unitInUnitService.Get() + .AsNoTracking() + .Where(link => unitIds.Contains(link.ParentUnitId)) + .Select(link => new { ParentId = link.ParentUnitId, ChildId = link.ChildUnitId }) + .ToListAsync(); + + var allChildIds = allChildLinks.Select(l => l.ChildId).Distinct().ToList(); + logger.LogDebug(" Найдено {ChildCount} уникальных детей для {LinkCount} связей", + allChildIds.Count, allChildLinks.Count); + + var validChildIds = new HashSet(allChildIds); + logger.LogDebug(" Начальное количество детей: {Count}", validChildIds.Count); + + foreach (var relFilter in childRelFilters) + { + var valueMask = relFilter.ValueMask?.Trim(); + if (string.IsNullOrWhiteSpace(valueMask)) + continue; + + bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%"); + bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%"); + string dbValueMask; + if (isStartsWith && isEndsWith) + dbValueMask = $"%{valueMask.Trim('%')}%"; + else if (isStartsWith) + dbValueMask = $"{valueMask.TrimEnd('%')}%"; + else if (isEndsWith) + dbValueMask = $"%{valueMask.TrimStart('%')}"; + else + dbValueMask = valueMask; + + var fieldName = relFilter.UnitField?.AihitName ?? $"FieldId={relFilter.FieldId}"; + logger.LogDebug(" RelationshipFilter (Child): Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}, IsFullMatch={IsFullMatch}", + fieldName, dbValueMask, relFilter.IsInverse, relFilter.IsFullMatch); + + var matchingChildren = await unitService.Get() + .AsNoTracking() + .Where(u => allChildIds.Contains(u.Id)) + .Where(u => u.UnitValues.Any(v => + v.FieldId == relFilter.FieldId && + EF.Functions.Like(v.Value.Value, dbValueMask))) + .Select(u => u.Id) + .ToListAsync(); + + logger.LogDebug(" Найдено {MatchCount} детей по маске", matchingChildren.Count); + + if (relFilter.IsInverse) + { + validChildIds.ExceptWith(matchingChildren); + logger.LogDebug(" IsInverse=true → исключены {ExcludedCount} детей, осталось {RemainingCount}", + matchingChildren.Count, validChildIds.Count); + } + else + { + validChildIds.IntersectWith(matchingChildren); + logger.LogDebug(" IsInverse=false → оставлены {KeptCount} детей", + validChildIds.Count); + } + } + + // Сопоставляем подходящих детей с их родителями + foreach (var link in allChildLinks) + { + if (validChildIds.Contains(link.ChildId)) + { + resultContexts[link.ParentId].ValidChildIds.Add(link.ChildId); + } + } + + var totalChildrenAdded = resultContexts.Values.Sum(c => c.ValidChildIds.Count); + logger.LogDebug(" Добавлено {TotalChildren} дочерних связей для {UnitCount} юнитов", + totalChildrenAdded, resultContexts.Count); + } + + var result = resultContexts.Values.ToList(); + logger.LogDebug("ApplyRelationshipFiltersOnDbAsync: выход {ContextCount} контекстов", result.Count); + + return result; + } + + + /// + /// Применяем фильтр по количеству связей для групп работ типа зонтик + /// + /// + /// + /// + private List ApplyUmbrellaFilterInMemory(List contexts, Job job) + { + var min = job.MinValueRelationships ?? 0; + var max = job.MaxValueRelationships ?? int.MaxValue; + var isParentDirection = job.IsParentRelationships == true; + + if (min == 0 && max == int.MaxValue) + return contexts; + + var result = new List(); + foreach (var context in contexts) + { + int count = isParentDirection ? context.ValidParentIds.Count : context.ValidChildIds.Count; + bool passes = count >= min && count <= max; + +#if DEBUG + if (context.UnitId == targetUnitId) + { + logger.LogDebug("🎯 DEBUG Umbrella: Юнит {TargetUnitId}, Count={Count}, Min={Min}, Max={Max}, Passes={Passes}", + targetUnitId, count, min, max, passes); + } +#endif + + if (passes) + { + result.Add(context); } } - return await unitService.Get().AsNoTracking() - .Where(u => unitIds.Contains(u.Id)) - .Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name }) - .ToListAsync(); + return result; } - private UnitFilterResultDto CreateResultDto(UnitDto source) + /// + /// Формируем итоговый результат + /// + /// + /// + private async Task> LoadFinalResultAsync(List contexts) { - return new UnitFilterResultDto + var allResults = new List(); + +#if DEBUG + var targetInContexts = contexts.FirstOrDefault(c => c.UnitId == targetUnitId); + if (targetInContexts != null) { - Id = source.Id, - Name = source.Name, - Values = new List(source.Values), - Parents = new List(source.Parents), - Children = new List(source.Children) - }; + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} передан в LoadFinalResultAsync. Родителей: {ParentCount}, Детей: {ChildCount}", + targetUnitId, targetInContexts.ValidParentIds.Count, targetInContexts.ValidChildIds.Count); + } + else + { + logger.LogDebug("🎯 DEBUG: Юнит {TargetUnitId} НЕ передан в LoadFinalResultAsync", targetUnitId); + } +#endif + + for (int i = 0; i < contexts.Count; i += batchSize) + { + var batch = contexts.Skip(i).Take(batchSize).ToList(); + var batchResults = await LoadBatchAsync(batch); + allResults.AddRange(batchResults); + } + + return allResults; } + + /// + /// Используем пакетную загрузку, чтобы база данных не ругалась + /// + /// + /// + private async Task> LoadBatchAsync(List batch) + { + var unitIds = batch.Select(c => c.UnitId).ToList(); + var allParentIds = batch.SelectMany(c => c.ValidParentIds).Distinct().ToList(); + var allChildIds = batch.SelectMany(c => c.ValidChildIds).Distinct().ToList(); + + // Загружаем юниты + var unitsMap = await unitService.Get().AsNoTracking() + .AsSingleQuery() + .Include(u => u.UnitValues).ThenInclude(v => v.Value) + .Where(u => unitIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + // Загружаем родителей + Dictionary parentsMap; + if (allParentIds.Any()) + { + parentsMap = await unitService.Get().AsNoTracking() + .AsSingleQuery() + .Include(u => u.UnitValues).ThenInclude(v => v.Value) + .Where(u => allParentIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + } + else + { + parentsMap = new Dictionary(); + } + + // Загружаем детей + Dictionary childrenMap; + if (allChildIds.Any()) + { + childrenMap = await unitService.Get().AsNoTracking() + .AsSingleQuery() + .Include(u => u.UnitValues).ThenInclude(v => v.Value) + .Where(u => allChildIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + } + else + { + childrenMap = new Dictionary(); + } + + var result = new List(); + foreach (var context in batch) + { + var unit = unitsMap[context.UnitId]; + var dto = new UnitFilterResultDto + { + Id = unit.Id, + Name = unit.Name, + Values = unit.UnitValues.Select(v => new UnitValueDto + { + FieldId = v.FieldId, + Value = v.Value?.Value + }).ToList(), + Parents = context.ValidParentIds + .Where(id => parentsMap.ContainsKey(id)) + .Select(id => new RelatedUnitDto + { + UnitId = id, + Name = parentsMap[id].Name, + Values = parentsMap[id].UnitValues.Select(v => new UnitValueDto + { + FieldId = v.FieldId, + Value = v.Value?.Value + }).ToList() + }).ToList(), + Children = context.ValidChildIds + .Where(id => childrenMap.ContainsKey(id)) + .Select(id => new RelatedUnitDto + { + UnitId = id, + Name = childrenMap[id].Name, + Values = childrenMap[id].UnitValues.Select(v => new UnitValueDto + { + FieldId = v.FieldId, + Value = v.Value?.Value + }).ToList() + }).ToList() + }; + result.Add(dto); + } + + return result; + } + + + public async Task> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId) { logger.LogDebug("Начало GetRelatedUnitNamesAsync. JobId: {JobId}, UnitId: {UnitId}", jobId, unitId); @@ -434,11 +706,11 @@ internal class UnitFilterService : IUnitFilterService logger.LogDebug("Обработка UnitFilter.Id {FilterId}. Количество RelationshipFilters: {RelFilterCount}", filter.Id, filter.RelationshipFilters.Count()); - // Получить все связи для юнита + // Получить все связи для юнита var parentLinks = await unitInUnitService.GetByChildIdAsync(unitId); var childLinks = await unitInUnitService.GetByParentIdAsync(unitId); - // Собрать все UnitId, участвующие в связях + // Собрать все UnitId, участвующие в связях var allRelatedUnitIds = parentLinks .Select(l => l.ParentUnitId) .Concat(childLinks.Select(l => l.ChildUnitId)) @@ -447,20 +719,19 @@ internal class UnitFilterService : IUnitFilterService if (!allRelatedUnitIds.Any()) continue; - // Получить значения для всех связанных юнитов + // Получить значения для всех связанных юнитов var allUnitValues = await unitInValueService.GetByUnitIdsAsync(allRelatedUnitIds); - // Сгруппировать значения по UnitId + // Сгруппировать значения по UnitId var valuesByUnit = allUnitValues .GroupBy(uv => uv.UnitId) .ToDictionary(g => g.Key, g => g.ToList()); - // Найти UnitId, которые проходят все RelationshipFilters + // Найти UnitId, которые проходят все RelationshipFilters var matchingUnitIds = new HashSet(); foreach (var relatedUnitId in allRelatedUnitIds) { - // Изменяем на `All` — все фильтры должны подходить bool passesAllFilters = filter.RelationshipFilters.All(rf => { var values = valuesByUnit.GetValueOrDefault(relatedUnitId, new List()); @@ -469,16 +740,13 @@ internal class UnitFilterService : IUnitFilterService .Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null) .ToList(); - if (!matchingValues.Any()) // Нет значений по полю + if (!matchingValues.Any()) { - // Если нет значений, и IsInverse = false → не подходит - // Если нет значений, и IsInverse = true → подходит return rf.IsInverse; } var hasMatch = matchingValues.Any(uv => uv.Value!.Value!.Contains(rf.ValueMask.Trim('%'), StringComparison.OrdinalIgnoreCase)); - // Учитываем IsInverse if (rf.IsInverse) hasMatch = !hasMatch; @@ -504,385 +772,26 @@ internal class UnitFilterService : IUnitFilterService #region вспомогательные методы - private IQueryable ApplyFieldFilter(IQueryable query, JobFieldFilter fieldFilter) - { - var valueMask = fieldFilter.ValueMask?.Trim(); - if (string.IsNullOrEmpty(valueMask)) - return query; - - logger.LogDebug("Применяем FieldFilterDto: FieldId={FieldId}, ValueMask={ValueMask}, IsInverse={IsInverse}", fieldFilter.FieldId, valueMask, fieldFilter.IsInverse); - - bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%"); - bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%"); - - return query.AsEnumerable().Where(dto => - { - var hasMatch = dto.Values.Any(v => - v.FieldId == fieldFilter.FieldId && - v.Value != null && - (isStartsWith && isEndsWith ? v.Value.Contains(valueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) : - isStartsWith ? v.Value.StartsWith(valueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) : - isEndsWith ? v.Value.EndsWith(valueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) : - v.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase)) - ); - - // Правильная логика: если IsInverse = true, то юнит подходит, если НЕ проходит фильтр - return fieldFilter.IsInverse ? !hasMatch : hasMatch; - }).AsQueryable(); - } - - private IQueryable ApplyRelationshipFilterToQuery(IQueryable query, JobRelationshipFilter relFilter) - { - var valueMask = relFilter.ValueMask?.Trim(); - if (string.IsNullOrWhiteSpace(valueMask)) - return query; - - logger.LogDebug("Применяем RelationshipFilterDto: IsParent={IsParent}, IsInverse={IsInverse}, IsFullMatch={IsFullMatch}, FieldId={FieldId}, ValueMask={ValueMask}", - relFilter.IsParent, relFilter.IsInverse, relFilter.IsFullMatch, relFilter.FieldId, valueMask); - - var isParent = relFilter.IsParent; - var isInverse = relFilter.IsInverse; - var isFullMatch = relFilter.IsFullMatch; - var fieldId = relFilter.FieldId; - - // Используем Trim('%') для всех случаев - var trimmedMask = valueMask.Trim('%'); - - return query.AsEnumerable().Where(dto => - { -#if DEBUG - // Отладка: проверить, это нужный юнит - if (dto.Id == targetUnitId) - { - logger.LogDebug("Проверяем юнит {TargetUnitId} с {ParentCount} родителями и {ChildCount} детьми.", - targetUnitId, dto.Parents.Count, dto.Children.Count); - } -#endif - - var links = isParent ? dto.Parents : dto.Children; - - if (links == null || !links.Any()) - { -#if DEBUG - // Отладка: юнит не имеет связей - if (dto.Id == targetUnitId) - { - logger.LogDebug("Юнит {TargetUnitId}: нет связей ({Direction}), результат фильтра: {Result}", - targetUnitId, isParent ? "Parent" : "Child", isFullMatch && isInverse); - } -#endif - - // Для IsFullMatch/IsInverse — "все подходят", т.е. true - // Для остальных — false - return isFullMatch && isInverse; - } - -#if DEBUG - // Отладка: проверить, какие связи у юнита - if (dto.Id == targetUnitId) - { - logger.LogDebug("Юнит {TargetUnitId}: {Count} связей ({Direction}).", targetUnitId, links.Count, isParent ? "Parent" : "Child"); - foreach (var link in links) - { - logger.LogDebug(" Связь: UnitId={LinkUnitId}, Name={LinkName}, Values=[{Values}]", link.UnitId, link.Name, string.Join(", ", link.Values.Select(v => $"{v.FieldId}={v.Value}"))); - } - } -#endif - - if (!isFullMatch) - { - // Если IsFullMatch = false — проверяем, есть ли хотя бы одна подходящая связь - var hasMatchingLinks = links.Any(link => - { - var values = link.Values.Where(v => v.FieldId == fieldId).ToList(); - - if (!values.Any()) - { - // Если нет значений по полю — связь не подходит, если IsInverse = false - // Если нет значений по полю — связь подходит, если IsInverse = true - return isInverse; - } - - var hasMatch = values.Any(v => - v.Value != null && - // Проверка с Trim('%') - (valueMask.StartsWith("%") && valueMask.EndsWith("%") ? v.Value.Contains(trimmedMask, StringComparison.OrdinalIgnoreCase) : - valueMask.StartsWith("%") ? v.Value.EndsWith(trimmedMask, StringComparison.OrdinalIgnoreCase) : - valueMask.EndsWith("%") ? v.Value.StartsWith(trimmedMask, StringComparison.OrdinalIgnoreCase) : - string.Equals(v.Value, valueMask, StringComparison.OrdinalIgnoreCase)) - ); - - // Учитываем IsInverse - return isInverse ? !hasMatch : hasMatch; - }); - -#if DEBUG - // Отладка: результат hasMatchingLinks - if (dto.Id == targetUnitId) - { - logger.LogDebug(" hasMatchingLinks = {HasMatchingLinks}, isInverse = {IsInverse}", hasMatchingLinks, isInverse); - } -#endif - - return hasMatchingLinks; - } - else - { - // Если IsFullMatch = true — все связи должны подходить - var allMatch = links.All(link => - { - var values = link.Values.Where(v => v.FieldId == fieldId).ToList(); - - if (!values.Any()) - { - // Если нет значений по полю — связь не подходит, если IsInverse = false - // Если нет значений по полю — связь подходит, если IsInverse = true - return isInverse; - } - - var hasMatch = values.Any(v => - v.Value != null && - // Проверка с Trim('%') - (valueMask.StartsWith("%") && valueMask.EndsWith("%") ? v.Value.Contains(trimmedMask, StringComparison.OrdinalIgnoreCase) : - valueMask.StartsWith("%") ? v.Value.EndsWith(trimmedMask, StringComparison.OrdinalIgnoreCase) : - valueMask.EndsWith("%") ? v.Value.StartsWith(trimmedMask, StringComparison.OrdinalIgnoreCase) : - string.Equals(v.Value, valueMask, StringComparison.OrdinalIgnoreCase)) - ); - - // Учитываем IsInverse - return isInverse ? !hasMatch : hasMatch; - }); - -#if DEBUG - // Отладка: результат allMatch - if (dto.Id == targetUnitId) - { - logger.LogDebug(" allMatch = {AllMatch}, isFullMatch = {IsFullMatch}", allMatch, isFullMatch); - } -#endif - - return allMatch; - } - }).AsQueryable(); - } - - private IEnumerable ApplyRelationshipCountFilter( - IEnumerable units, - Job job, - IEnumerable relationshipFilters) - { - var min = job.MinValueRelationships ?? 0; - var max = job.MaxValueRelationships ?? int.MaxValue; - var isParentDirection = job.IsParentRelationships == true; - - if (min == 0 && max == int.MaxValue) - { - logger.LogDebug("RelationshipCountFilter: Min и Max не заданы — пропускаем фильтр."); - return units; - } - - logger.LogDebug("RelationshipCountFilter: Min={Min}, Max={Max}, IsParent={IsParent}", min, max, isParentDirection); - - // Разделяем фильтры по направлениям - var parentFilters = relationshipFilters - .Where(rf => rf.IsParent && !string.IsNullOrWhiteSpace(rf.ValueMask)) - .ToList(); - - var childFilters = relationshipFilters - .Where(rf => !rf.IsParent && !string.IsNullOrWhiteSpace(rf.ValueMask)) - .ToList(); - - bool hasParentFieldFilters = parentFilters.Count > 0; - bool hasChildFieldFilters = childFilters.Count > 0; - - logger.LogDebug("RelationshipCountFilter: Найдено {ParentCount} фильтров по родителям и {ChildCount} фильтров по детям", parentFilters.Count, childFilters.Count); - - return units.Where(dto => - { - int matchingCount = 0; - - if (isParentDirection) // Считаем родительские связи - { - var links = dto.Parents; - - if (links == null || !links.Any()) - { - var result = min == 0; - logger.LogDebug("UnitId {UnitId}: родительских связей нет (null или пусто). Min={Min}, результат фильтра: {Result}", dto.Id, min, result); - return result; - } - - logger.LogDebug("UnitId {UnitId}: {Count} родительских связей до фильтрации", dto.Id, links.Count); - - if (hasParentFieldFilters) - { - // Есть фильтры по родителям → считаем только подходящие - foreach (var link in links) - { - // Проверяем, проходит ли связь все фильтры (логика как в ApplyRelationshipFilterToQuery) - bool passesAllFilters = true; - - foreach (var f in parentFilters) - { - var values = link.Values.Where(v => v.FieldId == f.FieldId).ToList(); - - if (!values.Any()) - { - // Если нет значений по полю — связь не подходит, если IsInverse = false - // Если нет значений по полю — связь подходит, если IsInverse = true - if (!f.IsInverse) - { - passesAllFilters = false; - break; - } - continue; - } - - var hasMatch = values.Any(v => - v.Value != null && - // Проверка в зависимости от маски - (f.ValueMask.StartsWith("%") && f.ValueMask.EndsWith("%") ? v.Value.Contains(f.ValueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) : - f.ValueMask.StartsWith("%") ? v.Value.EndsWith(f.ValueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) : - f.ValueMask.EndsWith("%") ? v.Value.StartsWith(f.ValueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) : - string.Equals(v.Value, f.ValueMask, StringComparison.OrdinalIgnoreCase)) - ); - - // Учитываем IsInverse - if (f.IsInverse) - hasMatch = !hasMatch; - - if (!hasMatch) - { - passesAllFilters = false; - break; - } - } - - if (passesAllFilters) - matchingCount++; - - if (matchingCount > max) - { - logger.LogDebug("UnitId {UnitId}: matchingCount ({Count}) > max ({Max}) — прерываем подсчёт", dto.Id, matchingCount, max); - break; - } - } - } - else - { - // Нет фильтров по родителям → считаем общее количество связей (без учёта значений) - matchingCount = links.Count; - logger.LogDebug("UnitId {UnitId}: нет фильтров по родителям — matchingCount = links.Count = {Count}", dto.Id, matchingCount); - } - } - else // Считаем дочерние связи - { - var links = dto.Children; - - if (links == null || !links.Any()) - { - var result = min == 0; - logger.LogDebug("UnitId {UnitId}: дочерних связей нет (null или пусто). Min={Min}, результат фильтра: {Result}", dto.Id, min, result); - return result; - } - - logger.LogDebug("UnitId {UnitId}: {Count} дочерних связей до фильтрации", dto.Id, links.Count); - - if (hasChildFieldFilters) - { - // Есть фильтры по детям → считаем только подходящие - foreach (var link in links) - { - // Проверяем, проходит ли связь все фильтры (логика как в ApplyRelationshipFilterToQuery) - bool passesAllFilters = true; - - foreach (var f in childFilters) - { - var values = link.Values.Where(v => v.FieldId == f.FieldId).ToList(); - - if (!values.Any()) - { - // Если нет значений по полю — связь не подходит, если IsInverse = false - // Если нет значений по полю — связь подходит, если IsInverse = true - if (!f.IsInverse) - { - passesAllFilters = false; - break; - } - continue; - } - - var hasMatch = values.Any(v => - v.Value != null && - // Проверка в зависимости от маски - (f.ValueMask.StartsWith("%") && f.ValueMask.EndsWith("%") ? v.Value.Contains(f.ValueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) : - f.ValueMask.StartsWith("%") ? v.Value.EndsWith(f.ValueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) : - f.ValueMask.EndsWith("%") ? v.Value.StartsWith(f.ValueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) : - string.Equals(v.Value, f.ValueMask, StringComparison.OrdinalIgnoreCase)) - ); - - // Учитываем IsInverse - if (f.IsInverse) - hasMatch = !hasMatch; - - if (!hasMatch) - { - passesAllFilters = false; - break; - } - } - - if (passesAllFilters) - matchingCount++; - - if (matchingCount > max) - { - logger.LogDebug("UnitId {UnitId}: matchingCount ({Count}) > max ({Max}) — прерываем подсчёт", dto.Id, matchingCount, max); - break; - } - } - } - else - { - // Нет фильтров по детям → считаем общее количество связей (без учёта значений) - matchingCount = links.Count; - logger.LogDebug("UnitId {UnitId}: нет фильтров по детям — matchingCount = links.Count = {Count}", dto.Id, matchingCount); - } - } - - var finalResult = matchingCount >= min && matchingCount <= max; - logger.LogDebug("UnitId {UnitId}: matchingCount={Count}, Min={Min}, Max={Max}, результат фильтра: {Result}", dto.Id, matchingCount, min, max, finalResult); - - return finalResult; - }); - } - private async Task> GetUnitIdsFromCacheOrDbAsync(JobUnitFilter filter) { - var cacheKey = $"uf_ids_{ComputeHash(filter.UnitFilter)}"; + var cacheKey = cacheService.GetKey(new[] { "uf_ids", filter.UnitFilter }, isUseHash: true); - var cachedData = await cacheService.GetCachedDataAsync(cacheKey); + var cachedData = await cacheService.GetCachedDataAsync(cacheKey); if (cachedData != null) { - logger.LogDebug("Кэш попал для UnitFilter '{Name}': {Count} юнитов", filter.UnitFilter, cachedData.UnitIds.Count); - return cachedData.UnitIds; + return cachedData.Data.UnitIds; } - logger.LogDebug("Кэш промахнут для UnitFilter '{Name}'. Запрашиваем из БД.", filter.UnitFilter); - var initialUnitIds = await unitService.Get().AsNoTracking() .Where(unit => EF.Functions.Like(unit.Name, filter.UnitFilter)) .Select(u => u.Id) .ToListAsync(); - logger.LogDebug("Загружено {Count} юнитов из БД для UnitFilter '{Name}'", initialUnitIds.Count, filter.UnitFilter); - - var toCache = new CachedUnitIds + var toCache = new UnitFilterIds { - UnitIds = initialUnitIds, + Data = new UnitFilterIdsDto { UnitIds = initialUnitIds }, Timestamp = DateTimeOffset.UtcNow, - Source = GetType().Name, - Version = 1 + Source = GetType().Name }; await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)); @@ -890,12 +799,5 @@ internal class UnitFilterService : IUnitFilterService return initialUnitIds; } - private static string ComputeHash(string input) - { - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hashedBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); - return Convert.ToBase64String(hashedBytes).Replace('+', '-').Replace('/', '_').Substring(0, 16); - } - #endregion } \ No newline at end of file diff --git a/PARR.DAL/ParrDalInstaller.cs b/PARR.DAL/ParrDalInstaller.cs index 21a686e1..095dba9c 100644 --- a/PARR.DAL/ParrDalInstaller.cs +++ b/PARR.DAL/ParrDalInstaller.cs @@ -9,6 +9,7 @@ using PARR.DAL.DomainServices.Implementations; using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes; using PARR.DAL.DomainServices.UnitFilterService; +using PARR.DAL.DomainServices.UnitFilterService.Models; using PARR.DAL.InfluxDbServices; using PARR.DAL.NextRunServices; using PARR.DAL.NextRunServices.Subservices; @@ -22,7 +23,6 @@ using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Schedule; using PARR.DAL.Services.Interfaces.Unit; using PARR.DAL.Settings; -using PARR.DAL.TransformServices; namespace PARR.DAL { @@ -149,6 +149,8 @@ namespace PARR.DAL services.AddTransient(); services.AddTransient(); services.AddTransient(); + + services.AddSingleton(new UnitFilterServiceOptions { LoadBatchSize = 1000 }); #endregion diff --git a/PARR.Test/appsettings.Development.json b/PARR.Test/appsettings.Development.json index 291a2e9d..ab3c1ee0 100644 --- a/PARR.Test/appsettings.Development.json +++ b/PARR.Test/appsettings.Development.json @@ -1,26 +1,23 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "Serilog": { - "MinimumLevel": { - "Default": "Debug", - "Override": { - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Debug" - } + "ConnectionStrings": { + "RedisConnection": "10.99.253.216:6379,password=ParrP@ssPtk202MMdevDvs" }, - "WriteTo": [ - { - "Name": "File", - "Args": { - "path": "log/log-.txt", - "rollingInterval": "Day" - } - } - ] - } + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Debug" + } + }, + "WriteTo": [ + { + "Name": "File", + "Args": { + "path": "log/log-.txt", + "rollingInterval": "Day" + } + } + ] + } } \ No newline at end of file diff --git a/PARR.Test/appsettings.json b/PARR.Test/appsettings.json index 476427d9..edaa6a6e 100644 --- a/PARR.Test/appsettings.json +++ b/PARR.Test/appsettings.json @@ -1,40 +1,41 @@ { - "ConnectionStrings": { - "DefaultConnection": "Server=10.99.253.184;Database=parr;User Id=app_parr; Password=PosdfkhT&)%sdfligL&%5546;" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", + "ConnectionStrings": { + "DefaultConnection": "Server=10.99.253.184;Database=parr;User Id=app_parr; Password=PosdfkhT&)%sdfligL&%5546;", + "RedisConnection": "parr-redis:6379,password=ParrP@ssPtk202MMdevDvs" + }, + "Logging": { + "LogLevel": { + "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" } }, - "WriteTo": [ - { - "Name": "File", - "Args": { - "path": "log/log-.txt", - "rollingInterval": "Day" - } - } - ] - }, - "EsppOrderSettings": { - //dev - //"Url": "http://rzd-espp-t-rpa-app-1.gvc.oao.rzd:8080/espp_api/OperationExecutor/", - "Url": "http://espp.gvc.rzd/esppapi_prom/OperationExecutor/", - "UserName": "Auto-PTK-INFO-0002-DVS", - "AccountName": "АВТО ТЕХНОЛОГ ПТК-ИНФО-0002-ДВС (AUTO-PTK-INFO-0002-DVS)", - "Password": "123456789", - "EsppUserTimeZone": 3, - "RobotEk": "РПА-РОБИН-ГВЦ-ЕСПП-ТС-513-ДВС", - "CodeRRO": "ЦТС-РЦТ-9999" - } + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "WriteTo": [ + { + "Name": "File", + "Args": { + "path": "log/log-.txt", + "rollingInterval": "Day" + } + } + ] + }, + "EsppOrderSettings": { + //dev + //"Url": "http://rzd-espp-t-rpa-app-1.gvc.oao.rzd:8080/espp_api/OperationExecutor/", + "Url": "http://espp.gvc.rzd/esppapi_prom/OperationExecutor/", + "UserName": "Auto-PTK-INFO-0002-DVS", + "AccountName": "АВТО ТЕХНОЛОГ ПТК-ИНФО-0002-ДВС (AUTO-PTK-INFO-0002-DVS)", + "Password": "123456789", + "EsppUserTimeZone": 3, + "RobotEk": "РПА-РОБИН-ГВЦ-ЕСПП-ТС-513-ДВС", + "CodeRRO": "ЦТС-РЦТ-9999" + } }