diff --git a/PARR.API/Controllers/V1/JobController.cs b/PARR.API/Controllers/V1/JobController.cs index 5aec320a..e436baba 100644 --- a/PARR.API/Controllers/V1/JobController.cs +++ b/PARR.API/Controllers/V1/JobController.cs @@ -20,6 +20,7 @@ using PARR.DAL.Cache.Services; using PARR.DAL.Contracts; using PARR.DAL.DomainModels; using PARR.DAL.DomainServices.Interfaces; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Extensions; using PARR.DAL.Models.Job; using PARR.DAL.Services.Interfaces; diff --git a/PARR.API/Controllers/V1/JobUnitPreviewController.cs b/PARR.API/Controllers/V1/JobUnitPreviewController.cs index c442dac1..d284e41e 100644 --- a/PARR.API/Controllers/V1/JobUnitPreviewController.cs +++ b/PARR.API/Controllers/V1/JobUnitPreviewController.cs @@ -9,7 +9,7 @@ using PARR.API.Contracts.V1.Responses; using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Controllers.V1.Base; using PARR.Constants; -using PARR.DAL.DomainServices.Interfaces; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; using Job = PARR.DAL.Models.Job.Job; diff --git a/PARR.API/Controllers/V1/TestController.cs b/PARR.API/Controllers/V1/TestController.cs index 6270b967..0d93d817 100644 --- a/PARR.API/Controllers/V1/TestController.cs +++ b/PARR.API/Controllers/V1/TestController.cs @@ -12,7 +12,7 @@ using PARR.API.Services.Interfaces; using PARR.DAL.Cache.Services.Base; using PARR.DAL.Contracts; using PARR.DAL.DomainModels; -using PARR.DAL.DomainServices.Interfaces; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Models.Job; using PARR.DAL.Services.Interfaces; using PARR.DAL.Services.Interfaces.Job; diff --git a/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs b/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs index 3e08053d..bfe0cb00 100644 --- a/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs +++ b/PARR.DAL.IntegrationTests/Integration/UnitFilterServiceIntegrationTests.cs @@ -3,17 +3,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using PARR.DAL.Context; -using PARR.DAL.DomainServices.Implementations; +using PARR.DAL.DomainServices.UnitFilterService; + // Предположим, что у вас есть реализации IJobService и IUnitService // Если нет, можно создать моки, но лучше использовать реальные, если они просты using PARR.DAL.Services.Implementations.Job; // Пример: JobService using PARR.DAL.Services.Implementations.Unit; // Пример: UnitService using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; -using System; -using System.Linq; -using System.Threading.Tasks; -using Xunit; namespace PARR.DAL.IntegrationTests.Integration { diff --git a/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs b/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs index 6a4318ff..a0d9578b 100644 --- a/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs +++ b/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs @@ -1,11 +1,8 @@ using Microsoft.Extensions.Logging; -using MockQueryable; using Moq; using PARR.DAL.Contracts; -using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes; -using PARR.DAL.Models.Job; -using PARR.DAL.Models.Unit; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; diff --git a/PARR.DAL/DomainServices/Implementations/UnitFilterService.cs b/PARR.DAL/DomainServices/Implementations/UnitFilterService.cs deleted file mode 100644 index 633c4a6e..00000000 --- a/PARR.DAL/DomainServices/Implementations/UnitFilterService.cs +++ /dev/null @@ -1,864 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using PARR.DAL.Cache.Services.Base; -using PARR.DAL.Contracts; -using PARR.DAL.DomainServices.Interfaces; -using PARR.DAL.Models.Job; -using PARR.DAL.Models.Unit; -using PARR.DAL.Services.Interfaces.Job; -using PARR.DAL.Services.Interfaces.Unit; - -namespace PARR.DAL.DomainServices.Implementations -{ - internal class UnitFilterService : IUnitFilterService - { -#if DEBUG - private readonly Guid targetUnitId = Guid.Parse("87fc4c36-1ea8-4163-983f-1605fee1de99"); -#endif - - private readonly ILogger logger; - private readonly IJobService jobService; - private readonly IUnitService unitService; - private readonly IUnitInUnitService unitInUnitService; - private readonly IRedisCacheService cacheService; - private readonly IUnitInValueService unitInValueService; - - public UnitFilterService( - ILogger logger, - IJobService jobService, - IUnitService unitService, - IUnitInUnitService unitInUnitService, - IUnitInValueService unitInValueService, - IRedisCacheService cacheService - ) - { - this.logger = logger; - this.jobService = jobService; - this.unitService = unitService; - this.unitInUnitService = unitInUnitService; - this.cacheService = cacheService; - this.unitInValueService = unitInValueService; - } - - #region вложенные классы - - private class UnitInfoDto - { - public Guid Id { get; set; } - public required string Name { get; set; } - } - - private class UnitValueDto - { - public Guid FieldId { get; set; } - public string? Value { get; set; } - } - - private class RelatedUnitDto - { - public Guid UnitId { get; set; } - public string Name { get; set; } = null!; - public List Values { get; set; } = new(); - } - - 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) - { - if (job.Group == null) - throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group"); - if (job.Group.GroupType == null) - throw new ArgumentNullException(nameof(job.Group.GroupType), $"Job {job.Id} не содержит GroupType"); - if (job.UnitFilters == null || !job.UnitFilters.Any()) - throw new ArgumentNullException(nameof(job.UnitFilters), $"Job {job.Id} не содержит UnitFilters"); - - var maxCount = takeCount ?? int.MaxValue; - var collectedIds = new HashSet(); - var filterNumber = 0; - - foreach (var filter in job.UnitFilters) - { - filterNumber++; - if (collectedIds.Count >= maxCount) break; - var remaining = maxCount - collectedIds.Count; - if (remaining <= 0) break; - - try - { - logger.LogDebug("Применяем фильтр #{Index} (Id={FilterId})", filterNumber, filter.Id); - - // 1️ Найти ID юнитов по UnitFilter (Name LIKE) - var initialUnitIds = await GetUnitIdsFromCacheOrDbAsync(filter); - - logger.LogDebug("Базовый фильтр по Name '{NameFilter}' дал {Count} юнитов", filter.UnitFilter, initialUnitIds.Count); - -#if DEBUG - // Отладка: проверить, есть ли юнит в initialUnitIds - if (initialUnitIds.Contains(targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в initialUnitIds.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в initialUnitIds.", targetUnitId); - //continue; // ❌ юнит отсеялся на этом этапе - } -#endif - - if (!initialUnitIds.Any()) - { - logger.LogDebug("Фильтр #{Index}: 0 юнитов после UnitFilter. Пропускаем.", filterNumber); - 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() - ? await unitService.Get().AsNoTracking() - .Where(u => parentUnitIds.Contains(u.Id)) - .Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name }) - .ToListAsync() - : new List(); - - var childUnits = childUnitIds.Any() - ? await unitService.Get().AsNoTracking() - .Where(u => childUnitIds.Contains(u.Id)) - .Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name }) - .ToListAsync() - : 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 = initialUnitIds.ToDictionary(id => id, id => new UnitDto { Id = id }); - - // 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 - // Отладка: проверить, есть ли юнит в candidateUnits после FieldFilter - var candidateListAfterFieldFilter = candidateUnits.ToList(); - if (candidateListAfterFieldFilter.Any(u => u.Id == 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 - // Отладка: проверить, есть ли юнит в candidateUnits после RelationshipFilter - 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 - // Отладка: проверить, есть ли юнит в finalUnits после Umbrella - if (finalUnits.Any(u => u.Id == targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в finalUnits после Umbrella-фильтра.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в finalUnits после Umbrella-фильтра.", targetUnitId); - //continue; // ❌ юнит отсеялся на этом этапе - } -#endif - } - - // 10 Взять ID - var newIds = finalUnits - .Select(u => u.Id) - .Take(remaining) - .ToList(); - -#if DEBUG - // Отладка: проверить, есть ли юнит в newIds - if (newIds.Contains(targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в newIds.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в newIds.", targetUnitId); - // ❌ юнит отсеялся на этапе Take(remaining), если его не было в finalUnits - } -#endif - - collectedIds.UnionWith(newIds); - logger.LogDebug("Фильтр #{Index}: найдено {Count} Unit'ов. Всего: {Total}", - filterNumber, newIds.Count, collectedIds.Count); - } - catch (Exception ex) - { - logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} (#{Index}) для Job {JobId}", - filter.Id, filterNumber, job.Id); - } - } - - var result = collectedIds.Take(maxCount).ToList(); - logger.LogInformation("Job {JobId}: из {FilterCount} фильтров получено {UnitCount} уникальных Unit'ов", - job.Id, job.UnitFilters.Count, result.Count); - -#if DEBUG - // Отладка: проверить, есть ли юнит в result - if (result.Contains(targetUnitId)) - { - logger.LogDebug("Юнит {TargetUnitId} найден в финальном результате.", targetUnitId); - } - else - { - logger.LogDebug("Юнит {TargetUnitId} НЕ найден в финальном результате.", targetUnitId); - } -#endif - - return result; - } - - public async Task> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId) - { - logger.LogDebug("Начало GetRelatedUnitNamesAsync. JobId: {JobId}, UnitId: {UnitId}", jobId, unitId); - - var job = await jobService - .Get().AsNoTracking() - .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters) - .FirstOrDefaultAsync(j => j.Id == jobId); - - if (job == null) - { - logger.LogWarning("Job с Id {JobId} не найден.", jobId); - throw new ArgumentException($"Job {jobId} не найден.", nameof(jobId)); - } - - logger.LogDebug("Найден Job: {JobName}. Количество UnitFilters: {FilterCount}", job.Name, job.UnitFilters.Count()); - - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var filter in job.UnitFilters) - { - if (!filter.RelationshipFilters.Any()) continue; - - 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, участвующие в связях - var allRelatedUnitIds = parentLinks - .Select(l => l.ParentUnitId) - .Concat(childLinks.Select(l => l.ChildUnitId)) - .Distinct() - .ToList(); - - if (!allRelatedUnitIds.Any()) continue; - - // Получить значения для всех связанных юнитов - var allUnitValues = await unitInValueService.GetByUnitIdsAsync(allRelatedUnitIds); - - // Сгруппировать значения по UnitId - var valuesByUnit = allUnitValues - .GroupBy(uv => uv.UnitId) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Найти 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()); - - var matchingValues = values - .Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null) - .ToList(); - - 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; - - return hasMatch; - }); - - if (passesAllFilters) - matchingUnitIds.Add(relatedUnitId); - } - - if (matchingUnitIds.Any()) - { - var names = await unitService.Get().AsNoTracking() - .Where(u => matchingUnitIds.Contains(u.Id)) - .Select(u => u.Name) - .ToListAsync(); - result.UnionWith(names); - } - } - - return result.ToList(); - } - - #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 cachedData = await cacheService.GetCachedDataAsync(cacheKey); - if (cachedData != null) - { - logger.LogDebug("Кэш попал для UnitFilter '{Name}': {Count} юнитов", filter.UnitFilter, cachedData.UnitIds.Count); - return cachedData.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 - { - UnitIds = initialUnitIds, - Timestamp = DateTimeOffset.UtcNow, - Source = GetType().Name, - Version = 1 - }; - - await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)); - - 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/DomainServices/Shortcodes/ShortcodesService.cs b/PARR.DAL/DomainServices/Shortcodes/ShortcodesService.cs index b2be0eb9..600f825a 100644 --- a/PARR.DAL/DomainServices/Shortcodes/ShortcodesService.cs +++ b/PARR.DAL/DomainServices/Shortcodes/ShortcodesService.cs @@ -4,8 +4,8 @@ using PARR.Constants; using PARR.DAL.Cache.Services.Base; using PARR.DAL.Contracts; using PARR.DAL.DomainModels; -using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes.Models; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Models; using PARR.DAL.Models.Job; using PARR.DAL.Services.Interfaces; diff --git a/PARR.DAL/DomainServices/Interfaces/IUnitFilterService.cs b/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs similarity index 51% rename from PARR.DAL/DomainServices/Interfaces/IUnitFilterService.cs rename to PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs index 21ed7eaf..09b2541e 100644 --- a/PARR.DAL/DomainServices/Interfaces/IUnitFilterService.cs +++ b/PARR.DAL/DomainServices/UnitFilterService/IUnitFilterService.cs @@ -1,6 +1,7 @@ -using PARR.DAL.Models.Job; +using PARR.DAL.DomainServices.UnitFilterService.Models; +using PARR.DAL.Models.Job; -namespace PARR.DAL.DomainServices.Interfaces +namespace PARR.DAL.DomainServices.UnitFilterService { public interface IUnitFilterService { @@ -21,7 +22,29 @@ namespace PARR.DAL.DomainServices.Interfaces /// Максимальное количество Unit'ов (опционально) /// Список ID Unit'ов Task?> GetUnitsIdByJobFilterAsync( - Models.Job.Job job, + Job job, + int? takeCount = null); + + /// + /// Получает отфильтрованные Unit'ы с полной информацией о значениях полей и связях. + /// Возвращает DTO с данными для дальнейшей обработки в синхронизаторах. + /// + /// ID Job + /// Максимальное количество Unit'ов (опционально) + /// Список UnitFilterResultDto или null, если Job не найден + Task?> GetUnitsByJobFilterAsync( + Guid jobId, + int? takeCount = null); + + /// + /// Получает отфильтрованные Unit'ы с полной информацией о значениях полей и связях. + /// Возвращает DTO с данными для дальнейшей обработки в синхронизаторах. + /// + /// Job с предзагруженными UnitFilters, Group, GroupType + /// Максимальное количество Unit'ов (опционально) + /// Список UnitFilterResultDto + Task?> GetUnitsByJobFilterAsync( + Job job, int? takeCount = null); /// @@ -30,7 +53,6 @@ namespace PARR.DAL.DomainServices.Interfaces /// /// ID Job /// ID Unit, для которого ищутся связи - /// Токен отмены (опционально) /// Список имён связанных Unit'ов (уникальные, без дублей) Task> GetRelatedUnitNamesAsync( Guid jobId, diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/RelatedUnitDto.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/RelatedUnitDto.cs new file mode 100644 index 00000000..0850783b --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/RelatedUnitDto.cs @@ -0,0 +1,23 @@ +namespace PARR.DAL.DomainServices.UnitFilterService.Models +{ + /// + /// Связанный юнит с его значениями + /// + public class RelatedUnitDto + { + /// + /// ID связанного юнита + /// + public Guid UnitId { get; set; } + + /// + /// Наименование связанного юнита + /// + public string Name { get; set; } = null!; + + /// + /// Значения полей связанного юнита + /// + public List Values { get; set; } = new(); + } +} diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs new file mode 100644 index 00000000..36ac1f79 --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitFilterResultDto.cs @@ -0,0 +1,33 @@ +namespace PARR.DAL.DomainServices.UnitFilterService.Models +{ + /// + /// Результат фильтрации Unit'ов с полной информацией для синхронизаторов. + /// + public class UnitFilterResultDto + { + /// + /// Уникальный идентификатор юнита + /// + 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(); + } +} diff --git a/PARR.DAL/DomainServices/UnitFilterService/Models/UnitValueDto.cs b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitValueDto.cs new file mode 100644 index 00000000..6dd38d07 --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/Models/UnitValueDto.cs @@ -0,0 +1,19 @@ +namespace PARR.DAL.DomainServices.UnitFilterService.Models +{ + + /// + /// Значение поля юнита + /// + public class UnitValueDto + { + /// + /// ID поля + /// + public Guid FieldId { get; set; } + + /// + /// Значение поля + /// + public string? Value { get; set; } + } +} diff --git a/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs b/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs new file mode 100644 index 00000000..c4c369a2 --- /dev/null +++ b/PARR.DAL/DomainServices/UnitFilterService/UnitFilterService.cs @@ -0,0 +1,901 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PARR.DAL.Cache.Services.Base; +using PARR.DAL.Contracts; +using PARR.DAL.DomainServices.UnitFilterService.Models; +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; + +namespace PARR.DAL.DomainServices.UnitFilterService; + +internal class UnitFilterService : IUnitFilterService +{ +#if DEBUG + private readonly Guid targetUnitId = Guid.Parse("87fc4c36-1ea8-4163-983f-1605fee1de99"); +#endif + + private readonly ILogger logger; + private readonly IJobService jobService; + private readonly IUnitService unitService; + private readonly IUnitInUnitService unitInUnitService; + private readonly IRedisCacheService cacheService; + private readonly IUnitInValueService unitInValueService; + + public UnitFilterService( + ILogger logger, + IJobService jobService, + IUnitService unitService, + IUnitInUnitService unitInUnitService, + IUnitInValueService unitInValueService, + IRedisCacheService cacheService + ) + { + this.logger = logger; + this.jobService = jobService; + this.unitService = unitService; + this.unitInUnitService = unitInUnitService; + this.cacheService = cacheService; + this.unitInValueService = unitInValueService; + } + + #region DTO классы + private class UnitInfoDto + { + public Guid Id { get; set; } + public required string Name { get; set; } + } + + 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) + { + var filteredUnits = await GetUnitsByJobFilterAsync(job, takeCount); + 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) + { + if (job.Group == null) + throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group"); + if (job.Group.GroupType == null) + throw new ArgumentNullException(nameof(job.Group.GroupType), $"Job {job.Id} не содержит GroupType"); + if (job.UnitFilters == null || !job.UnitFilters.Any()) + throw new ArgumentNullException(nameof(job.UnitFilters), $"Job {job.Id} не содержит UnitFilters"); + + // Загружаем имена всех юнитов заранее для оптимизации + var allUnitNames = await LoadAllUnitNamesAsync(job.UnitFilters); + + var maxCount = takeCount ?? int.MaxValue; + var collectedUnits = new ConcurrentBag(); + var filterNumber = 0; + + foreach (var filter in job.UnitFilters) + { + filterNumber++; + if (collectedUnits.Count >= maxCount) break; + var remaining = maxCount - collectedUnits.Count; + if (remaining <= 0) break; + + try + { + logger.LogDebug("Применяем фильтр #{Index} (Id={FilterId})", filterNumber, filter.Id); + + // 1️ Найти ID юнитов по UnitFilter (Name LIKE) + var initialUnitIds = await GetUnitIdsFromCacheOrDbAsync(filter); + + logger.LogDebug("Базовый фильтр по Name '{NameFilter}' дал {Count} юнитов", filter.UnitFilter, initialUnitIds.Count); + +#if DEBUG + if (initialUnitIds.Contains(targetUnitId)) + { + logger.LogDebug("Юнит {TargetUnitId} найден в initialUnitIds.", targetUnitId); + } + else + { + logger.LogDebug("Юнит {TargetUnitId} НЕ найден в initialUnitIds.", targetUnitId); + } +#endif + + if (!initialUnitIds.Any()) + { + logger.LogDebug("Фильтр #{Index}: 0 юнитов после UnitFilter. Пропускаем.", filterNumber); + 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)) + { + 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); + } +#endif + + foreach (var unit in newUnits) + { + collectedUnits.Add(unit); + } + + logger.LogDebug("Фильтр #{Index}: найдено {Count} Unit'ов. Всего: {Total}", + filterNumber, newUnits.Count, collectedUnits.Count); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} (#{Index}) для Job {JobId}", + filter.Id, filterNumber, job.Id); + } + } + + var result = collectedUnits.Take(maxCount).ToList(); + logger.LogInformation("Job {JobId}: из {FilterCount} фильтров получено {UnitCount} уникальных Unit'ов", + job.Id, job.UnitFilters.Count, result.Count); + +#if DEBUG + if (result.Any(u => u.Id == targetUnitId)) + { + logger.LogDebug("Юнит {TargetUnitId} найден в финальном результате.", targetUnitId); + } + else + { + logger.LogDebug("Юнит {TargetUnitId} НЕ найден в финальном результате.", targetUnitId); + } +#endif + + return result; + } + + private async Task> LoadAllUnitNamesAsync(IEnumerable filters) + { + var unitIds = new HashSet(); + + foreach (var filter in filters) + { + var ids = await GetUnitIdsFromCacheOrDbAsync(filter); + foreach (var id in ids) + { + unitIds.Add(id); + } + } + + return await unitService.Get().AsNoTracking() + .Where(u => unitIds.Contains(u.Id)) + .Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name }) + .ToListAsync(); + } + + private UnitFilterResultDto CreateResultDto(UnitDto source) + { + return new UnitFilterResultDto + { + Id = source.Id, + Name = source.Name, + Values = new List(source.Values), + Parents = new List(source.Parents), + Children = new List(source.Children) + }; + } + + public async Task> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId) + { + logger.LogDebug("Начало GetRelatedUnitNamesAsync. JobId: {JobId}, UnitId: {UnitId}", jobId, unitId); + + var job = await jobService + .Get().AsNoTracking() + .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters) + .FirstOrDefaultAsync(j => j.Id == jobId); + + if (job == null) + { + logger.LogWarning("Job с Id {JobId} не найден.", jobId); + throw new ArgumentException($"Job {jobId} не найден.", nameof(jobId)); + } + + logger.LogDebug("Найден Job: {JobName}. Количество UnitFilters: {FilterCount}", job.Name, job.UnitFilters.Count()); + + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var filter in job.UnitFilters) + { + if (!filter.RelationshipFilters.Any()) continue; + + 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, участвующие в связях + var allRelatedUnitIds = parentLinks + .Select(l => l.ParentUnitId) + .Concat(childLinks.Select(l => l.ChildUnitId)) + .Distinct() + .ToList(); + + if (!allRelatedUnitIds.Any()) continue; + + // Получить значения для всех связанных юнитов + var allUnitValues = await unitInValueService.GetByUnitIdsAsync(allRelatedUnitIds); + + // Сгруппировать значения по UnitId + var valuesByUnit = allUnitValues + .GroupBy(uv => uv.UnitId) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Найти 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()); + + var matchingValues = values + .Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null) + .ToList(); + + 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; + + return hasMatch; + }); + + if (passesAllFilters) + matchingUnitIds.Add(relatedUnitId); + } + + if (matchingUnitIds.Any()) + { + var names = await unitService.Get().AsNoTracking() + .Where(u => matchingUnitIds.Contains(u.Id)) + .Select(u => u.Name) + .ToListAsync(); + result.UnionWith(names); + } + } + + return result.ToList(); + } + + #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 cachedData = await cacheService.GetCachedDataAsync(cacheKey); + if (cachedData != null) + { + logger.LogDebug("Кэш попал для UnitFilter '{Name}': {Count} юнитов", filter.UnitFilter, cachedData.UnitIds.Count); + return cachedData.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 + { + UnitIds = initialUnitIds, + Timestamp = DateTimeOffset.UtcNow, + Source = GetType().Name, + Version = 1 + }; + + await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)); + + 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 c90624fd..3b01f27f 100644 --- a/PARR.DAL/ParrDalInstaller.cs +++ b/PARR.DAL/ParrDalInstaller.cs @@ -8,6 +8,7 @@ using PARR.DAL.Contracts; using PARR.DAL.DomainServices.Implementations; using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.InfluxDbServices; using PARR.DAL.NextRunServices; using PARR.DAL.NextRunServices.Subservices; @@ -22,7 +23,6 @@ using PARR.DAL.Services.Interfaces.Schedule; using PARR.DAL.Services.Interfaces.Unit; using PARR.DAL.Settings; using PARR.DAL.TransformServices; -using System.ComponentModel; namespace PARR.DAL { diff --git a/PARR.TemplateMatcher/Services/Implemetaions/GroupedTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implemetaions/GroupedTemplateSynchronizer.cs index 3c423ba0..fbbfa87a 100644 --- a/PARR.TemplateMatcher/Services/Implemetaions/GroupedTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implemetaions/GroupedTemplateSynchronizer.cs @@ -6,6 +6,7 @@ using PARR.Common.Domain; using PARR.Constants; using PARR.DAL.Cache.Models; using PARR.DAL.DomainServices.Interfaces; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Models; using PARR.DAL.Models.Job; using PARR.DAL.Models.Unit; @@ -170,15 +171,18 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer logger.LogDebug("Используется Job {JobId} с максимальным MaxValueRelationships ({MaxValue}) для фильтрации.", maxJob.Id, maxJob.MaxValueRelationships); - // 3. Использовать фильтры maxJob для получения expectedUnitIds - var expectedUnitIds = await unitFilterService.GetUnitsIdByJobFilterAsync(maxJob.Id); - if (expectedUnitIds == null || !expectedUnitIds.Any()) + // 3. Использовать фильтры maxJob для получения отфильтрованных юнитов + var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(maxJob.Id); + if (filteredUnits == null || !filteredUnits.Any()) { logger.LogInformation("Для JobGroup {JobGroupId} фильтры не дали Unit'ов.", jobGroupId); await UpdateMatchingStatusAsync(jobGroupId, "Фильтры не дали Unit'ов"); return; } + // Извлекаем ID юнитов для последующих операций + var expectedUnitIds = filteredUnits.Select(u => u.Id).ToList(); + // 4. Отфильтровать expectedUnitIds по GroupingUnitFieldId if (!jobGroup.GroupingUnitFieldId.HasValue) { @@ -188,15 +192,14 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer } var groupingFieldId = jobGroup.GroupingUnitFieldId.Value; - var unitIdsWithValidGroupingFieldSet = await unitService.Get() - .AsNoTracking() - .Where(u => expectedUnitIds.Contains(u.Id)) - .Where(u => u.UnitValues.Any(uv => - uv.FieldId == groupingFieldId && - uv.Value != null && - !string.IsNullOrEmpty(uv.Value.Value))) + + // Фильтруем юниты по GroupingUnitFieldId используя данные из DTO + var unitIdsWithValidGroupingFieldSet = filteredUnits + .Where(u => u.Values.Any(v => + v.FieldId == groupingFieldId && + !string.IsNullOrEmpty(v.Value))) .Select(u => u.Id) - .ToListAsync(); + .ToList(); logger.LogDebug("После фильтрации по GroupingUnitFieldId осталось {Count} юнитов.", unitIdsWithValidGroupingFieldSet.Count); @@ -208,15 +211,13 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer } // Аналогично для фильтрации по "РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК" - var unitIdsWithValidWorkGroupFieldSet = await unitService.Get() - .AsNoTracking() - .Where(u => unitIdsWithValidGroupingFieldSet.Contains(u.Id)) - .Where(u => u.UnitValues.Any(uv => - uv.FieldId == workGroupFieldId && - uv.Value != null && - relationshipGroupValueIds.Contains(uv.Value.Id))) + var unitIdsWithValidWorkGroupFieldSet = filteredUnits + .Where(u => u.Values.Any(v => + v.FieldId == workGroupFieldId && + relationshipGroupValueIds.Contains(v.FieldId) && + !string.IsNullOrEmpty(v.Value))) .Select(u => u.Id) - .ToListAsync(); + .ToList(); logger.LogDebug("После фильтрации по 'РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК' осталось {Count} юнитов.", unitIdsWithValidWorkGroupFieldSet.Count); @@ -233,6 +234,8 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer var relationshipFilters = maxJob.UnitFilters?.SelectMany(uf => uf.RelationshipFilters).ToList() ?? new List(); logger.LogDebug("Получение связей UnitInUnit для юнитов, прошедших фильтрацию..."); + + // Используем unitIdsWithValidGroupingFieldSet для получения связей var potentialUnitInUnitLinks = await unitInUnitService.Get() .AsNoTracking() .Where(link => unitIdsWithValidGroupingFieldSet.Contains(link.ParentUnitId) || unitIdsWithValidGroupingFieldSet.Contains(link.ChildUnitId)) @@ -240,30 +243,26 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer logger.LogDebug("Найдено {Count} потенциальных связей UnitInUnit.", potentialUnitInUnitLinks.Count); + // Подготавливаем данные для фильтрации связей var allParentIds = potentialUnitInUnitLinks.Select(l => l.ParentUnitId).ToHashSet(); var allChildIds = potentialUnitInUnitLinks.Select(l => l.ChildUnitId).ToHashSet(); - var parentUnitValues = await unitInValueService.Get() - .AsNoTracking() - .Include(uv => uv.Field) - .Include(uv => uv.Value) - .Where(uv => allParentIds.Contains(uv.UnitId)) - .ToListAsync(); + // Фильтруем юниты для получения родительских и дочерних значений + var parentUnits = filteredUnits.Where(u => allParentIds.Contains(u.Id)).ToList(); + var childUnits = filteredUnits.Where(u => allChildIds.Contains(u.Id)).ToList(); - var childUnitValues = await unitInValueService.Get() - .AsNoTracking() - .Include(uv => uv.Field) - .Include(uv => uv.Value) - .Where(uv => allChildIds.Contains(uv.UnitId)) - .ToListAsync(); + // Создаем словари значений для родителей и детей + var parentValuesMap = parentUnits + .ToDictionary( + u => u.Id, + u => u.Values.ToDictionary(v => v.FieldId, v => v.Value) + ); - var parentValuesMap = parentUnitValues - .GroupBy(uv => uv.UnitId) - .ToDictionary(g => g.Key, g => g.ToList()); - - var childValuesMap = childUnitValues - .GroupBy(uv => uv.UnitId) - .ToDictionary(g => g.Key, g => g.ToList()); + var childValuesMap = childUnits + .ToDictionary( + u => u.Id, + u => u.Values.ToDictionary(v => v.FieldId, v => v.Value) + ); logger.LogDebug("Применение {Count} RelationshipFilters к найденным связям.", relationshipFilters.Count); @@ -273,15 +272,37 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer bool linkMatchesAllFilters = true; foreach (var rf in relationshipFilters) { - var valuesToCheck = rf.IsParent ? parentValuesMap.GetValueOrDefault(link.ParentUnitId, new List()) : childValuesMap.GetValueOrDefault(link.ChildUnitId, new List()); - - bool filterMatch = valuesToCheck.Any(uv => - uv.FieldId == rf.FieldId && - uv.Value != null && - uv.Value.Value != null && - uv.Value.Value.Contains(rf.ValueMask ?? "", StringComparison.OrdinalIgnoreCase) - ); + bool filterMatch; + if (rf.IsParent) + { + // Родительские значения + if (parentValuesMap.TryGetValue(link.ParentUnitId, out var parentValues) && + parentValues.TryGetValue(rf.FieldId, out var parentValue) && + parentValue != null) + { + filterMatch = parentValue.Contains(rf.ValueMask ?? "", StringComparison.OrdinalIgnoreCase); + } + else + { + filterMatch = false; + } + } + else + { + // Дочерние значения + if (childValuesMap.TryGetValue(link.ChildUnitId, out var childValues) && + childValues.TryGetValue(rf.FieldId, out var childValue) && + childValue != null) + { + filterMatch = childValue.Contains(rf.ValueMask ?? "", StringComparison.OrdinalIgnoreCase); + } + else + { + filterMatch = false; + } + } + // Учитываем инверсию фильтра if (rf.IsInverse) filterMatch = !filterMatch; @@ -350,9 +371,10 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer .Distinct() .ToList(); - var unitNamesMap = await unitService.Get() + // Создаем словарь имен для конфликтующих ключей из отфильтрованных юнитов + var unitNamesMap = filteredUnits .Where(u => conflictKeys.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.Name ?? string.Empty); + .ToDictionary(u => u.Id, u => u.Name); foreach (var conflictedUnitEntry in unitToKeys.Where(kvp => kvp.Value.Count > 1)) { @@ -404,10 +426,10 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer logger.LogDebug("Обработка связанного юнита {RelationshipUnitId} с {Count} юнитами из списка.", relationshipUnitId, childUnitIds.Count); - var childUnitNameMap = await unitService.Get() - .AsNoTracking() + // Получаем имена юнитов из отфильтрованных данных + var childUnitNameMap = filteredUnits .Where(u => childUnitIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.Name); + .ToDictionary(u => u.Id, u => u.Name); var sortedChildUnitIds = childUnitIds .OrderBy(id => childUnitNameMap.GetValueOrDefault(id, id.ToString())) @@ -454,10 +476,11 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer // === 2. Сравниваем детерминированно с сортировкой по имени === var allUnitIdsForSort = currentUnitIds.Concat(proposedUnitIds).Distinct().ToList(); - var unitNamesForSort = await unitService.Get() - .AsNoTracking() + + // Получаем имена юнитов из отфильтрованных данных + var unitNamesForSort = filteredUnits .Where(u => allUnitIdsForSort.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString()); + .ToDictionary(u => u.Id, u => u.Name); var sortedCurrentUnitIds = currentUnitIds .OrderBy(id => unitNamesForSort.GetValueOrDefault(id, id.ToString())) @@ -566,10 +589,12 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer { var relationshipUnitId = kvp.Key; var childUnitIds = kvp.Value; - var childUnitNameMapForDeactivate = await unitService.Get() - .AsNoTracking() + + // Получаем имена юнитов из отфильтрованных данных + var childUnitNameMapForDeactivate = filteredUnits .Where(u => childUnitIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u.Name); + .ToDictionary(u => u.Id, u => u.Name); + var sortedChildUnitIdsForDeactivate = childUnitIds .OrderBy(id => childUnitNameMapForDeactivate.GetValueOrDefault(id, id.ToString())) .ToList(); diff --git a/PARR.TemplateMatcher/Services/Implemetaions/SimpleTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implemetaions/SimpleTemplateSynchronizer.cs index c59409ca..f8136260 100644 --- a/PARR.TemplateMatcher/Services/Implemetaions/SimpleTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implemetaions/SimpleTemplateSynchronizer.cs @@ -8,6 +8,7 @@ using PARR.DAL.Cache.Models; using PARR.DAL.Contracts; using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes; +using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Models; using PARR.DAL.Models.Job; using PARR.DAL.Services.Interfaces; @@ -137,8 +138,9 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer return; } - var unitIds = await unitFilterService.GetUnitsIdByJobFilterAsync(jobId); - if (unitIds == null || !unitIds.Any()) + // === Получение отфильтрованных юнитов с полной информацией === + var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId); + if (filteredUnits == null || !filteredUnits.Any()) { logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId); @@ -162,6 +164,9 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer return; } + // Извлекаем ID юнитов для последующих операций + var unitIds = filteredUnits.Select(u => u.Id).ToList(); + #if DEBUG // Отладка: проверить, есть ли юнит в unitIds if (unitIds.Contains(targetUnitId)) @@ -198,26 +203,6 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer .Where(t => !unitIds.Contains(t.UnitId)) .ToList(); - // === Подсчёт операций === - int toCreate = newUnitIds.Count; - int toUpdate = 0; - int toDeactivate = unusedTemplates.Count; - - // Подсчёт обновлений по имени - foreach (var template in existingUsedTemplates) - { - if (unitIds.Contains(template.UnitId)) - { - var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template); - if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase)) - toUpdate++; - } - } - - await UpdateMatchingStatusAsync(jobId, $"Осталось: создать={toCreate}, обновить={toUpdate}, деактивировать={toDeactivate}"); - - // === Обработка новых юнитов === - int created = 0, updated = 0, deactivated = 0; foreach (var unitId in newUnitIds) { var reusableTemplate = await templateReuser.TryReuseOneUnusedTemplateAsync(jobId, unitId, initiator); @@ -262,8 +247,6 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer logger.LogDebug("Создание нового шаблона для Job {JobId}, UnitId {UnitId}.", jobId, unitId); await CreateSimpleTemplateAsync(jobId, unitId, initiator); } - created++; - await UpdateMatchingStatusAsync(jobId, $"Прогресс: создано={created}, обновлено={updated}, деактивировано={deactivated}. Осталось: создать={toCreate - created}, обновить={toUpdate - updated}, деактивировать={toDeactivate - deactivated}"); } // === Обработка существующих шаблонов (проверка имени) === @@ -295,8 +278,6 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer }; await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); - updated++; - await UpdateMatchingStatusAsync(jobId, $"Прогресс: создано={created}, обновлено={updated}, деактивировано={deactivated}. Осталось: создать={toCreate - created}, обновить={toUpdate - updated}, деактивировать={toDeactivate - deactivated}"); } } } @@ -306,8 +287,6 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer { logger.LogInformation("Деактивация лишнего шаблона {TemplateId} (Job {JobId}, UnitId {UnitId}).", unusedTemplate.Id, jobId, unusedTemplate.UnitId); await templateDeactivator.DeactivateTemplateAsync(unusedTemplate, initiator); - deactivated++; - await UpdateMatchingStatusAsync(jobId, $"Прогресс: создано={created}, обновлено={updated}, деактивировано={deactivated}. Осталось: создать={toCreate - created}, обновить={toUpdate - updated}, деактивировать={toDeactivate - deactivated}"); } // === Успешное завершение === @@ -372,8 +351,9 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer return; } - var unitIds = await unitFilterService.GetUnitsIdByJobFilterAsync(jobId); - if (unitIds == null || !unitIds.Any()) + // === Получение отфильтрованных юнитов с полной информацией === + var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId); + if (filteredUnits == null || !filteredUnits.Any()) { logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId); await UpdateMatchingStatusAsync(jobId, "Нет Unit'ов — обновление не требуется"); @@ -381,6 +361,9 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer return; } + // Извлекаем ID юнитов для последующих операций + var unitIds = filteredUnits.Select(u => u.Id).ToList(); + #if DEBUG // Отладка: проверить, есть ли юнит в unitIds if (unitIds.Contains(targetUnitId))