refactor(unitFilterService): Этапы фильтрации вынесены в отдельные классы

This commit is contained in:
Mikhail Kuznetsov
2026-06-09 17:46:43 +10:00
parent 6f8d889680
commit 535e87ec21
15 changed files with 773 additions and 792 deletions

View File

@@ -18,7 +18,8 @@ using PARR.Core.Services.TaskServices.Interfaces;
using PARR.Core.Services.TaskServices.Providers;
using PARR.Core.Services.TaskServices.ReconciliationHosted;
using PARR.Core.Services.UnitFilterService;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Core.Services.UnitFilterService.Matchers;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitService.Implementations;
using PARR.Core.Services.UnitService.Interfaces;
using PARR.Core.Services.Workload.Implementations;
@@ -131,11 +132,12 @@ namespace PARR.Core
#region UnitFilterService
services.AddTransient<IUnitFilterService, UnitFilterService>();
services.Configure<UnitFilterServiceOptions>(options =>
{
options.LoadBatchSize = 50;
});
services.AddScoped<IUnitFieldMatcher, UnitFieldMatcher>();
services.AddScoped<IUnitRelationshipMatcher, UnitRelationshipMatcher>();
services.AddScoped<IUmbrellaFilter, UmbrellaFilter>();
services.AddScoped<IUnitNameResolver, UnitNameResolver>();
services.AddScoped<IUnitFilterResultLoader, UnitFilterResultLoader>();
services.AddScoped<IUnitFilterService, UnitFilterService>();
#endregion

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
using PARR.Domain.Enums;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Применяет Umbrella-фильтр по количеству связей.
/// </summary>
internal class UmbrellaFilter : IUmbrellaFilter
{
private readonly ILogger<UmbrellaFilter> logger;
public UmbrellaFilter(ILogger<UmbrellaFilter> logger)
{
this.logger = logger;
}
public List<UnitFilterMatchResult> Apply(
List<UnitFilterMatchResult> contexts,
Job job)
{
if (job.Group?.GroupType?.Code != JobGroupTypesEnum.Umbrella)
return contexts;
var min = job.MinValueRelationships ?? 0;
var max = job.MaxValueRelationships ?? int.MaxValue;
var isParentDirection = job.IsParentRelationships == true;
if (min == 0 && max == int.MaxValue)
return contexts;
logger.LogDebug("UmbrellaFilter: Min={Min}, Max={Max}, IsParent={IsParent}, Вход={Count}",
min, max, isParentDirection, contexts.Count);
var result = new List<UnitFilterMatchResult>(contexts.Count);
foreach (var context in contexts)
{
int count = isParentDirection
? context.ValidParentIds.Count
: context.ValidChildIds.Count;
if (count >= min && count <= max)
result.Add(context);
}
logger.LogDebug("UmbrellaFilter: Выход={Count} (отфильтровано {Filtered})",
result.Count, contexts.Count - result.Count);
return result;
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Domain.Entities.Job;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Сопоставляет юниты с фильтрами по атрибутам.
/// </summary>
internal class UnitFieldMatcher : IUnitFieldMatcher
{
private const int chunkSize = 1000;
private readonly IUnitRepository unitRepository;
private readonly ILogger<UnitFieldMatcher> logger;
public UnitFieldMatcher(
IUnitRepository unitRepository,
ILogger<UnitFieldMatcher> logger)
{
this.unitRepository = unitRepository;
this.logger = logger;
}
public async Task<List<Guid>> MatchAsync(
IReadOnlyList<Guid> unitIds,
IEnumerable<JobFieldFilter> fieldFilters,
CancellationToken ct = default)
{
var filters = fieldFilters?.ToList();
if (filters == null || filters.Count == 0)
return new List<Guid>(unitIds);
logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
unitIds.Count, filters.Count);
var result = new List<Guid>(unitIds.Count);
foreach (var chunk in unitIds.Chunk(chunkSize))
{
var query = unitRepository.Get().AsNoTracking()
.Where(u => chunk.Contains(u.Id));
foreach (var fieldFilter in filters)
{
var valueMask = fieldFilter.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
continue;
query = unitRepository.GetUnitByFieldAndValue(
query, fieldFilter.FieldId, fieldFilter.ValueMask!, fieldFilter.IsInverse);
}
var chunkResult = await query.Select(u => u.Id).ToListAsync(ct);
result.AddRange(chunkResult);
}
logger.LogDebug("UnitFieldMatcher: выход {UnitCount} юнитов", result.Count);
return result;
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Core.Services.UnitService.Interfaces;
using PARR.Domain.DTOs.UnitDto;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Загружает полные данные юнитов через IUnitService и формирует UnitFilterResultDto.
/// </summary>
internal class UnitFilterResultLoader : IUnitFilterResultLoader
{
private readonly IUnitService unitService;
private readonly ILogger<UnitFilterResultLoader> logger;
public UnitFilterResultLoader(
IUnitService unitService,
ILogger<UnitFilterResultLoader> logger)
{
this.unitService = unitService;
this.logger = logger;
}
public async Task<List<UnitFilterResultDto>> LoadAsync(
List<UnitFilterMatchResult> contexts,
CancellationToken ct = default)
{
if (contexts.Count == 0)
return new List<UnitFilterResultDto>();
// Собираем ВСЕ уникальные ID сразу
var allRequiredUnitIds = new HashSet<Guid>();
foreach (var ctx in contexts)
{
allRequiredUnitIds.Add(ctx.UnitId);
foreach (var id in ctx.ValidParentIds) allRequiredUnitIds.Add(id);
foreach (var id in ctx.ValidChildIds) allRequiredUnitIds.Add(id);
}
// Один вызов — UnitCacheService сам решает, как грузить
var cachedUnitsMap = await unitService.GetWithCachingAsync(allRequiredUnitIds);
var result = new List<UnitFilterResultDto>(contexts.Count);
int missedCount = 0;
foreach (var context in contexts)
{
ct.ThrowIfCancellationRequested();
if (!cachedUnitsMap.TryGetValue(context.UnitId, out var unitInfo))
{
missedCount++;
logger.LogWarning("Юнит {UnitId} не найден в кэше при формировании результата фильтрации.", context.UnitId);
continue;
}
var dto = new UnitFilterResultDto
{
Id = unitInfo.Id,
Name = unitInfo.Name,
Values = unitInfo.Values.Select(v => new UnitValueDto
{
FieldId = v.FieldId,
Value = v.Value
}).ToList(),
Parents = context.ValidParentIds
.Where(id => cachedUnitsMap.ContainsKey(id))
.Select(id => MapToRelatedUnitDto(cachedUnitsMap[id]))
.ToList(),
Children = context.ValidChildIds
.Where(id => cachedUnitsMap.ContainsKey(id))
.Select(id => MapToRelatedUnitDto(cachedUnitsMap[id]))
.ToList()
};
result.Add(dto);
}
if (missedCount > 0)
{
logger.LogWarning("LoadAsync: {MissedCount} из {TotalCount} юнитов не найдены в кэше для Job фильтрации.",
missedCount, contexts.Count);
}
return result;
}
/// <summary>
/// Маппинг UnitInfo из кэша в RelatedUnitDto для родителей/детей
/// </summary>
private static RelatedUnitDto MapToRelatedUnitDto(UnitInfo unitInfo)
{
return new RelatedUnitDto
{
UnitId = unitInfo.Id,
Name = unitInfo.Name,
Values = unitInfo.Values.Select(v => new UnitValueDto
{
FieldId = v.FieldId,
Value = v.Value
}).ToList()
};
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Domain.Cache.Models;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Находит ID юнитов по маске имени через Redis-кэш с fallback на БД.
/// </summary>
internal class UnitNameResolver : IUnitNameResolver
{
private readonly IRedisCacheService cacheService;
private readonly IUnitRepository unitRepository;
private readonly ILogger<UnitNameResolver> logger;
public UnitNameResolver(
IRedisCacheService cacheService,
IUnitRepository unitRepository,
ILogger<UnitNameResolver> logger)
{
this.cacheService = cacheService;
this.unitRepository = unitRepository;
this.logger = logger;
}
public async Task<List<Guid>> ResolveAsync(string unitFilterMask, CancellationToken ct = default)
{
var cacheKey = cacheService.GetKey(new[] { "unit filter", "unit name mask" }, new[] { unitFilterMask });
var cachedData = await cacheService.GetCachedDataAsync<UnitFilterIds>(cacheKey, true);
if (cachedData != null)
{
logger.LogDebug("UnitNameResolver: маска '{Mask}' найдена в кэше ({Count} юнитов)",
unitFilterMask, cachedData.Data.UnitIds.Count);
return cachedData.Data.UnitIds;
}
var dbValueMask = NormalizeLikeMask(unitFilterMask);
var initialUnitIds = await unitRepository.GetInitialUnitIds(dbValueMask).ToListAsync(ct);
var toCache = new UnitFilterIds
{
Data = new UnitFilterIdsDto { UnitIds = initialUnitIds },
Timestamp = DateTimeOffset.UtcNow,
Source = GetType().Name
};
await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1), true);
logger.LogDebug("UnitNameResolver: маска '{Mask}' загружена из БД и сохранена в кэш ({Count} юнитов)",
unitFilterMask, initialUnitIds.Count);
return initialUnitIds;
}
/// <summary>
/// Обрабатывает маску LIKE для корректной работы с SQL
/// </summary>
private static string NormalizeLikeMask(string valueMask)
{
if (string.IsNullOrWhiteSpace(valueMask))
return valueMask;
valueMask = valueMask.Trim();
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
if (isStartsWith && isEndsWith)
return $"%{valueMask.Trim('%')}%";
else if (isStartsWith)
return $"{valueMask.TrimEnd('%')}%";
else if (isEndsWith)
return $"%{valueMask.TrimStart('%')}";
else
return valueMask;
}
}

View File

@@ -0,0 +1,261 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Сопоставляет юниты с фильтрами по связям.
/// </summary>
internal class UnitRelationshipMatcher : IUnitRelationshipMatcher
{
private readonly IUnitInUnitRepository unitInUnitRepository;
private readonly IUnitInValueRepository unitInValueRepository;
private readonly ILogger<UnitRelationshipMatcher> logger;
public UnitRelationshipMatcher(
IUnitInUnitRepository unitInUnitRepository,
IUnitInValueRepository unitInValueRepository,
ILogger<UnitRelationshipMatcher> logger)
{
this.unitInUnitRepository = unitInUnitRepository;
this.unitInValueRepository = unitInValueRepository;
this.logger = logger;
}
public async Task<List<UnitFilterMatchResult>> MatchAsync(
IReadOnlyList<Guid> unitIds,
IEnumerable<JobRelationshipFilter> relationshipFilters,
CancellationToken ct = default)
{
var filters = relationshipFilters?.ToList();
if (filters == null || filters.Count == 0 || unitIds.Count == 0)
{
logger.LogDebug("UnitRelationshipMatcher: вход {UnitCount} юнитов, фильтров: 0 → возврат без изменений",
unitIds.Count);
return unitIds.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList();
}
logger.LogDebug("UnitRelationshipMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
unitIds.Count, filters.Count);
var preFilteredUnitsById = unitIds.ToDictionary(id => id, id => new UnitFilterMatchResult { UnitId = id });
var parentRelFilters = filters.Where(rf => rf.IsParent).ToList();
var childRelFilters = filters.Where(rf => !rf.IsParent).ToList();
if (parentRelFilters.Any())
{
await ApplyFiltersToUnitsAsync(
unitIds, preFilteredUnitsById, parentRelFilters,
isParentDirection: true, ct);
}
if (childRelFilters.Any())
{
await ApplyFiltersToUnitsAsync(
unitIds, preFilteredUnitsById, childRelFilters,
isParentDirection: false, ct);
}
var result = preFilteredUnitsById.Values.ToList();
logger.LogDebug("UnitRelationshipMatcher: выход {ContextCount} контекстов (из {InitialCount})",
result.Count, preFilteredUnitsById.Count);
return result;
}
/// <summary>
/// Применяет фильтры к связям (родителям или детям)
/// </summary>
private async Task ApplyFiltersToUnitsAsync(
IReadOnlyList<Guid> unitIds,
Dictionary<Guid, UnitFilterMatchResult> preFilteredUnitsById,
List<JobRelationshipFilter> relFilters,
bool isParentDirection,
CancellationToken cancellationToken)
{
if (!relFilters.Any())
return;
var directionName = isParentDirection ? "Родительские" : "Дочерние";
logger.LogDebug(" {Direction} фильтры ({Count}):", directionName, relFilters.Count);
// 1. Получить все связи для юнитов из unitIds
var allLinks = await unitInUnitRepository.Get()
.AsNoTracking()
.Where(link => isParentDirection
? unitIds.Contains(link.ChildUnitId)
: unitIds.Contains(link.ParentUnitId))
.Select(link => new
{
SourceId = isParentDirection ? link.ChildUnitId : link.ParentUnitId,
TargetId = isParentDirection ? link.ParentUnitId : link.ChildUnitId
})
.ToListAsync(cancellationToken);
var allRelatedUnitIds = allLinks.Select(l => l.TargetId).Distinct().ToList();
logger.LogDebug(" Найдено {TargetCount} уникальных {TargetType} для {LinkCount} связей",
allRelatedUnitIds.Count,
isParentDirection ? "родителей" : "детей",
allLinks.Count);
// 2. Сгруппировать связи по SourceId
var relationshipsByUnitId = allLinks.GroupBy(l => l.SourceId)
.ToDictionary(g => g.Key, g => g.Select(l => l.TargetId).ToList());
// 3. Для каждого фильтра найти TargetId, которые ему соответствуют
var matchedTargetIdsByFilter = new Dictionary<JobRelationshipFilter, HashSet<Guid>>();
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
continue;
string dbValueMask = NormalizeLikeMask(valueMask);
var fieldName = relFilter.UnitField?.AihitName ?? $"FieldId={relFilter.FieldId}";
logger.LogDebug(" RelationshipFilter ({Direction}): Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}, IsFullMatch={IsFullMatch}",
isParentDirection ? "Parent" : "Child",
fieldName, dbValueMask, relFilter.IsInverse, relFilter.IsFullMatch);
var matchingTargetIds = await unitInValueRepository.GetMatchingTargetIds(relFilter.FieldId, dbValueMask)
.ToListAsync(cancellationToken);
var filteredMatchingTargetIds = matchingTargetIds
.Intersect(allRelatedUnitIds)
.ToList();
logger.LogDebug(" Найдено {MatchCount} {TargetType} по маске",
filteredMatchingTargetIds.Count,
isParentDirection ? "родителей" : "детей");
HashSet<Guid> targetIdsThatPassThisFilter;
if (relFilter.IsInverse)
{
targetIdsThatPassThisFilter = new HashSet<Guid>(allRelatedUnitIds.Except(filteredMatchingTargetIds));
logger.LogDebug(" (IsInverse) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count);
}
else
{
targetIdsThatPassThisFilter = new HashSet<Guid>(filteredMatchingTargetIds);
logger.LogDebug(" (Direct) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count);
}
matchedTargetIdsByFilter[relFilter] = targetIdsThatPassThisFilter;
}
// 4. Определить, какие юниты проходят ВСЕ фильтры с учётом IsFullMatch
var unitIdsToRemove = new HashSet<Guid>();
foreach (var context in preFilteredUnitsById.Values)
{
var relatedUnitIds = relationshipsByUnitId.GetValueOrDefault(context.UnitId, new List<Guid>()).ToHashSet();
bool hasTargets = relatedUnitIds.Count > 0;
if (!hasTargets)
{
var hasFiltersWithMask = relFilters.Any(rf => !string.IsNullOrWhiteSpace(rf.ValueMask?.Trim()));
if (hasFiltersWithMask)
{
unitIdsToRemove.Add(context.UnitId);
continue;
}
}
bool unitPassesCurrentFilterGroup = true;
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
continue;
var targetIdsThatPassThisFilter = matchedTargetIdsByFilter[relFilter];
if (relFilter.IsFullMatch)
{
bool allTargetsPass = relatedUnitIds.All(targetId => targetIdsThatPassThisFilter.Contains(targetId));
if (!allTargetsPass)
{
unitPassesCurrentFilterGroup = false;
break;
}
}
else
{
bool anyTargetPasses = relatedUnitIds.Any(targetId => targetIdsThatPassThisFilter.Contains(targetId));
if (!anyTargetPasses)
{
unitPassesCurrentFilterGroup = false;
break;
}
}
}
if (!unitPassesCurrentFilterGroup)
{
unitIdsToRemove.Add(context.UnitId);
}
else
{
var validRelatedIds = new HashSet<Guid>(relatedUnitIds);
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
continue;
validRelatedIds.IntersectWith(matchedTargetIdsByFilter[relFilter]);
}
if (isParentDirection)
context.ValidParentIds.UnionWith(validRelatedIds);
else
context.ValidChildIds.UnionWith(validRelatedIds);
}
}
foreach (var unitId in unitIdsToRemove)
preFilteredUnitsById.Remove(unitId);
logger.LogDebug(" Удалено {RemovedCount} юнитов, не прошедших фильтры", unitIdsToRemove.Count);
var totalAdded = preFilteredUnitsById.Values.Sum(c =>
isParentDirection ? c.ValidParentIds.Count : c.ValidChildIds.Count);
logger.LogDebug(" Добавлено {Total} {TargetType} связей для {UnitCount} юнитов",
totalAdded,
isParentDirection ? "родительских" : "дочерних",
preFilteredUnitsById.Count);
}
/// <summary>
/// Обрабатывает маску LIKE для корректной работы с SQL
/// </summary>
private static string NormalizeLikeMask(string valueMask)
{
if (string.IsNullOrWhiteSpace(valueMask))
return valueMask;
valueMask = valueMask.Trim();
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
if (isStartsWith && isEndsWith)
return $"%{valueMask.Trim('%')}%";
else if (isStartsWith)
return $"{valueMask.TrimEnd('%')}%";
else if (isEndsWith)
return $"%{valueMask.TrimStart('%')}";
else
return valueMask;
}
}

View File

@@ -0,0 +1,21 @@
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces
{
/// <summary>
/// Применяет Umbrella-фильтр по количеству связей в памяти.
/// </summary>
internal interface IUmbrellaFilter
{
/// <summary>
/// Фильтрует контексты по min/max количеству родительских или дочерних связей.
/// </summary>
/// <param name="contexts">Входящие контексты юнитов.</param>
/// <param name="job">Job с параметрами Umbrella-фильтра.</param>
/// <returns>Отфильтрованный список контекстов.</returns>
List<UnitFilterMatchResult> Apply(
List<UnitFilterMatchResult> contexts,
Job job);
}
}

View File

@@ -0,0 +1,18 @@
using PARR.Domain.Entities.Job;
namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces
{
/// <summary>
/// Сопоставляет юниты с фильтрами по полям.
/// </summary>
public interface IUnitFieldMatcher
{
/// <summary>
/// Возвращает ID юнитов, прошедших все указанные фильтры.
/// </summary>
Task<List<Guid>> MatchAsync(
IReadOnlyList<Guid> unitIds,
IEnumerable<JobFieldFilter> fieldFilters,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,20 @@
using PARR.Core.Services.UnitFilterService.Models;
namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces
{
/// <summary>
/// Загружает полные данные юнитов для формирования итогового результата фильтрации.
/// </summary>
internal interface IUnitFilterResultLoader
{
/// <summary>
/// Загружает юниты, их родителей и детей из кэша и формирует DTO.
/// </summary>
/// <param name="contexts">Контексты юнитов с валидными связями.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Список DTO с полными данными.</returns>
Task<List<UnitFilterResultDto>> LoadAsync(
List<UnitFilterMatchResult> contexts,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,16 @@
namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces
{
/// <summary>
/// Находит ID юнитов по маске имени.
/// </summary>
public interface IUnitNameResolver
{
/// <summary>
/// Возвращает список ID юнитов, чьи имена соответствуют маске.
/// </summary>
/// <param name="unitFilterMask">Маска имени (поддерживает %).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Список ID подходящих юнитов.</returns>
Task<List<Guid>> ResolveAsync(string unitFilterMask, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,20 @@
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces
{
/// <summary>
/// Сопоставляет юниты с фильтрами по связям (родители/дети).
/// </summary>
internal interface IUnitRelationshipMatcher
{
/// <summary>
/// Применяет фильтры по связям к набору юнитов.
/// Возвращает контексты с валидными родительскими и дочерними связями.
/// </summary>
Task<List<UnitFilterMatchResult>> MatchAsync(
IReadOnlyList<Guid> unitIds,
IEnumerable<JobRelationshipFilter> relationshipFilters,
CancellationToken ct = default);
}
}

View File

@@ -1,7 +0,0 @@
namespace PARR.Core.Services.UnitFilterService.Models
{
internal class UnitFilterServiceOptions
{
public int LoadBatchSize { get; set; } = 1000;
}
}

View File

@@ -1,14 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Cache.Models;
using PARR.Core.Services.UnitService.Interfaces;
using PARR.Domain.Entities.Job;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
using System.Diagnostics;
namespace PARR.Core.Services.UnitFilterService;
@@ -19,39 +18,44 @@ internal class UnitFilterService : IUnitFilterService
private readonly Guid debugTargetUnitId = Guid.Parse("a3b5f3e2-928e-48b7-b481-df4556e1ed32");
#endif
private const int DebugMaxUnitsToLog = 10;
private const int DebugMaxRelationsToLog = 5;
private readonly int batchSize;
private readonly ILogger<UnitFilterService> logger;
private readonly IJobRepository jobService;
private readonly IUnitRepository unitService;
private readonly IUnitInUnitRepository unitInUnitService;
private readonly IRedisCacheService cacheService;
private readonly IUnitFieldRepository unitFieldService;
private readonly IUnitInValueRepository unitInValueService;
private readonly IJobRepository jobRepository;
private readonly IUnitInUnitRepository unitInUnitRepository;
private readonly IUnitInValueRepository unitInValueRepository;
private readonly IUnitService unitService;
private readonly IUnitFieldMatcher unitFieldMatcher;
private readonly IUnitRelationshipMatcher unitRelationshipMatcher;
private readonly IUmbrellaFilter umbrellaFilter;
private readonly IUnitFilterResultLoader resultLoader;
private readonly IUnitNameResolver nameResolver;
public UnitFilterService(
ILogger<UnitFilterService> logger,
IJobRepository jobService,
IUnitRepository unitService,
IUnitInUnitRepository unitInUnitService,
IUnitInValueRepository unitInValueService,
IJobRepository jobRepository,
IUnitRepository unitRepository,
IUnitInUnitRepository unitInUnitRepository,
IUnitInValueRepository unitInValueRepository,
IRedisCacheService cacheService,
IOptions<UnitFilterServiceOptions> options,
IUnitFieldRepository unitFieldService
IUnitFieldRepository unitFieldRepository,
IUnitService unitService,
IUnitFieldMatcher unitFieldMatcher,
IUnitRelationshipMatcher unitRelationshipMatcher,
IUmbrellaFilter umbrellaFilter,
IUnitFilterResultLoader resultLoader,
IUnitNameResolver nameResolver
)
{
this.logger = logger;
this.jobService = jobService;
this.jobRepository = jobRepository;
this.unitInUnitRepository = unitInUnitRepository;
this.unitInValueRepository = unitInValueRepository;
this.unitService = unitService;
this.unitInUnitService = unitInUnitService;
this.cacheService = cacheService;
this.unitFieldService = unitFieldService;
this.unitInValueService = unitInValueService;
batchSize = options.Value.LoadBatchSize;
this.unitFieldMatcher = unitFieldMatcher;
this.unitRelationshipMatcher = unitRelationshipMatcher;
this.umbrellaFilter = umbrellaFilter;
this.resultLoader = resultLoader;
this.nameResolver = nameResolver;
}
@@ -109,8 +113,8 @@ internal class UnitFilterService : IUnitFilterService
{
logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id);
// 1. Найти ID юнитов по UnitFilter (Name LIKE)
var initialUnitIds = await GetUnitIdsFromCacheOrDbAsync(filter, cancellationToken);
// 1. Найти ID юнитов по UnitFilter
var initialUnitIds = await nameResolver.ResolveAsync(filter.UnitFilter, cancellationToken);
if (!initialUnitIds.Any())
{
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов)", i + 1);
@@ -124,10 +128,10 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 2. Применить FieldFilters на уровне SQL
// 2. Применить FieldFilters
var fieldStopwatch = Stopwatch.StartNew();
var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
fieldStopwatch.Stop();
//var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
var fieldFilteredIds = await unitFieldMatcher.MatchAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
if (!fieldFilteredIds.Any())
{
@@ -142,10 +146,10 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 3. Применить RelationshipFilters на уровне SQL -> ВОЗВРАЩАЕТ UnitFilterMatchResult
// 3. Применить RelationshipFilters
var relStopwatch = Stopwatch.StartNew();
var relationshipFilteredContexts = await ProcessRelationshipFiltersAsync(fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
relStopwatch.Stop();
var relationshipFilteredContexts = await unitRelationshipMatcher.MatchAsync(
fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
if (!relationshipFilteredContexts.Any())
{
@@ -193,48 +197,15 @@ internal class UnitFilterService : IUnitFilterService
ValidChildIds = new HashSet<Guid>(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
var targetBeforeUmbrella = mergedContexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
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}",
debugTargetUnitId, 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 == debugTargetUnitId);
if (targetBeforeUmbrella != null && targetAfterUmbrella == null)
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} ОТФИЛЬТРОВАН на этапе Umbrella", debugTargetUnitId);
}
#endif
}
var umbrellaFilteredContexts = umbrellaFilter.Apply(mergedContexts, job);
// Этап 3: Применение takeCount и загрузка полных данных для результата
var finalContexts = umbrellaFilteredContexts.Take(takeCount ?? int.MaxValue).ToList();
var loadStopwatch = Stopwatch.StartNew();
var result = await LoadFinalResultAsync(finalContexts);
var result = await resultLoader.LoadAsync(finalContexts, cancellationToken);
loadStopwatch.Stop();
totalStopwatch.Stop();
@@ -243,15 +214,14 @@ internal class UnitFilterService : IUnitFilterService
job.Id,
totalStopwatch.ElapsedMilliseconds,
result.Count,
loadStopwatch.ElapsedMilliseconds
);
loadStopwatch.ElapsedMilliseconds);
return result;
}
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
{
return await jobService
return await jobRepository
.Get().AsNoTracking()
.AsSplitQuery()
.Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
@@ -261,431 +231,11 @@ internal class UnitFilterService : IUnitFilterService
}
/// <summary>
/// Применяем фильтры аттрибутов
/// </summary>
private async Task<List<Guid>> ApplyFieldFiltersOnDbAsync(
List<Guid> unitIds,
IEnumerable<JobFieldFilter> fieldFilters,
CancellationToken cancellationToken = default)
{
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)
{
filterIndex++;
var valueMask = fieldFilter.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
continue;
string dbValueMask = NormalizeLikeMask(valueMask);
var fieldName = fieldFilter.UnitField?.AihitName ?? $"FieldId={fieldFilter.FieldId}";
logger.LogDebug(" FieldFilter #{Index}: Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}",
filterIndex, fieldName, dbValueMask, fieldFilter.IsInverse);
query = unitService.GetUnitByFieldAndValue(query, fieldFilter.FieldId, fieldFilter.ValueMask!, fieldFilter.IsInverse);
#if DEBUG
if (unitIds.Contains(debugTargetUnitId))
{
var unitHasField = await unitInValueService.Get()
.AsNoTracking()
.AnyAsync(uiv => uiv.UnitId == debugTargetUnitId && uiv.FieldId == fieldFilter.FieldId, cancellationToken);
var unitValue = await unitInValueService.Get()
.AsNoTracking()
.Where(uiv => uiv.UnitId == debugTargetUnitId && uiv.FieldId == fieldFilter.FieldId)
.Select(uiv => uiv.Value.Value)
.FirstOrDefaultAsync(cancellationToken);
var intermediateResult = await query.Select(u => u.Id).ToListAsync(cancellationToken);
var passes = intermediateResult.Contains(debugTargetUnitId);
logger.LogDebug(" DEBUG: Юнит {TargetUnitId}: Поле={FieldName}, Значение={Value}, Маска={Mask}, HasField={HasField}, Проходит={Passes}",
debugTargetUnitId, fieldName, unitValue ?? "null", dbValueMask, unitHasField, passes);
query = unitService.Get().AsNoTracking()
.Where(u => intermediateResult.Contains(u.Id));
}
#endif
}
var result = await query.Select(u => u.Id).ToListAsync(cancellationToken);
logger.LogDebug("ApplyFieldFiltersOnDbAsync: выход {UnitCount} юнитов", result.Count);
return result;
}
/// <summary>
/// Применяем фильтры аттрибутов у связанных ЭК
/// </summary>
private async Task<List<UnitFilterMatchResult>> ProcessRelationshipFiltersAsync(
List<Guid> unitIds,
IEnumerable<JobRelationshipFilter> relationshipFilters,
CancellationToken cancellationToken = default)
{
if (!relationshipFilters.Any() || !unitIds.Any())
{
logger.LogDebug("ProcessRelationshipFiltersAsync: вход {UnitCount} юнитов, фильтров: 0 → возврат без изменений",
unitIds.Count);
return unitIds.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList();
}
logger.LogDebug("ProcessRelationshipFiltersAsync: вход {UnitCount} юнитов, фильтров: {FilterCount}",
unitIds.Count, relationshipFilters.Count());
#if DEBUG
if (unitIds.Contains(debugTargetUnitId))
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} найден во входных данных ProcessRelationshipFiltersAsync", debugTargetUnitId);
}
#endif
var preFilteredUnitsById = unitIds.ToDictionary(id => id, id => new UnitFilterMatchResult { UnitId = id });
var parentRelFilters = relationshipFilters.Where(rf => rf.IsParent).ToList();
var childRelFilters = relationshipFilters.Where(rf => !rf.IsParent).ToList();
if (parentRelFilters.Any())
{
await ApplyRelationshipFiltersToUnitsAsync(
unitIds,
preFilteredUnitsById,
parentRelFilters,
isParentDirection: true,
cancellationToken);
}
if (childRelFilters.Any())
{
await ApplyRelationshipFiltersToUnitsAsync(
unitIds,
preFilteredUnitsById,
childRelFilters,
isParentDirection: false,
cancellationToken);
}
#if DEBUG
var unitsWithAnyConnections = preFilteredUnitsById.Values.Count(c => c.ValidParentIds.Any() || c.ValidChildIds.Any());
var unitsWithoutConnections = preFilteredUnitsById.Values.Count(c => !c.ValidParentIds.Any() && !c.ValidChildIds.Any());
logger.LogDebug("DEBUG: После ApplyRelationshipFiltersToUnitsAsync: {WithConnections} юнитов со связями, {WithoutConnections} без связей",
unitsWithAnyConnections, unitsWithoutConnections);
var targetContext = preFilteredUnitsById.Values.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
if (targetContext != null)
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId}: Родителей={ParentCount}, Детей={ChildCount}, PassesFilter={Passes}",
debugTargetUnitId,
targetContext.ValidParentIds.Count,
targetContext.ValidChildIds.Count,
targetContext.ValidParentIds.Any() || targetContext.ValidChildIds.Any());
}
#endif
var result = preFilteredUnitsById.Values.ToList();
logger.LogDebug("ProcessRelationshipFiltersAsync: выход {ContextCount} контекстов (из {InitialCount})",
result.Count, preFilteredUnitsById.Count);
#if DEBUG
if (unitIds.Contains(debugTargetUnitId))
{
var targetContextInResult = result.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
if (targetContextInResult != null)
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} в результатах ProcessRelationshipFiltersAsync. Родителей: {ParentCount}, Детей: {ChildCount}",
debugTargetUnitId, targetContextInResult.ValidParentIds.Count, targetContextInResult.ValidChildIds.Count);
}
else
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} НЕ в результатах ProcessRelationshipFiltersAsync (удалён)", debugTargetUnitId);
}
}
if (logger.IsEnabled(LogLevel.Debug))
{
var allUnitIds = result.Select(c => c.UnitId)
.Concat(result.SelectMany(c => c.ValidParentIds))
.Concat(result.SelectMany(c => c.ValidChildIds))
.Distinct()
.ToList();
var unitsMap = await unitService.Get().AsNoTracking()
.Where(u => allUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken);
var parentFieldIds = relationshipFilters.Where(rf => rf.IsParent).Select(rf => rf.FieldId).Distinct().ToList();
var childFieldIds = relationshipFilters.Where(rf => !rf.IsParent).Select(rf => rf.FieldId).Distinct().ToList();
var allFieldIds = parentFieldIds.Concat(childFieldIds).Distinct().ToList();
var allValues = await unitInValueService.GetByUnitIdsAndFieldIdsAsync(allUnitIds, allFieldIds);
var valuesByUnit = allValues.GroupBy(v => v.UnitId)
.ToDictionary(g => g.Key, g => g.ToList());
var fieldsMap = await unitFieldService.Get()
.AsNoTracking()
.Where(f => allFieldIds.Contains(f.Id))
.ToDictionaryAsync(f => f.Id, f => f.AihitName, cancellationToken);
foreach (var context in result.Take(DebugMaxUnitsToLog))
{
var u = unitsMap.GetValueOrDefault(context.UnitId);
if (u == null)
continue;
logger.LogDebug("Найден ЭК {UnitName} (Id={UnitId})", u.Name, u.Id);
if (context.ValidParentIds.Any())
{
logger.LogDebug("\tФильтрам соответствуют {ParentsCount} родителей:", context.ValidParentIds.Count);
foreach (var parentId in context.ValidParentIds.Take(DebugMaxRelationsToLog))
{
var p = unitsMap.GetValueOrDefault(parentId);
logger.LogDebug("\t\t{ParentName} (Id={ParentId})", p?.Name ?? "null", parentId);
if (parentFieldIds.Any() && valuesByUnit.TryGetValue(parentId, out var parentValues))
{
foreach (var pv in parentValues.Where(v => parentFieldIds.Contains(v.FieldId)))
{
var fieldName = fieldsMap.GetValueOrDefault(pv.FieldId) ?? $"FieldId={pv.FieldId}";
logger.LogDebug("\t\t {FieldName} = {FieldValue}",
fieldName,
pv.Value?.Value ?? "null");
}
}
}
if (context.ValidParentIds.Count > DebugMaxRelationsToLog)
{
logger.LogDebug("\t\t... и ещё {Count} родителей", context.ValidParentIds.Count - DebugMaxRelationsToLog);
}
}
else if (parentRelFilters.Any())
{
logger.LogDebug("\tРодительские фильтры заданы, но подходящих родителей не найдено");
}
if (context.ValidChildIds.Any())
{
logger.LogDebug("\tФильтрам соответствуют {ChildrenCount} детей:", context.ValidChildIds.Count);
foreach (var childId in context.ValidChildIds.Take(DebugMaxRelationsToLog))
{
var c = unitsMap.GetValueOrDefault(childId);
logger.LogDebug("\t\t{ChildName} (Id={ChildId})", c?.Name ?? "null", childId);
if (childFieldIds.Any() && valuesByUnit.TryGetValue(childId, out var childValues))
{
foreach (var cv in childValues.Where(v => childFieldIds.Contains(v.FieldId)))
{
var fieldName = fieldsMap.GetValueOrDefault(cv.FieldId) ?? $"FieldId={cv.FieldId}";
logger.LogDebug("\t\t {FieldName} = {FieldValue}",
fieldName,
cv.Value?.Value ?? "null");
}
}
}
if (context.ValidChildIds.Count > DebugMaxRelationsToLog)
{
logger.LogDebug("\t\t... и ещё {Count} детей", context.ValidChildIds.Count - DebugMaxRelationsToLog);
}
}
else if (childRelFilters.Any())
{
logger.LogDebug("\tДочерние фильтры заданы, но подходящих детей не найдено");
}
}
}
#endif
return result;
}
/// <summary>
/// Применяем фильтр по количеству связей для групп работ типа зонтик
/// </summary>
/// <param name="contexts"></param>
/// <param name="job"></param>
/// <returns></returns>
private List<UnitFilterMatchResult> ApplyUmbrellaFilterInMemory(List<UnitFilterMatchResult> 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<UnitFilterMatchResult>();
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 == debugTargetUnitId)
{
logger.LogDebug("DEBUG Umbrella: Юнит {TargetUnitId}, Count={Count}, Min={Min}, Max={Max}, Passes={Passes}",
debugTargetUnitId, count, min, max, passes);
}
#endif
if (passes)
{
result.Add(context);
}
}
return result;
}
/// <summary>
/// Формируем итоговый результат
/// </summary>
/// <param name="contexts"></param>
/// <returns></returns>
private async Task<List<UnitFilterResultDto>> LoadFinalResultAsync(List<UnitFilterMatchResult> contexts, CancellationToken cancellationToken = default)
{
var allResults = new List<UnitFilterResultDto>();
#if DEBUG
var targetInContexts = contexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
if (targetInContexts != null)
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} передан в LoadFinalResultAsync. Родителей: {ParentCount}, Детей: {ChildCount}",
debugTargetUnitId, targetInContexts.ValidParentIds.Count, targetInContexts.ValidChildIds.Count);
}
else
{
logger.LogDebug("DEBUG: Юнит {TargetUnitId} НЕ передан в LoadFinalResultAsync", debugTargetUnitId);
}
#endif
for (int i = 0; i < contexts.Count; i += batchSize)
{
var batch = contexts.Skip(i).Take(batchSize).ToList();
var batchResults = await LoadBatchAsync(batch, cancellationToken);
allResults.AddRange(batchResults);
}
return allResults;
}
/// <summary>
/// Используем пакетную загрузку, чтобы база данных не ругалась
/// </summary>
/// <param name="batch"></param>
/// <returns></returns>
private async Task<List<UnitFilterResultDto>> LoadBatchAsync(List<UnitFilterMatchResult> batch, CancellationToken cancellationToken = default)
{
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, cancellationToken);
// Загружаем родителей
Dictionary<Guid, Unit> 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, cancellationToken);
}
else
{
parentsMap = new Dictionary<Guid, Unit>();
}
// Загружаем детей
Dictionary<Guid, Unit> 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, cancellationToken);
}
else
{
childrenMap = new Dictionary<Guid, Unit>();
}
var result = new List<UnitFilterResultDto>();
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<List<string>> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId, CancellationToken cancellationToken = default)
{
logger.LogDebug("Начало GetRelatedUnitNamesAsync. JobId: {JobId}, UnitId: {UnitId}", jobId, unitId);
var job = await jobService
var job = await jobRepository
.Get().AsNoTracking()
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters)
.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
@@ -707,8 +257,8 @@ 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);
var parentLinks = await unitInUnitRepository.GetByChildIdAsync(unitId);
var childLinks = await unitInUnitRepository.GetByParentIdAsync(unitId);
// Собрать все UnitId, участвующие в связях
var allRelatedUnitIds = parentLinks
@@ -720,7 +270,7 @@ internal class UnitFilterService : IUnitFilterService
if (!allRelatedUnitIds.Any()) continue;
// Получить значения для всех связанных юнитов
var allUnitValues = await unitInValueService.GetByUnitIdsAsync(allRelatedUnitIds);
var allUnitValues = await unitInValueRepository.GetByUnitIdsAsync(allRelatedUnitIds);
// Сгруппировать значения по UnitId
var valuesByUnit = allUnitValues
@@ -759,272 +309,15 @@ internal class UnitFilterService : IUnitFilterService
if (matchingUnitIds.Any())
{
var names = await unitService.Get().AsNoTracking()
.Where(u => matchingUnitIds.Contains(u.Id))
// Используем кэширующий сервис вместо прямого запроса к БД
var cachedUnits = await unitService.GetWithCachingAsync(matchingUnitIds);
var names = cachedUnits.Values
.Select(u => u.Name)
.ToListAsync(cancellationToken);
result.UnionWith(names);
.Where(n => !string.IsNullOrEmpty(n));
result.UnionWith(names!);
}
}
return result.ToList();
}
#region вспомогательные методы
/// <summary>
/// Применяет фильтры к связям (родителям или детям)
/// </summary>
private async Task ApplyRelationshipFiltersToUnitsAsync(
List<Guid> unitIds,
Dictionary<Guid, UnitFilterMatchResult> preFilteredUnitsById,
List<JobRelationshipFilter> relFilters,
bool isParentDirection,
CancellationToken cancellationToken)
{
if (!relFilters.Any())
return;
var directionName = isParentDirection ? "Родительские" : "Дочерние";
logger.LogDebug(" {Direction} фильтры ({Count}):", directionName, relFilters.Count);
// 1. Получить все связи (unitInUnit) для юнитов из unitIds
var allLinks = await unitInUnitService.Get()
.AsNoTracking()
.Where(link => isParentDirection
? unitIds.Contains(link.ChildUnitId)
: unitIds.Contains(link.ParentUnitId))
.Select(link => new
{
SourceId = isParentDirection ? link.ChildUnitId : link.ParentUnitId,
TargetId = isParentDirection ? link.ParentUnitId : link.ChildUnitId
})
.ToListAsync(cancellationToken);
var allRelatedUnitIds = allLinks.Select(l => l.TargetId).Distinct().ToList();
logger.LogDebug(" Найдено {TargetCount} уникальных {TargetType} для {LinkCount} связей",
allRelatedUnitIds.Count,
isParentDirection ? "родителей" : "детей",
allLinks.Count);
// 2. Сгруппировать связи по SourceId (ID юнита из unitIds)
var relationshipsByUnitId = allLinks.GroupBy(l => l.SourceId)
.ToDictionary(g => g.Key, g => g.Select(l => l.TargetId).ToList());
// 3. Для каждого фильтра в группе найдём TargetId, которые ему соответствуют (с учётом IsInverse)
var matchedTargetIdsByFilter = new Dictionary<JobRelationshipFilter, HashSet<Guid>>();
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
continue;
string dbValueMask = NormalizeLikeMask(valueMask);
var fieldName = relFilter.UnitField?.AihitName ?? $"FieldId={relFilter.FieldId}";
logger.LogDebug(" RelationshipFilter ({Direction}): Поле='{FieldName}', Маска='{Mask}', IsInverse={IsInverse}, IsFullMatch={IsFullMatch}",
isParentDirection ? "Parent" : "Child",
fieldName, dbValueMask, relFilter.IsInverse, relFilter.IsFullMatch);
var matchingTargetIds = await unitInValueService.GetMatchingTargetIds(relFilter.FieldId, dbValueMask)
.ToListAsync(cancellationToken);
// Ограничиваем найденные ID только теми, которые действительно связаны с юнитами из unitIds
var filteredMatchingTargetIds = matchingTargetIds
.Intersect(allRelatedUnitIds)
.ToList();
logger.LogDebug(" Найдено {MatchCount} {TargetType} по маске",
filteredMatchingTargetIds.Count,
isParentDirection ? "родителей" : "детей");
HashSet<Guid> targetIdsThatPassThisFilter;
if (relFilter.IsInverse)
{
// Юнит проходит фильтр, если его значение НЕ соответствует маске
// Это означает, что юниты, НЕ входящие в filteredMatchingTargetIds, проходят фильтр
targetIdsThatPassThisFilter = new HashSet<Guid>(allRelatedUnitIds.Except(filteredMatchingTargetIds));
logger.LogDebug(" (IsInverse) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count);
}
else
{
// Юнит проходит фильтр, если его значение соответствует маске
targetIdsThatPassThisFilter = new HashSet<Guid>(filteredMatchingTargetIds);
logger.LogDebug(" (Direct) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count);
}
// Сохраняем результат для этого конкретного фильтра
matchedTargetIdsByFilter[relFilter] = targetIdsThatPassThisFilter;
}
// 4. Определим, какие юниты (context.UnitId) проходят ВСЕ фильтры с учётом IsFullMatch
var unitIdsToRemove = new HashSet<Guid>();
foreach (var context in preFilteredUnitsById.Values)
{
// Получаем связанные юниты для конкретного context.UnitId
var relatedUnitIds = relationshipsByUnitId.GetValueOrDefault(context.UnitId, new List<Guid>()).ToHashSet();
bool hasTargets = relatedUnitIds.Count > 0;
if (!hasTargets)
{
// Нет связей - проверяем, есть ли фильтры, которые требуют наличия связей
// Если есть хотя бы один фильтр с маской, и связей нет - юнит не проходит.
var hasFiltersWithMask = relFilters.Any(rf => !string.IsNullOrWhiteSpace(rf.ValueMask?.Trim()));
if (hasFiltersWithMask)
{
unitIdsToRemove.Add(context.UnitId);
continue; // Переходим к следующему юниту
}
// Если фильтров с маской нет, юнит остаётся.
}
bool unitPassesCurrentFilterGroup = true;
// Проверяем, проходит ли юнит все фильтры в группе
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
{
continue; // Пустой фильтр пропускаем
}
// Получаем юниты, прошедшие конкретный фильтр relFilter
var targetIdsThatPassThisFilter = matchedTargetIdsByFilter[relFilter];
if (relFilter.IsFullMatch)
{
// ВСЕ relatedUnitIds должны пройти этот конкретный фильтр relFilter
// Это означает, что каждый relatedUnitId должен быть в targetIdsThatPassThisFilter
bool allTargetsPassThisFilter = relatedUnitIds.All(targetId => targetIdsThatPassThisFilter.Contains(targetId));
if (!allTargetsPassThisFilter)
{
unitPassesCurrentFilterGroup = false;
break; // Не нужно проверять остальные фильтры, юнит заведомо не проходит
}
}
else // IsFullMatch = false
{
// Хоть один relatedUnitId должен пройти этот конкретный фильтр relFilter
// Это означает, что хотя бы один relatedUnitId должен быть в targetIdsThatPassThisFilter
bool anyTargetPassesThisFilter = relatedUnitIds.Any(targetId => targetIdsThatPassThisFilter.Contains(targetId));
if (!anyTargetPassesThisFilter)
{
unitPassesCurrentFilterGroup = false;
break; // Не нужно проверять остальные фильтры, юнит заведимо не проходит
}
}
}
if (!unitPassesCurrentFilterGroup)
{
unitIdsToRemove.Add(context.UnitId);
}
else
{
// Юнит прошёл все фильтры.
// Теперь определим, какие из его связей (relatedUnitIds) идут в ValidParentIds/ValidChildIds.
// Это должны быть связи, прошедшие *все* фильтры (т.е. быть в пересечении результатов для каждого фильтра).
// Соберём пересечение всех targetId, прошедших каждый фильтр для этого конкретного юнита.
// Начинаем с множества всех его связей.
var validRelatedIds = new HashSet<Guid>(relatedUnitIds);
foreach (var relFilter in relFilters)
{
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
continue;
var targetIdsThatPassThisFilter = matchedTargetIdsByFilter[relFilter];
// Пересекаем текущий список validRelatedIds с результатами для этого фильтра
validRelatedIds.IntersectWith(targetIdsThatPassThisFilter);
}
// Добавляем полученные валидные связи в контекст юнита
if (isParentDirection)
{
context.ValidParentIds.UnionWith(validRelatedIds);
}
else
{
context.ValidChildIds.UnionWith(validRelatedIds);
}
}
}
// Удаляем юниты, которые не прошли проверку
foreach (var unitId in unitIdsToRemove)
{
preFilteredUnitsById.Remove(unitId);
}
logger.LogDebug(" Удалено {RemovedCount} юнитов, не прошедших фильтры (новая логика с IsFullMatch/IsInverse для каждого фильтра)",
unitIdsToRemove.Count);
var totalAdded = preFilteredUnitsById.Values.Sum(c =>
isParentDirection ? c.ValidParentIds.Count : c.ValidChildIds.Count);
logger.LogDebug(" Добавлено {Total} {TargetType} связей для {UnitCount} юнитов (новая логика)",
totalAdded,
isParentDirection ? "родительских" : "дочерних",
preFilteredUnitsById.Count);
}
private async Task<List<Guid>> GetUnitIdsFromCacheOrDbAsync(JobUnitFilter filter, CancellationToken cancellationToken = default)
{
// var cacheKey = cacheService.GetKey(new[] { "uf_ids", filter.UnitFilter }, isUseHash: true);
var cacheKey = cacheService.GetKey(new[] { "unit filter", "unit name mask" }, new[] { filter.UnitFilter });
var cachedData = await cacheService.GetCachedDataAsync<UnitFilterIds>(cacheKey, true);
if (cachedData != null)
{
return cachedData.Data.UnitIds;
}
var dbValueMask = NormalizeLikeMask(filter.UnitFilter);
var initialUnitIds = await unitService.GetInitialUnitIds(dbValueMask).ToListAsync(cancellationToken);
var toCache = new UnitFilterIds
{
Data = new UnitFilterIdsDto { UnitIds = initialUnitIds },
Timestamp = DateTimeOffset.UtcNow,
Source = GetType().Name
};
await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1), true);
return initialUnitIds;
}
/// <summary>
/// Обрабатывает маску LIKE для корректной работы с SQL
/// </summary>
private static string NormalizeLikeMask(string valueMask)
{
if (string.IsNullOrWhiteSpace(valueMask))
return valueMask;
valueMask = valueMask.Trim();
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
if (isStartsWith && isEndsWith)
return $"%{valueMask.Trim('%')}%";
else if (isStartsWith)
return $"{valueMask.TrimEnd('%')}%";
else if (isEndsWith)
return $"%{valueMask.TrimStart('%')}";
else
return valueMask;
}
#endregion
}

