refactor(unitFilterService): Этапы фильтрации вынесены в отдельные классы
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace PARR.Core.Services.UnitFilterService.Models
|
||||
{
|
||||
internal class UnitFilterServiceOptions
|
||||
{
|
||||
public int LoadBatchSize { get; set; } = 1000;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user