diff --git a/PARR.Core/DependencyInjection.cs b/PARR.Core/DependencyInjection.cs index a1ffe502..08524797 100644 --- a/PARR.Core/DependencyInjection.cs +++ b/PARR.Core/DependencyInjection.cs @@ -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(); - services.Configure(options => - { - options.LoadBatchSize = 50; - }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); #endregion diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UmbrellaFilter.cs b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UmbrellaFilter.cs new file mode 100644 index 00000000..1a7015cf --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UmbrellaFilter.cs @@ -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; + +/// +/// Применяет Umbrella-фильтр по количеству связей. +/// +internal class UmbrellaFilter : IUmbrellaFilter +{ + private readonly ILogger logger; + + public UmbrellaFilter(ILogger logger) + { + this.logger = logger; + } + + public List Apply( + List 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(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; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFieldMatcher.cs b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFieldMatcher.cs new file mode 100644 index 00000000..363a33bc --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFieldMatcher.cs @@ -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; + +/// +/// Сопоставляет юниты с фильтрами по атрибутам. +/// +internal class UnitFieldMatcher : IUnitFieldMatcher +{ + private const int chunkSize = 1000; + private readonly IUnitRepository unitRepository; + private readonly ILogger logger; + + public UnitFieldMatcher( + IUnitRepository unitRepository, + ILogger logger) + { + this.unitRepository = unitRepository; + this.logger = logger; + } + + public async Task> MatchAsync( + IReadOnlyList unitIds, + IEnumerable fieldFilters, + CancellationToken ct = default) + { + var filters = fieldFilters?.ToList(); + if (filters == null || filters.Count == 0) + return new List(unitIds); + + logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}", + unitIds.Count, filters.Count); + + var result = new List(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; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFilterResultLoader.cs b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFilterResultLoader.cs new file mode 100644 index 00000000..89f06c5a --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitFilterResultLoader.cs @@ -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; + +/// +/// Загружает полные данные юнитов через IUnitService и формирует UnitFilterResultDto. +/// +internal class UnitFilterResultLoader : IUnitFilterResultLoader +{ + private readonly IUnitService unitService; + private readonly ILogger logger; + + public UnitFilterResultLoader( + IUnitService unitService, + ILogger logger) + { + this.unitService = unitService; + this.logger = logger; + } + + public async Task> LoadAsync( + List contexts, + CancellationToken ct = default) + { + if (contexts.Count == 0) + return new List(); + + // Собираем ВСЕ уникальные ID сразу + var allRequiredUnitIds = new HashSet(); + 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(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; + } + + /// + /// Маппинг UnitInfo из кэша в RelatedUnitDto для родителей/детей + /// + 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() + }; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitNameResolver.cs b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitNameResolver.cs new file mode 100644 index 00000000..04cd0a48 --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitNameResolver.cs @@ -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; + +/// +/// Находит ID юнитов по маске имени через Redis-кэш с fallback на БД. +/// +internal class UnitNameResolver : IUnitNameResolver +{ + private readonly IRedisCacheService cacheService; + private readonly IUnitRepository unitRepository; + private readonly ILogger logger; + + public UnitNameResolver( + IRedisCacheService cacheService, + IUnitRepository unitRepository, + ILogger logger) + { + this.cacheService = cacheService; + this.unitRepository = unitRepository; + this.logger = logger; + } + + public async Task> ResolveAsync(string unitFilterMask, CancellationToken ct = default) + { + var cacheKey = cacheService.GetKey(new[] { "unit filter", "unit name mask" }, new[] { unitFilterMask }); + + var cachedData = await cacheService.GetCachedDataAsync(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; + } + + /// + /// Обрабатывает маску LIKE для корректной работы с SQL + /// + 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; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitRelationshipMatcher.cs b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitRelationshipMatcher.cs new file mode 100644 index 00000000..d454ac6f --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Implemetations/UnitRelationshipMatcher.cs @@ -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; + +/// +/// Сопоставляет юниты с фильтрами по связям. +/// +internal class UnitRelationshipMatcher : IUnitRelationshipMatcher +{ + private readonly IUnitInUnitRepository unitInUnitRepository; + private readonly IUnitInValueRepository unitInValueRepository; + private readonly ILogger logger; + + public UnitRelationshipMatcher( + IUnitInUnitRepository unitInUnitRepository, + IUnitInValueRepository unitInValueRepository, + ILogger logger) + { + this.unitInUnitRepository = unitInUnitRepository; + this.unitInValueRepository = unitInValueRepository; + this.logger = logger; + } + + public async Task> MatchAsync( + IReadOnlyList unitIds, + IEnumerable 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; + } + + /// + /// Применяет фильтры к связям (родителям или детям) + /// + private async Task ApplyFiltersToUnitsAsync( + IReadOnlyList unitIds, + Dictionary preFilteredUnitsById, + List 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>(); + + 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 targetIdsThatPassThisFilter; + + if (relFilter.IsInverse) + { + targetIdsThatPassThisFilter = new HashSet(allRelatedUnitIds.Except(filteredMatchingTargetIds)); + logger.LogDebug(" (IsInverse) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count); + } + else + { + targetIdsThatPassThisFilter = new HashSet(filteredMatchingTargetIds); + logger.LogDebug(" (Direct) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count); + } + + matchedTargetIdsByFilter[relFilter] = targetIdsThatPassThisFilter; + } + + // 4. Определить, какие юниты проходят ВСЕ фильтры с учётом IsFullMatch + var unitIdsToRemove = new HashSet(); + + foreach (var context in preFilteredUnitsById.Values) + { + var relatedUnitIds = relationshipsByUnitId.GetValueOrDefault(context.UnitId, new List()).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(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); + } + + /// + /// Обрабатывает маску LIKE для корректной работы с SQL + /// + 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; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUmbrellaFilter.cs b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUmbrellaFilter.cs new file mode 100644 index 00000000..00f16657 --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUmbrellaFilter.cs @@ -0,0 +1,21 @@ +using PARR.Core.Services.UnitFilterService.Models; +using PARR.Domain.Entities.Job; + +namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces +{ + /// + /// Применяет Umbrella-фильтр по количеству связей в памяти. + /// + internal interface IUmbrellaFilter + { + /// + /// Фильтрует контексты по min/max количеству родительских или дочерних связей. + /// + /// Входящие контексты юнитов. + /// Job с параметрами Umbrella-фильтра. + /// Отфильтрованный список контекстов. + List Apply( + List contexts, + Job job); + } +} diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFieldMatcher.cs b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFieldMatcher.cs new file mode 100644 index 00000000..7825530b --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFieldMatcher.cs @@ -0,0 +1,18 @@ +using PARR.Domain.Entities.Job; + +namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces +{ + /// + /// Сопоставляет юниты с фильтрами по полям. + /// + public interface IUnitFieldMatcher + { + /// + /// Возвращает ID юнитов, прошедших все указанные фильтры. + /// + Task> MatchAsync( + IReadOnlyList unitIds, + IEnumerable fieldFilters, + CancellationToken ct = default); + } +} diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFilterResultLoader.cs b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFilterResultLoader.cs new file mode 100644 index 00000000..b1326fea --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitFilterResultLoader.cs @@ -0,0 +1,20 @@ +using PARR.Core.Services.UnitFilterService.Models; + +namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces +{ + /// + /// Загружает полные данные юнитов для формирования итогового результата фильтрации. + /// + internal interface IUnitFilterResultLoader + { + /// + /// Загружает юниты, их родителей и детей из кэша и формирует DTO. + /// + /// Контексты юнитов с валидными связями. + /// Токен отмены. + /// Список DTO с полными данными. + Task> LoadAsync( + List contexts, + CancellationToken ct = default); + } +} diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitNameResolver.cs b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitNameResolver.cs new file mode 100644 index 00000000..9dc4555e --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitNameResolver.cs @@ -0,0 +1,16 @@ +namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces +{ + /// + /// Находит ID юнитов по маске имени. + /// + public interface IUnitNameResolver + { + /// + /// Возвращает список ID юнитов, чьи имена соответствуют маске. + /// + /// Маска имени (поддерживает %). + /// Токен отмены. + /// Список ID подходящих юнитов. + Task> ResolveAsync(string unitFilterMask, CancellationToken ct = default); + } +} diff --git a/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitRelationshipMatcher.cs b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitRelationshipMatcher.cs new file mode 100644 index 00000000..3a0a78ca --- /dev/null +++ b/PARR.Core/Services/UnitFilterService/Matchers/Interfaces/IUnitRelationshipMatcher.cs @@ -0,0 +1,20 @@ +using PARR.Core.Services.UnitFilterService.Models; +using PARR.Domain.Entities.Job; + +namespace PARR.Core.Services.UnitFilterService.Matchers.Interfaces +{ + /// + /// Сопоставляет юниты с фильтрами по связям (родители/дети). + /// + internal interface IUnitRelationshipMatcher + { + /// + /// Применяет фильтры по связям к набору юнитов. + /// Возвращает контексты с валидными родительскими и дочерними связями. + /// + Task> MatchAsync( + IReadOnlyList unitIds, + IEnumerable relationshipFilters, + CancellationToken ct = default); + } +} diff --git a/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs b/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs deleted file mode 100644 index 217dba05..00000000 --- a/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace PARR.Core.Services.UnitFilterService.Models -{ - internal class UnitFilterServiceOptions - { - public int LoadBatchSize { get; set; } = 1000; - } -} diff --git a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs index 04a4d0af..ab0faecf 100644 --- a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs +++ b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs @@ -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 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 logger, - IJobRepository jobService, - IUnitRepository unitService, - IUnitInUnitRepository unitInUnitService, - IUnitInValueRepository unitInValueService, + IJobRepository jobRepository, + IUnitRepository unitRepository, + IUnitInUnitRepository unitInUnitRepository, + IUnitInValueRepository unitInValueRepository, IRedisCacheService cacheService, - IOptions 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(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 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 } - /// - /// Применяем фильтры аттрибутов - /// - private async Task> ApplyFieldFiltersOnDbAsync( - List unitIds, - IEnumerable 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; - } - - - /// - /// Применяем фильтры аттрибутов у связанных ЭК - /// - private async Task> ProcessRelationshipFiltersAsync( - List unitIds, - IEnumerable 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; - } - - - /// - /// Применяем фильтр по количеству связей для групп работ типа зонтик - /// - /// - /// - /// - private List ApplyUmbrellaFilterInMemory(List contexts, Job job) - { - var min = job.MinValueRelationships ?? 0; - var max = job.MaxValueRelationships ?? int.MaxValue; - var isParentDirection = job.IsParentRelationships == true; - - if (min == 0 && max == int.MaxValue) - return contexts; - - var result = new List(); - foreach (var context in contexts) - { - int count = isParentDirection ? context.ValidParentIds.Count : context.ValidChildIds.Count; - bool passes = count >= min && count <= max; - -#if DEBUG - if (context.UnitId == 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; - } - - - /// - /// Формируем итоговый результат - /// - /// - /// - private async Task> LoadFinalResultAsync(List contexts, CancellationToken cancellationToken = default) - { - var allResults = new List(); - -#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; - } - - - /// - /// Используем пакетную загрузку, чтобы база данных не ругалась - /// - /// - /// - private async Task> LoadBatchAsync(List 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 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(); - } - - // Загружаем детей - Dictionary childrenMap; - if (allChildIds.Any()) - { - childrenMap = await unitService.Get().AsNoTracking() - .AsSingleQuery() - .Include(u => u.UnitValues).ThenInclude(v => v.Value) - .Where(u => allChildIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, cancellationToken); - } - else - { - childrenMap = new Dictionary(); - } - - var result = new List(); - foreach (var context in batch) - { - var unit = unitsMap[context.UnitId]; - var dto = new UnitFilterResultDto - { - Id = unit.Id, - Name = unit.Name, - Values = unit.UnitValues.Select(v => new UnitValueDto - { - FieldId = v.FieldId, - Value = v.Value?.Value - }).ToList(), - Parents = context.ValidParentIds - .Where(id => parentsMap.ContainsKey(id)) - .Select(id => new RelatedUnitDto - { - UnitId = id, - Name = parentsMap[id].Name, - Values = parentsMap[id].UnitValues.Select(v => new UnitValueDto - { - FieldId = v.FieldId, - Value = v.Value?.Value - }).ToList() - }).ToList(), - Children = context.ValidChildIds - .Where(id => childrenMap.ContainsKey(id)) - .Select(id => new RelatedUnitDto - { - UnitId = id, - Name = childrenMap[id].Name, - Values = childrenMap[id].UnitValues.Select(v => new UnitValueDto - { - FieldId = v.FieldId, - Value = v.Value?.Value - }).ToList() - }).ToList() - }; - result.Add(dto); - } - - return result; - } - - public async Task> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId, 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 вспомогательные методы - - - /// - /// Применяет фильтры к связям (родителям или детям) - /// - private async Task ApplyRelationshipFiltersToUnitsAsync( - List unitIds, - Dictionary preFilteredUnitsById, - List 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>(); - - 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 targetIdsThatPassThisFilter; - - if (relFilter.IsInverse) - { - // Юнит проходит фильтр, если его значение НЕ соответствует маске - // Это означает, что юниты, НЕ входящие в filteredMatchingTargetIds, проходят фильтр - targetIdsThatPassThisFilter = new HashSet(allRelatedUnitIds.Except(filteredMatchingTargetIds)); - logger.LogDebug(" (IsInverse) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count); - } - else - { - // Юнит проходит фильтр, если его значение соответствует маске - targetIdsThatPassThisFilter = new HashSet(filteredMatchingTargetIds); - logger.LogDebug(" (Direct) Юниты, прошедшие фильтр: {Count}", targetIdsThatPassThisFilter.Count); - } - - // Сохраняем результат для этого конкретного фильтра - matchedTargetIdsByFilter[relFilter] = targetIdsThatPassThisFilter; - } - - // 4. Определим, какие юниты (context.UnitId) проходят ВСЕ фильтры с учётом IsFullMatch - var unitIdsToRemove = new HashSet(); - - foreach (var context in preFilteredUnitsById.Values) - { - // Получаем связанные юниты для конкретного context.UnitId - var relatedUnitIds = relationshipsByUnitId.GetValueOrDefault(context.UnitId, new List()).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(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> 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(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; - } - - - /// - /// Обрабатывает маску LIKE для корректной работы с SQL - /// - 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 } \ No newline at end of file diff --git a/PARR.Core/Services/UnitService/Implementations/UnitCacheService.cs b/PARR.Core/Services/UnitService/Implementations/UnitCacheService.cs index 7c7acd06..410d29c6 100644 --- a/PARR.Core/Services/UnitService/Implementations/UnitCacheService.cs +++ b/PARR.Core/Services/UnitService/Implementations/UnitCacheService.cs @@ -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(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 { diff --git a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs index 1d251f42..4bd7bc48 100644 --- a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs @@ -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; }