View File

@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Domain.Cache;
@@ -121,7 +120,7 @@ namespace PARR.Core.Services.UnitService.Implementations
//await redisCacheService.SetHashFieldAsync(hashKey, unitKey, unit, CacheTtl, true);
dataToCache.Add((hashKey, unitKey, unit));
}
}
if (dataToCache.Count > 0)
await redisCacheService.SetHashFieldsAsync<UnitInfo>(dataToCache, CacheTtl, true);
@@ -174,6 +173,7 @@ namespace PARR.Core.Services.UnitService.Implementations
{
var unit = await unitRepository.Get()
.AsNoTracking()
.AsSplitQuery()
.Select(t => new
{
Id = t.Id,
@@ -193,6 +193,7 @@ namespace PARR.Core.Services.UnitService.Implementations
// Получаем базовую инфу о родственниках
var relativesDictionary = await unitRepository.Get()
.AsNoTracking()
.AsSplitQuery()
.Where(t => relatives.Contains(t.Id))
.Select(t => new UnitInfoBase
{
@@ -240,6 +241,7 @@ namespace PARR.Core.Services.UnitService.Implementations
{
var chunkUnits = await unitRepository.Get()
.AsNoTracking()
.AsSplitQuery()
.Where(t => chank.Contains(t.Id))
.Select(t => new
{
@@ -275,6 +277,7 @@ namespace PARR.Core.Services.UnitService.Implementations
{
var chunkRelatives = await unitRepository.Get()
.AsNoTracking()
.AsSplitQuery()
.Where(t => chunk.Contains(t.Id))
.Select(t => new UnitInfoBase
{

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Job;
@@ -56,6 +57,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator)
{
var totalSw = Stopwatch.StartNew();
logger.LogInformation("Начало синхронизации шаблонов для JobGroup {JobGroupId}", jobGroupId);
// === Проверка: уже запущена? ===
@@ -82,7 +84,8 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
try
{
// 1. Загрузка JobGroup и связанных Job'ов
// === ЭТАП 1: Загрузка JobGroup ===
var stageSw = Stopwatch.StartNew();
var jobGroup = await jobGroupService.Get()
.AsNoTracking()
.AsSingleQuery()
@@ -105,8 +108,11 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
}
var jobsInGroup = jobGroup.Jobs.ToList();
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Загрузка JobGroup | Время: {Ms} мс | Jobs: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, jobsInGroup.Count);
// 2. Поиск Job с максимальным MaxValueRelationships
// === Поиск эталонного Job ===
var maxJob = jobsInGroup
.Where(j => j.MaxValueRelationships.HasValue)
.OrderByDescending(j => j.MaxValueRelationships)
@@ -121,9 +127,13 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
logger.LogDebug("Используется Job {JobId} с максимальным MaxValueRelationships ({MaxValue}).", maxJob.Id, maxJob.MaxValueRelationships);
// 3. Получение отфильтрованных юнитов через UnitFilterService
logger.LogDebug("Получение отфильтрованных юнитов через UnitFilterService для Job {JobId}.", maxJob.Id);
// === ЭТАП 2: Фильтрация юнитов ===
stageSw.Restart();
var unitFilterResults = await unitFilterService.GetUnitsByJobFilterAsync(maxJob.Id);
stageSw.Stop();
var filterCount = unitFilterResults?.Count() ?? 0;
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Фильтрация юнитов | Время: {Ms} мс | Результат: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, filterCount);
if (unitFilterResults == null || !unitFilterResults.Any())
{
@@ -133,21 +143,26 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
return;
}
// 4. Применение специфичных правил фильтрации для групповых шаблонов
logger.LogDebug("Применение специфичных правил фильтрации для групповых шаблонов.");
// === ЭТАП 3: Групповая фильтрация ===
stageSw.Restart();
var finalFilteredUnits = await groupedTemplateUnitFilter.FilterAsync(unitFilterResults, jobGroup);
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Групповая фильтрация | Время: {Ms} мс | Результат: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, finalFilteredUnits.Count);
if (!finalFilteredUnits.Any())
{
logger.LogInformation("После применения правил фильтрации в JobGroup {JobGroupId} не осталось юнитов.", jobGroupId);
await UpdateMatchingStatusAsync(jobGroupId, "Нет юнитов после фильтрации");
await matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup);
return;
await matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); return;
}
// 5. Разрешение конфликтов связей и построение первичного маппинга
logger.LogDebug("Разрешение конфликтов связей и построение первичного маппинга.");
// === ЭТАП 4: Разрешение конфликтов ===
stageSw.Restart();
var initialReverseMapping = await unitInTemplateConflictMapper.BuildMappingAsync(finalFilteredUnits, maxJob);
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Разрешение конфликтов | Время: {Ms} мс | Связей: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, initialReverseMapping.Count);
if (!initialReverseMapping.Any())
{
@@ -156,9 +171,12 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
return;
}
// 6. Построение структуры групп (трансформация, внутренняя группировка, разбиение)
logger.LogDebug("Построение структуры групп для шаблонов.");
// === ЭТАП 5: Построение структуры групп ===
stageSw.Restart();
var templateGroups = await groupedTemplateBuilder.BuildAsync(initialReverseMapping, jobGroup, maxJob);
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Построение групп | Время: {Ms} мс | Групп: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, templateGroups.Count);
if (!templateGroups.Any())
{
@@ -167,25 +185,37 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
return;
}
// 7. Обработка групп: сравнение, обновление, создание, отправка MQ
logger.LogDebug("Обработка групп шаблонов: сравнение, обновление и создание.");
// === ЭТАП 6: Обработка групп (сравнение, обновление, MQ) ===
stageSw.Restart();
var expectedTemplateKeys = await groupedTemplateProcessor.ProcessAsync(
templateGroups,
jobsInGroup,
maxJob,
initiator);
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Обработка групп | Время: {Ms} мс | Ключей: {Count}",
jobGroupId, stageSw.ElapsedMilliseconds, expectedTemplateKeys.Count);
// 8. Деактивация лишних шаблонов
// === ЭТАП 7: Деактивация лишних шаблонов ===
stageSw.Restart();
await DeactivateUnusedTemplatesAsync(expectedTemplateKeys, jobGroupId, jobsInGroup, initiator);
stageSw.Stop();
logger.LogDebug("[Perf] JobGroup {JobGroupId} | Этап: Деактивация | Время: {Ms} мс",
jobGroupId, stageSw.ElapsedMilliseconds);
// === ИТОГО === totalSw.Stop();
logger.LogInformation(
"[Perf] JobGroup {JobGroupId} | ИТОГО: {TotalMs} мс",
jobGroupId, totalSw.ElapsedMilliseconds);
// 9. Успешное завершение
await UpdateMatchingStatusAsync(jobGroupId, "Синхронизация завершена успешно");
await matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup);
logger.LogInformation("Синхронизация шаблонов завершена для JobGroup {JobGroupId}.", jobGroupId);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при синхронизации JobGroup {JobGroupId}", jobGroupId);
totalSw.Stop();
logger.LogError(ex, "Ошибка при синхронизации JobGroup {JobGroupId} через {ElapsedMs} мс", jobGroupId, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(jobGroupId, $"Ошибка: {ex.Message}");
throw;
}