feat(dal,templateMatcher): UnitFilterService переписан, теперь при подсчете количества связей для групп работ зонтик учитываются relationshipFilter. В TemplateMatcher добавлено изменение имени Unused шаблонов в конце имени добавляется DateTimeOffset.UtcNow.ToUnixTimeSeconds() для уникальности неиспользуемых шаблонов. Актуализированы unit-тесты

This commit is contained in:
Mikhail Kuznetsov
2025-12-15 11:50:12 +10:00
parent 03683a13d3
commit 8da4f226a7
9 changed files with 429 additions and 223 deletions

View File

@@ -31,13 +31,45 @@ namespace PARR.DAL.DomainServices.Implementations
this.unitInValueService = unitInValueService;
}
#region вложенные классы
private class UnitInfoDto
{
public Guid Id { get; set; }
public required string Name { get; set; }
}
private class UnitValueDto
{
public Guid FieldId { get; set; }
public string? Value { get; set; }
}
private class RelatedUnitDto
{
public Guid UnitId { get; set; }
public string Name { get; set; } = null!;
public List<UnitValueDto> Values { get; set; } = new();
}
private class UnitDto
{
public Guid Id { get; set; }
public string Name { get; set; } = null!;
public List<UnitValueDto> Values { get; set; } = new();
public List<RelatedUnitDto> Parents { get; set; } = new();
public List<RelatedUnitDto> Children { get; set; } = new();
}
#endregion
public async Task<IEnumerable<Guid>?> GetUnitsIdByJobFilterAsync(Guid jobId, int? takeCount = null)
{
var job = await jobService
.Get().AsNoTracking()
.Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters)
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters)
.Include(j => j.Group).ThenInclude(g => g.GroupType)
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
.FirstOrDefaultAsync(j => j.Id == jobId);
return job == null ? null : await GetUnitsIdByJobFilterAsync(job, takeCount);
@@ -45,14 +77,12 @@ namespace PARR.DAL.DomainServices.Implementations
public async Task<IEnumerable<Guid>?> GetUnitsIdByJobFilterAsync(Job job, int? takeCount = null)
{
#region Проверка обязательных зависимостей
if (job.Group == null)
throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group");
if (job.Group.GroupType == null)
throw new ArgumentNullException(nameof(job.Group.GroupType), $"Job {job.Id} не содержит GroupType");
if (job.UnitFilters == null || !job.UnitFilters.Any())
throw new ArgumentNullException(nameof(job.UnitFilters), $"Job {job.Id} не содержит UnitFilters");
#endregion
var maxCount = takeCount ?? int.MaxValue;
var collectedIds = new HashSet<Guid>();
@@ -69,30 +99,171 @@ namespace PARR.DAL.DomainServices.Implementations
{
logger.LogDebug("Применяем фильтр #{Index} (Id={FilterId})", filterNumber, filter.Id);
var query = unitService.Get().AsNoTracking()
.Where(unit => EF.Functions.Like(unit.Name, filter.UnitFilter));
// 1 Найти ID юнитов по UnitFilter (Name LIKE)
var initialUnitIds = await unitService.Get().AsNoTracking()
.Where(unit => EF.Functions.Like(unit.Name, filter.UnitFilter))
.Select(u => u.Id)
.ToListAsync();
logger.LogDebug("Базовый фильтр по Name: {NameFilter}", filter.UnitFilter);
logger.LogDebug("Базовый фильтр по Name '{NameFilter}' дал {Count} юнитов", filter.UnitFilter, initialUnitIds.Count);
if (!initialUnitIds.Any())
{
logger.LogDebug("Фильтр #{Index}: 0 юнитов после UnitFilter. Пропускаем.", filterNumber);
continue;
}
// 2 Загрузить UnitValues для этих юнитов (только FieldId из FieldFilter)
var fieldFilterFieldIds = filter.FieldFilters.Select(f => f.FieldId).ToHashSet();
var unitValues = fieldFilterFieldIds.Any()
? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(initialUnitIds, fieldFilterFieldIds)
: new List<UnitInValue>();
// 3 Загрузить связи
var parentRelFilters = filter.RelationshipFilters.Where(rf => rf.IsParent).ToList();
var childRelFilters = filter.RelationshipFilters.Where(rf => !rf.IsParent).ToList();
var parentLinks = parentRelFilters.Any()
? await unitInUnitService.GetParentLinksByChildIdsAsync(initialUnitIds)
: new List<UnitInUnit>();
var childLinks = childRelFilters.Any()
? await unitInUnitService.GetChildLinksByParentIdsAsync(initialUnitIds)
: new List<UnitInUnit>();
// 4 ID родителей и детей
var parentUnitIds = parentLinks.Select(l => l.ParentUnitId).ToHashSet();
var childUnitIds = childLinks.Select(l => l.ChildUnitId).ToHashSet();
// 5 Загрузить родительские и дочерние юниты (Id + Name)
var parentUnits = parentUnitIds.Any()
? await unitService.Get().AsNoTracking()
.Where(u => parentUnitIds.Contains(u.Id))
.Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name })
.ToListAsync()
: new List<UnitInfoDto>();
var childUnits = childUnitIds.Any()
? await unitService.Get().AsNoTracking()
.Where(u => childUnitIds.Contains(u.Id))
.Select(u => new UnitInfoDto { Id = u.Id, Name = u.Name })
.ToListAsync()
: new List<UnitInfoDto>();
// 6 UnitValues для родителей и детей (только FieldId из RelationshipFilter)
var relFilterFieldIds = filter.RelationshipFilters.Select(f => f.FieldId).ToHashSet();
var parentUnitValues = relFilterFieldIds.Any() && parentUnitIds.Any()
? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(parentUnitIds, relFilterFieldIds)
: new List<UnitInValue>();
var childUnitValues = relFilterFieldIds.Any() && childUnitIds.Any()
? await unitInValueService.GetByUnitIdsAndFieldIdsAsync(childUnitIds, relFilterFieldIds)
: new List<UnitInValue>();
// 7 Сборка DTO
var unitMap = initialUnitIds.ToDictionary(id => id, id => new UnitDto { Id = id });
// UnitValues → Unit
foreach (var uv in unitValues)
{
if (unitMap.TryGetValue(uv.UnitId, out var dto))
{
dto.Values.Add(new UnitValueDto { FieldId = uv.FieldId, Value = uv.Value?.Value });
}
}
var parentIdMap = parentUnits.ToDictionary(u => u.Id, u => u.Name);
var childIdMap = childUnits.ToDictionary(u => u.Id, u => u.Name);
// ParentLinks → Parent
foreach (var link in parentLinks)
{
if (unitMap.TryGetValue(link.ChildUnitId, out var dto))
{
dto.Parents.Add(new RelatedUnitDto
{
UnitId = link.ParentUnitId,
Name = parentIdMap.GetValueOrDefault(link.ParentUnitId, "")
});
}
}
// ChildLinks → Child
foreach (var link in childLinks)
{
if (unitMap.TryGetValue(link.ParentUnitId, out var dto))
{
dto.Children.Add(new RelatedUnitDto
{
UnitId = link.ChildUnitId,
Name = childIdMap.GetValueOrDefault(link.ChildUnitId, "")
});
}
}
// Привязка значений к родителям
var parentValuesMap = parentUnitValues
.GroupBy(v => v.UnitId)
.ToDictionary(g => g.Key, g => g.Select(v => new UnitValueDto { FieldId = v.FieldId, Value = v.Value?.Value }).ToList());
foreach (var dto in unitMap.Values)
{
foreach (var parent in dto.Parents)
{
if (parentValuesMap.TryGetValue(parent.UnitId, out var values))
{
parent.Values.AddRange(values);
}
}
}
// Привязка значений к детям
var childValuesMap = childUnitValues
.GroupBy(v => v.UnitId)
.ToDictionary(g => g.Key, g => g.Select(v => new UnitValueDto { FieldId = v.FieldId, Value = v.Value?.Value }).ToList());
foreach (var dto in unitMap.Values)
{
foreach (var child in dto.Children)
{
if (childValuesMap.TryGetValue(child.UnitId, out var values))
{
child.Values.AddRange(values);
}
}
}
// 8 Применение фильтров
var candidateUnits = unitMap.Values.AsQueryable();
foreach (var fieldFilter in filter.FieldFilters)
{
query = ApplyFieldFilter(query, fieldFilter);
candidateUnits = ApplyFieldFilter(candidateUnits, fieldFilter);
}
logger.LogDebug("После FieldFilter осталось {Count} юнитов", candidateUnits.Count());
foreach (var relFilter in filter.RelationshipFilters)
{
query = ApplyRelationshipFilterToQuery(query, relFilter);
candidateUnits = ApplyRelationshipFilterToQuery(candidateUnits, relFilter);
}
logger.LogDebug("После RelationshipFilter осталось {Count} юнитов", candidateUnits.Count());
// 9⃣ Umbrella-фильтр
var finalUnits = candidateUnits.AsEnumerable();
if (job.Group.GroupType.Code == JobGroupTypesEnum.Umbrella)
{
query = ApplyRelationshipCountFilter(query, job);
finalUnits = ApplyRelationshipCountFilter(finalUnits, job, filter.RelationshipFilters);
logger.LogDebug("После Umbrella-фильтра осталось {Count} юнитов", finalUnits.Count());
}
var newIds = await query
// 10 Взять ID
var newIds = finalUnits
.Select(u => u.Id)
.Take(remaining)
.ToListAsync();
.ToList();
collectedIds.UnionWith(newIds);
logger.LogDebug("Фильтр #{Index}: найдено {Count} Unit'ов. Всего: {Total}",
@@ -133,11 +304,7 @@ namespace PARR.DAL.DomainServices.Implementations
foreach (var filter in job.UnitFilters)
{
if (!filter.RelationshipFilters.Any())
{
logger.LogDebug("UnitFilter.Id {FilterId} не содержит RelationshipFilters. Пропускаем.", filter.Id);
continue;
}
if (!filter.RelationshipFilters.Any()) continue;
logger.LogDebug("Обработка UnitFilter.Id {FilterId}. Количество RelationshipFilters: {RelFilterCount}", filter.Id, filter.RelationshipFilters.Count());
@@ -147,11 +314,7 @@ namespace PARR.DAL.DomainServices.Implementations
rf.UnitFilterId, rf.IsParent, rf.FieldId, rf.ValueMask);
var valueMask = rf.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
{
logger.LogDebug("ValueMask пуст. Пропускаем фильтр (UnitFilterId={UnitFilterId}).", rf.UnitFilterId);
continue;
}
if (string.IsNullOrEmpty(valueMask)) continue;
List<UnitInUnit> relevantLinks;
if (rf.IsParent)
@@ -159,207 +322,176 @@ namespace PARR.DAL.DomainServices.Implementations
else
relevantLinks = await unitInUnitService.GetByParentIdAsync(unitId);
logger.LogDebug("Найдено {Count} связей через UnitInUnitService.", relevantLinks.Count);
// Получаем Id юнитов, которые участвуют в связях (дети или родители)
var unitIdsToCheck = rf.IsParent
? relevantLinks.Select(l => l.ParentUnitId).ToList()
: relevantLinks.Select(l => l.ChildUnitId).ToList();
if (!unitIdsToCheck.Any())
{
logger.LogDebug("Нет юнитов для проверки значений.");
continue;
}
if (!unitIdsToCheck.Any()) continue;
// Получаем значения полей для этих юнитов
var unitValues = await unitInValueService.GetByUnitIdsAsync(unitIdsToCheck);
logger.LogDebug("Получено {Count} значений полей через UnitInValueService.", unitValues.Count);
// Фильтруем юниты, у которых есть нужное значение
var matchingUnitIds = unitValues
.Where(uv => uv.FieldId == rf.FieldId
&& uv.Value != null
&& uv.Value.Value != null
&& uv.Value.Value.ToString()!.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
&& uv.Value?.Value != null
&& uv.Value.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.ToList();
logger.LogDebug("Найдено {Count} юнитов с подходящим значением поля.", matchingUnitIds.Count);
// Получаем имена этих юнитов через IUnitService
if (matchingUnitIds.Any())
{
var matchingUnitNames = await unitService.Get().AsNoTracking()
var names = await unitService.Get().AsNoTracking()
.Where(u => matchingUnitIds.Contains(u.Id))
.Select(u => u.Name)
.ToListAsync();
logger.LogDebug("Найдены имена: [{Names}]", string.Join(", ", matchingUnitNames));
result.UnionWith(matchingUnitNames);
result.UnionWith(names);
}
}
}
logger.LogDebug("Итоговый результат: [{Result}]", string.Join(", ", result));
return result.ToList();
}
#region Вспомогательные методы фильтрации
#region вспомогательные методы
private IQueryable<Unit> ApplyFieldFilter(IQueryable<Unit> query, JobFieldFilter fieldFilter)
private IQueryable<UnitDto> ApplyFieldFilter(IQueryable<UnitDto> query, JobFieldFilter fieldFilter)
{
var fieldId = fieldFilter.FieldId;
var valueMask = fieldFilter.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
return query;
logger.LogDebug("Фильтр по полю: FieldId={FieldId}, ValueMask={ValueMask}", fieldId, valueMask);
logger.LogDebug("Применяем FieldFilterDto: FieldId={FieldId}, ValueMask={ValueMask}", fieldFilter.FieldId, valueMask);
return query.Where(unit =>
unit.UnitValues.Any(v =>
v.FieldId == fieldId &&
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
return query.AsEnumerable().Where(dto =>
dto.Values.Any(v =>
v.FieldId == fieldFilter.FieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask)));
(isStartsWith && isEndsWith ? v.Value.Contains(valueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) :
isStartsWith ? v.Value.StartsWith(valueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) :
isEndsWith ? v.Value.EndsWith(valueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) :
v.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
)
).AsQueryable();
}
private IQueryable<Unit> ApplyRelationshipFilterToQuery(IQueryable<Unit> query, JobRelationshipFilter relFilter)
private IQueryable<UnitDto> ApplyRelationshipFilterToQuery(IQueryable<UnitDto> query, JobRelationshipFilter relFilter)
{
var fieldId = relFilter.FieldId;
var valueMask = relFilter.ValueMask ?? "";
var valueMask = relFilter.ValueMask?.Trim();
if (string.IsNullOrWhiteSpace(valueMask))
return query;
logger.LogDebug("Фильтр по связи: IsParent={IsParent}, IsInverse={IsInverse}, IsFullMatch={IsFullMatch}, FieldId={FieldId}, ValueMask={ValueMask}",
relFilter.IsParent, relFilter.IsInverse, relFilter.IsFullMatch, fieldId, valueMask);
logger.LogDebug("Применяем RelationshipFilterDto: IsParent={IsParent}, IsInverse={IsInverse}, IsFullMatch={IsFullMatch}, FieldId={FieldId}, ValueMask={ValueMask}",
relFilter.IsParent, relFilter.IsInverse, relFilter.IsFullMatch, relFilter.FieldId, valueMask);
if (relFilter.IsParent)
var isParent = relFilter.IsParent;
var isInverse = relFilter.IsInverse;
var isFullMatch = relFilter.IsFullMatch;
var fieldId = relFilter.FieldId;
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
return query.AsEnumerable().Where(dto =>
{
if (relFilter.IsInverse)
var links = isParent ? dto.Parents : dto.Children;
if (links == null || !links.Any())
{
if (relFilter.IsFullMatch)
{
return query.Where(unit =>
!unit.ParentUnits.Any() ||
unit.ParentUnits.All(link =>
!link.ParentUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
else
{
return query.Where(unit =>
!unit.ParentUnits.Any() ||
unit.ParentUnits.Any(link =>
!link.ParentUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
// Для IsFullMatch/IsInverse — "все подходят", т.е. true
// Для остальных — false
return isFullMatch && isInverse;
}
else
var hasMatchingLinks = links.Any(link =>
{
if (relFilter.IsFullMatch)
{
return query.Where(unit =>
!unit.ParentUnits.Any() ||
unit.ParentUnits.All(link =>
link.ParentUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
else
{
return query.Where(unit =>
unit.ParentUnits.Any(link =>
link.ParentUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
}
}
else
{
if (relFilter.IsInverse)
var hasMatch = link.Values.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
(isStartsWith && isEndsWith ? v.Value.Contains(valueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) :
isStartsWith ? v.Value.StartsWith(valueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) :
isEndsWith ? v.Value.EndsWith(valueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) :
v.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
);
return isInverse ? !hasMatch : hasMatch;
});
if (isFullMatch)
{
if (relFilter.IsFullMatch)
var allMatch = links.All(link =>
{
return query.Where(unit =>
!unit.ChildUnits.Any() ||
unit.ChildUnits.All(link =>
!link.ChildUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
else
{
return query.Where(unit =>
!unit.ChildUnits.Any() ||
unit.ChildUnits.Any(link =>
!link.ChildUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
var hasMatch = link.Values.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
(isStartsWith && isEndsWith ? v.Value.Contains(valueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) :
isStartsWith ? v.Value.StartsWith(valueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) :
isEndsWith ? v.Value.EndsWith(valueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) :
v.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
);
return isInverse ? !hasMatch : hasMatch;
});
return allMatch;
}
else
{
if (relFilter.IsFullMatch)
{
return query.Where(unit =>
!unit.ChildUnits.Any() ||
unit.ChildUnits.All(link =>
link.ChildUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
else
{
return query.Where(unit =>
unit.ChildUnits.Any(link =>
link.ChildUnit!.UnitValues.Any(v =>
v.FieldId == fieldId &&
v.Value != null &&
v.Value.Value != null &&
EF.Functions.Like(v.Value.Value, valueMask))));
}
}
}
return hasMatchingLinks;
}).AsQueryable();
}
private IQueryable<Unit> ApplyRelationshipCountFilter(IQueryable<Unit> query, Job job)
private IEnumerable<UnitDto> ApplyRelationshipCountFilter(
IEnumerable<UnitDto> units,
Job job,
IEnumerable<JobRelationshipFilter> relationshipFilters)
{
int min = job.MinValueRelationships.GetValueOrDefault(0);
int max = job.MaxValueRelationships.GetValueOrDefault(int.MaxValue);
var min = job.MinValueRelationships ?? 0;
var max = job.MaxValueRelationships ?? int.MaxValue;
var isParentDirection = job.IsParentRelationships == true;
logger.LogDebug("Фильтр по количеству связей: Min={Min}, Max={Max}, IsParent={IsParent}", min, max, job.IsParentRelationships);
if (min == 0 && max == int.MaxValue)
return units;
if (job.IsParentRelationships == true)
var activeFilters = relationshipFilters
.Where(rf => rf.IsParent == isParentDirection && !string.IsNullOrWhiteSpace(rf.ValueMask))
.ToList();
if (activeFilters.Count == 0)
return units;
logger.LogDebug("RelationshipFilter: Min={Min}, Max={Max}, IsParent={IsParent}, Filters={Count}",
min, max, isParentDirection, activeFilters.Count);
return units.Where(dto =>
{
return query.Where(unit => unit.ParentUnits.Count >= min && unit.ParentUnits.Count <= max);
}
else
{
return query.Where(unit => unit.ChildUnits.Count >= min && unit.ChildUnits.Count <= max);
}
var links = isParentDirection ? dto.Parents : dto.Children;
if (links == null || !links.Any())
return min == 0;
int matchingCount = 0;
foreach (var link in links)
{
bool hasMatch = link.Values.Any(v =>
activeFilters.Any(f =>
v.FieldId == f.FieldId &&
v.Value != null &&
v.Value.Contains(f.ValueMask, StringComparison.OrdinalIgnoreCase)
)
);
if (hasMatch)
matchingCount++;
if (matchingCount > max)
break;
}
return matchingCount >= min && matchingCount <= max;
});
}
#endregion