fix(dal): UnitFilterServicen исправлена инверсивная фильтрация связанных ЭК

This commit is contained in:
Mikhail Kuznetsov
2026-03-16 16:21:16 +10:00
parent 74d7affd8e
commit 8f4ad21a3c
3 changed files with 139 additions and 129 deletions

View File

@@ -2,6 +2,6 @@
{
internal class UnitFilterServiceOptions
{
public int LoadBatchSize { get; set; } = 100;
public int LoadBatchSize { get; set; } = 50;
}
}

View File

@@ -7,6 +7,7 @@ using PARR.DAL.Contracts;
using PARR.DAL.DomainServices.UnitFilterService.Models;
using PARR.DAL.Models.Job;
using PARR.DAL.Models.Unit;
using PARR.DAL.Services.Implementations.Unit;
using PARR.DAL.Services.Interfaces.Job;
using PARR.DAL.Services.Interfaces.Unit;
using System.Diagnostics;
@@ -26,6 +27,7 @@ internal class UnitFilterService : IUnitFilterService
private readonly IUnitService unitService;
private readonly IUnitInUnitService unitInUnitService;
private readonly IRedisCacheService cacheService;
private readonly IUnitFieldService unitFieldService;
private readonly IUnitInValueService unitInValueService;
public UnitFilterService(
@@ -35,7 +37,8 @@ internal class UnitFilterService : IUnitFilterService
IUnitInUnitService unitInUnitService,
IUnitInValueService unitInValueService,
IRedisCacheService cacheService,
IOptions<UnitFilterServiceOptions> options
IOptions<UnitFilterServiceOptions> options,
IUnitFieldService unitFieldService
)
{
this.logger = logger;
@@ -43,6 +46,7 @@ internal class UnitFilterService : IUnitFilterService
this.unitService = unitService;
this.unitInUnitService = unitInUnitService;
this.cacheService = cacheService;
this.unitFieldService = unitFieldService;
this.unitInValueService = unitInValueService;
batchSize = options.Value.LoadBatchSize;
@@ -138,7 +142,7 @@ internal class UnitFilterService : IUnitFilterService
// 3. Применить RelationshipFilters на уровне SQL -> ВОЗВРАЩАЕТ FilteredUnitContext
var relStopwatch = Stopwatch.StartNew();
var relationshipFilteredContexts = await ApplyRelationshipFiltersOnDbAsync(fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
var relationshipFilteredContexts = await ProcessRelationshipFiltersAsync(fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
relStopwatch.Stop();
if (!relationshipFilteredContexts.Any())
@@ -248,7 +252,7 @@ internal class UnitFilterService : IUnitFilterService
{
return await jobService
.Get().AsNoTracking()
.AsSplitQuery()
.AsSplitQuery()
.Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
@@ -306,22 +310,14 @@ internal class UnitFilterService : IUnitFilterService
{
query = query.Where(u => !u.UnitValues.Any(v =>
v.FieldId == fieldFilter.FieldId &&
EF.Functions.Like(v.Value.Value, dbValueMask)));
EF.Functions.ILike(v.Value.Value, dbValueMask)));
}
else
{
query = query.Where(u => u.UnitValues.Any(v =>
v.FieldId == fieldFilter.FieldId &&
EF.Functions.Like(v.Value.Value, dbValueMask)));
EF.Functions.ILike(v.Value.Value, dbValueMask)));
}
// Выполняем промежуточный запрос для логирования
//var intermediateResult = await query.Select(u => u.Id).ToListAsync(cancellationToken);
//logger.LogDebug(" После фильтра #{Index}: осталось {Count} юнитов", filterIndex, intermediateResult.Count);
//// Обновляем query для следующей итерации
//query = unitService.Get().AsNoTracking()
// .Where(u => intermediateResult.Contains(u.Id));
}
var result = await query.Select(u => u.Id).ToListAsync(cancellationToken);
@@ -337,7 +333,7 @@ internal class UnitFilterService : IUnitFilterService
/// <param name="unitIds"></param>
/// <param name="relationshipFilters"></param>
/// <returns></returns>
private async Task<List<FilteredUnitContext>> ApplyRelationshipFiltersOnDbAsync(
private async Task<List<FilteredUnitContext>> ProcessRelationshipFiltersAsync(
List<Guid> unitIds,
IEnumerable<JobRelationshipFilter> relationshipFilters,
CancellationToken cancellationToken = default)
@@ -360,9 +356,9 @@ internal class UnitFilterService : IUnitFilterService
// Обработка родительских фильтров
if (parentRelFilters.Any())
{
await ApplyRelationshipFiltersAsync(
await ApplyRelationshipFiltersOnDbAsync(
unitIds,
resultContexts, // ← Передаём словарь, а не список
resultContexts,
parentRelFilters,
isParentDirection: true,
cancellationToken);
@@ -371,9 +367,9 @@ internal class UnitFilterService : IUnitFilterService
// Обработка дочерних фильтров
if (childRelFilters.Any())
{
await ApplyRelationshipFiltersAsync(
await ApplyRelationshipFiltersOnDbAsync(
unitIds,
resultContexts, // ← Передаём словарь, а не список
resultContexts,
childRelFilters,
isParentDirection: false,
cancellationToken);
@@ -392,85 +388,100 @@ internal class UnitFilterService : IUnitFilterService
result.Count, resultContexts.Count);
#if DEBUG
// Собираем все UnitId для пакетной загрузки (основные + родители + дети)
var allUnitIds = result.Select(c => c.UnitId)
.Concat(result.SelectMany(c => c.ValidParentIds))
.Concat(result.SelectMany(c => c.ValidChildIds))
.Distinct()
.Take(100)
.ToList();
// Пакетная загрузка всех юнитов
var unitsMap = await unitService.Get().AsNoTracking()
.Where(u => allUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken);
// Получаем все FieldId из фильтров для отображения значений
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();
// ПАКЕТНАЯ загрузка значений для ВСЕХ юнитов сразу (1 запрос вместо N+1)
var allValues = await unitInValueService.GetByUnitIdsAndFieldIdsAsync(allUnitIds, allFieldIds);
var valuesByUnit = allValues.GroupBy(v => v.UnitId)
.ToDictionary(g => g.Key, g => g.ToList());
// ПАКЕТНАЯ загрузка имён полей (1 запрос вместо N+1)
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(10))
{
var u = await unitService.GetAsync(context.UnitId);
var u = unitsMap.GetValueOrDefault(context.UnitId);
if (u == null)
continue;
logger.LogDebug("📦 Найден ЭК {UnitName} (Id={UnitId})", u.Name, u.Id);
// Получаем все FieldId из фильтров для отображения значений
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();
logger.LogDebug("Найден ЭК {UnitName} (Id={UnitId})", u.Name, u.Id);
// === РОДИТЕЛИ ===
if (context.ValidParentIds.Any())
{
logger.LogDebug("\t📌 Фильтрам соответствуют {ParentsCount} родителей:", context.ValidParentIds.Count);
logger.LogDebug("\tФильтрам соответствуют {ParentsCount} родителей:", context.ValidParentIds.Count);
foreach (var parentId in context.ValidParentIds)
{
var p = await unitService.GetAsync(parentId);
logger.LogDebug("\t\t👤 {ParentName} (Id={ParentId})", p?.Name, parentId);
var p = unitsMap.GetValueOrDefault(parentId);
logger.LogDebug("\t\t{ParentName} (Id={ParentId})", p?.Name, parentId);
// Выводим значения только для полей из RelationshipFilters
if (parentFieldIds.Any())
// Берём значения из кэша, а не делаем запрос
if (parentFieldIds.Any() && valuesByUnit.TryGetValue(parentId, out var parentValues))
{
var parentValues = await unitInValueService.GetByUnitIdsAndFieldIdsAsync(
new[] { parentId },
parentFieldIds
);
foreach (var pv in parentValues)
foreach (var pv in parentValues.Where(v => parentFieldIds.Contains(v.FieldId)))
{
var field = pv.Field;
var value = pv.Value?.Value;
logger.LogDebug("\t\t 🔹 {FieldName} = {FieldValue}",
field?.AihitName ?? $"FieldId={pv.FieldId}",
value ?? "null");
// Используем предварительно загруженный словарь полей
var fieldName = fieldsMap.GetValueOrDefault(pv.FieldId) ?? $"FieldId={pv.FieldId}";
logger.LogDebug("\t\t {FieldName} = {FieldValue}",
fieldName,
pv.Value?.Value ?? "null");
}
}
}
}
else if (parentRelFilters.Any())
{
logger.LogDebug("\t Родительские фильтры заданы, но подходящих родителей не найдено");
logger.LogDebug("\tРодительские фильтры заданы, но подходящих родителей не найдено");
}
// === ДЕТИ ===
if (context.ValidChildIds.Any())
{
logger.LogDebug("\t Фильтрам соответствуют {ChildrenCount} детей:", context.ValidChildIds.Count);
logger.LogDebug("\tФильтрам соответствуют {ChildrenCount} детей:", context.ValidChildIds.Count);
foreach (var childId in context.ValidChildIds)
{
var c = await unitService.GetAsync(childId);
logger.LogDebug("\t\t {ChildName} (Id={ChildId})", c?.Name, childId);
var c = unitsMap.GetValueOrDefault(childId);
logger.LogDebug("\t\t{ChildName} (Id={ChildId})", c?.Name, childId);
// Выводим значения только для полей из RelationshipFilters
if (childFieldIds.Any())
// Берём значения из кэша, а не делаем запрос
if (childFieldIds.Any() && valuesByUnit.TryGetValue(childId, out var childValues))
{
var childValues = await unitInValueService.GetByUnitIdsAndFieldIdsAsync(
new[] { childId },
childFieldIds
);
foreach (var cv in childValues)
foreach (var cv in childValues.Where(v => childFieldIds.Contains(v.FieldId)))
{
var field = cv.Field;
var value = cv.Value?.Value;
logger.LogDebug("\t\t {FieldName} = {FieldValue}",
field?.AihitName ?? $"FieldId={cv.FieldId}",
value ?? "null");
// Используем предварительно загруженный словарь полей
var fieldName = fieldsMap.GetValueOrDefault(cv.FieldId) ?? $"FieldId={cv.FieldId}";
logger.LogDebug("\t\t {FieldName} = {FieldValue}",
fieldName,
cv.Value?.Value ?? "null");
}
}
}
}
else if (childRelFilters.Any())
{
logger.LogDebug("\t Дочерние фильтры заданы, но подходящих детей не найдено");
logger.LogDebug("\tДочерние фильтры заданы, но подходящих детей не найдено");
}
}
#endif
@@ -733,57 +744,11 @@ internal class UnitFilterService : IUnitFilterService
}
#region вспомогательные методы
/// <summary>
/// Оценивает, проходит ли юнит фильтр связей
/// </summary>
private bool EvaluateFilter(
List<Guid> unitRelations, // Все связи юнита
List<Guid> matchingRelations, // Связи, которые соответствуют маске
bool isFullMatch, // Полное совпадение (All) или хотя бы одно (Any)
bool isInverse, // Обратный фильтр
bool hasRelations // Есть ли связи у юнита
)
{
int matchingCount = unitRelations.Count(r => matchingRelations.Contains(r));
int totalCount = unitRelations.Count;
// Случай: нет связей в направлении
if (!hasRelations)
{
// Если IsInverse = true → юнит проходит (нет связей, которые нарушают)
// Если IsInverse = false → юнит не проходит (нет связей, которые соответствуют)
return isInverse;
}
// Основная логика — только для определения, проходит ли юнит
if (isFullMatch)
{
// ВСЕ связи должны соответствовать
bool allMatch = matchingCount == totalCount;
if (isInverse)
return !allMatch; // НЕ все связи соответствуют
else
return allMatch; // ВСЕ связи соответствуют
}
else
{
// ХОТЯ БЫ ОДНА связь должна соответствовать
bool anyMatch = matchingCount > 0;
if (isInverse)
return !anyMatch; // НИ ОДНА связь не соответствует
else
return anyMatch; // ХОТЯ БЫ ОДНА связь соответствует
}
}
/// <summary>
/// Применяет фильтры к связям (родителям или детям)
/// Связь должна пройти ВСЕ фильтры направления
/// </summary>
private async Task ApplyRelationshipFiltersAsync(
private async Task ApplyRelationshipFiltersOnDbAsync(
List<Guid> unitIds,
Dictionary<Guid, FilteredUnitContext> resultContexts,
List<JobRelationshipFilter> relFilters,
@@ -820,7 +785,6 @@ internal class UnitFilterService : IUnitFilterService
.ToDictionary(g => g.Key, g => g.Select(l => l.TargetId).ToList());
// Находим связи, которые проходят ВСЕ фильтры одновременно
// Для каждого TargetId считаем, сколько фильтров он прошёл
var targetFilterPassCount = new Dictionary<Guid, int>();
foreach (var relFilter in relFilters)
@@ -847,13 +811,13 @@ internal class UnitFilterService : IUnitFilterService
isParentDirection ? "Parent" : "Child",
fieldName, dbValueMask, relFilter.IsInverse, relFilter.IsFullMatch);
// Находим целевые юниты, которые соответствуют текущему фильтру
// Находим целевые юниты, которые соответствуют ТЕКУЩЕМУ фильтру
var matchingTargetIds = await unitService.Get()
.AsNoTracking()
.Where(u => allTargetIds.Contains(u.Id))
.Where(u => u.UnitValues.Any(v =>
v.FieldId == relFilter.FieldId &&
EF.Functions.Like(v.Value.Value, dbValueMask)))
EF.Functions.ILike(v.Value.Value, dbValueMask)))
.Select(u => u.Id)
.ToListAsync(cancellationToken);
@@ -862,15 +826,36 @@ internal class UnitFilterService : IUnitFilterService
isParentDirection ? "родителей" : "детей");
// Считаем, сколько фильтров прошёл каждый TargetId
foreach (var targetId in matchingTargetIds)
if (relFilter.IsInverse)
{
if (!targetFilterPassCount.ContainsKey(targetId))
targetFilterPassCount[targetId] = 0;
targetFilterPassCount[targetId]++;
// Для IsInverse: "проходит" фильтр тот, кто НЕ соответствует маске
foreach (var targetId in allTargetIds)
{
if (!matchingTargetIds.Contains(targetId))
{
if (!targetFilterPassCount.ContainsKey(targetId))
targetFilterPassCount[targetId] = 0;
targetFilterPassCount[targetId]++;
}
}
logger.LogDebug(" Для IsInverse: {NonMatchCount} {TargetType} НЕ соответствуют маске",
allTargetIds.Count - matchingTargetIds.Count,
isParentDirection ? "родителей" : "детей");
}
else
{
// Для обычного фильтра: "проходит" тот, кто соответствует маске
foreach (var targetId in matchingTargetIds)
{
if (!targetFilterPassCount.ContainsKey(targetId))
targetFilterPassCount[targetId] = 0;
targetFilterPassCount[targetId]++;
}
}
}
// Оставляем только те связи, которые прошли ВСЕ фильтры
// Оставляем только те связи, которые прошли ВСЕ фильтры
int requiredFiltersCount = relFilters.Count(r => !string.IsNullOrWhiteSpace(r.ValueMask?.Trim()));
var validTargetIds = targetFilterPassCount
.Where(kvp => kvp.Value == requiredFiltersCount)
@@ -898,22 +883,47 @@ internal class UnitFilterService : IUnitFilterService
var validTargets = unitTargets.Intersect(validTargetIds).ToList();
bool hasValidTargets = validTargets.Any();
// Оцениваем, проходит ли юнит фильтр
bool passesFilter = EvaluateFilter(
validTargets, // Только связи, прошедшие ВСЕ фильтры
validTargets, // matchingRelations = validTargets (все валидные)
relFilters.All(rf => rf.IsFullMatch), // IsFullMatch для всех фильтров
relFilters.Any(rf => rf.IsInverse), // IsInverse для всех фильтров
hasTargets
);
// Логика с IsFullMatch
bool isFullMatch = relFilters.All(rf => rf.IsFullMatch);
bool isInverse = relFilters.Any(rf => rf.IsInverse);
if (passesFilter && hasValidTargets)
bool passesFilter;
if (!hasTargets)
{
// Сохраняем только валидные связи
if (isParentDirection)
context.ValidParentIds.UnionWith(validTargets);
// Нет связей вообще
passesFilter = isInverse; // IsInverse=true → проходит, иначе нет
}
else if (isFullMatch)
{
// ВСЕ связи должны быть валидными
passesFilter = validTargets.Count == unitTargets.Count;
}
else
{
// ХОТЯ БЫ ОДНА связь должна быть валидной
passesFilter = hasValidTargets;
}
if (passesFilter)
{
// Сохраняем связи
if (isFullMatch)
{
// Для IsFullMatch=true: сохраняем ВСЕ связи (они все валидные)
if (isParentDirection)
context.ValidParentIds.UnionWith(unitTargets);
else
context.ValidChildIds.UnionWith(unitTargets);
}
else
context.ValidChildIds.UnionWith(validTargets);
{
// Для IsFullMatch=false: сохраняем ТОЛЬКО валидные связи
if (isParentDirection)
context.ValidParentIds.UnionWith(validTargets);
else
context.ValidChildIds.UnionWith(validTargets);
}
}
else
{
@@ -951,7 +961,7 @@ internal class UnitFilterService : IUnitFilterService
}
var initialUnitIds = await unitService.Get().AsNoTracking()
.Where(unit => EF.Functions.Like(unit.Name, filter.UnitFilter))
.Where(unit => EF.Functions.ILike(unit.Name, filter.UnitFilter))
.Select(u => u.Id)
.ToListAsync(cancellationToken);

View File

@@ -151,7 +151,7 @@ namespace PARR.DAL
services.AddTransient<IUnitFilterService, UnitFilterService>();
services.AddTransient<IMatchingStatusService, MatchingStatusService>();
services.AddSingleton(new UnitFilterServiceOptions { LoadBatchSize = 1000 });
services.AddSingleton(new UnitFilterServiceOptions { LoadBatchSize = 50 });
#endregion