feat(templateMatcher): Добавлена проверка принадлежности связанного ЭК к КИИ, который имеет больший приоритет.

This commit is contained in:
Mikhail Kuznetsov
2026-05-12 11:24:17 +10:00
parent 0235fe0028
commit 0cc7b0dfbb
4 changed files with 173 additions and 156 deletions

View File

@@ -6,7 +6,6 @@ using PARR.Core.Repositories.Interfaces.Job;
using PARR.Core.Repositories.Interfaces.Unit; using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.MatchingStatusService; using PARR.Core.Services.MatchingStatusService;
using PARR.Core.Services.UnitFilterService; using PARR.Core.Services.UnitFilterService;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Cache.Models; using PARR.Domain.Cache.Models;
using PARR.Domain.Common.Rabbit.Messages; using PARR.Domain.Common.Rabbit.Messages;
using PARR.Domain.Entities; using PARR.Domain.Entities;
@@ -38,7 +37,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
private readonly ITemplateNameNormalizer templateNameNormalizer; private readonly ITemplateNameNormalizer templateNameNormalizer;
private readonly ITemplateUpdaterMqSender templateUpdaterMqSender; private readonly ITemplateUpdaterMqSender templateUpdaterMqSender;
private readonly IMatchingStatusService matchingStatusService; private readonly IMatchingStatusService matchingStatusService;
private readonly IUnitInTemplateConflictMapper unitInTemplateConflictMapper;
public GroupedTemplateSynchronizer( public GroupedTemplateSynchronizer(
ILogger<GroupedTemplateSynchronizer> logger, ILogger<GroupedTemplateSynchronizer> logger,
@@ -55,7 +54,8 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
ITemplateDeactivator templateDeactivator, ITemplateDeactivator templateDeactivator,
ITemplateNameNormalizer templateNameNormalizer, ITemplateNameNormalizer templateNameNormalizer,
ITemplateUpdaterMqSender templateUpdaterMqSender, ITemplateUpdaterMqSender templateUpdaterMqSender,
IMatchingStatusService matchingStatusService IMatchingStatusService matchingStatusService,
IUnitInTemplateConflictMapper unitInTemplateConflictMapper
) )
{ {
this.logger = logger; this.logger = logger;
@@ -73,6 +73,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
this.templateNameNormalizer = templateNameNormalizer; this.templateNameNormalizer = templateNameNormalizer;
this.templateUpdaterMqSender = templateUpdaterMqSender; this.templateUpdaterMqSender = templateUpdaterMqSender;
this.matchingStatusService = matchingStatusService; this.matchingStatusService = matchingStatusService;
this.unitInTemplateConflictMapper = unitInTemplateConflictMapper;
} }
public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator) public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator)
@@ -236,16 +237,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
} }
logger.LogDebug("Построение обратного отображения: связанные юниты -> юниты, связанные с ними (после фильтрации)."); logger.LogDebug("Построение обратного отображения: связанные юниты -> юниты, связанные с ними (после фильтрации).");
var reverseMapping = await BuildReverseMappingAsync(finalFilteredUnitFilterResults, maxJob); var reverseMapping = await unitInTemplateConflictMapper.BuildMappingAsync(finalFilteredUnitFilterResults, maxJob);
logger.LogDebug("Построено {Count} записей в обратном отображении.", reverseMapping.Count);
if (!reverseMapping.Any())
{
logger.LogInformation("После построения обратного отображения в JobGroup {JobGroupId} не осталось юнитов.", jobGroupId);
await UpdateMatchingStatusAsync(jobGroupId, "Нет юнитов после построения обратного отображения");
return;
}
logger.LogDebug("Построено {Count} записей в обратном отображении.", reverseMapping.Count); logger.LogDebug("Построено {Count} записей в обратном отображении.", reverseMapping.Count);
@@ -620,148 +612,5 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
return field.Id; return field.Id;
} }
/// <summary>
/// Строит обратное отображение: UnitId шаблона -> [UnitsInTemplate]
/// Решает конфликты, когда юнит из UnitsInTemplate может быть связан с несколькими UnitId шаблона.
/// </summary>
/// <param name="unitFilterResults">Результаты фильтрации, содержащие связи.</param>
/// <param name="maxJob">Job, используемый для определения направления связей (IsParentRelationships).</param>
/// <returns>Словарь, где ключ - это UnitId шаблона, а значение - список юнитов, входящих в него (UnitsInTemplate).</returns>
private async Task<Dictionary<Guid, List<Guid>>> BuildReverseMappingAsync(IEnumerable<UnitFilterResultDto> unitFilterResults, Job maxJob)
{
logger.LogDebug("Разрешение конфликта: определение, для какого UnitId выбрать каждый юнит из UnitsInTemplate.");
// 1. Собираем все потенциальные пары (relatedUnitId, unitInTemplateId)
var potentialAssignments = new Dictionary<Guid, List<Guid>>(); // relatedUnitId -> [unitInTemplateId, ...]
var allPotentialRelatedUnitIds = new HashSet<Guid>();
var allUnitInTemplateIds = new HashSet<Guid>();
foreach (var dto in unitFilterResults)
{
List<Guid> relatedUnitIds;
if (maxJob.IsParentRelationships == true)
{
relatedUnitIds = dto.Children.Select(c => c.UnitId).ToList(); // relatedUnitIds - это ChildUnitIds, которые станут UnitId шаблона
}
else
{
relatedUnitIds = dto.Parents.Select(p => p.UnitId).ToList(); // relatedUnitIds - это ParentUnitIds, которые станут UnitId шаблона
}
var unitInTemplateId = dto.Id; // dto.Id - это юнит, который прошел фильтры, он будет в UnitsInTemplate
allUnitInTemplateIds.Add(unitInTemplateId);
allPotentialRelatedUnitIds.UnionWith(relatedUnitIds); // Собираем все unique relatedUnitId
foreach (var relatedUnitId in relatedUnitIds)
{
if (!potentialAssignments.ContainsKey(relatedUnitId))
{
potentialAssignments[relatedUnitId] = new List<Guid>();
}
potentialAssignments[relatedUnitId].Add(unitInTemplateId);
}
}
// 2. Загрузим имена всех potentialRelatedUnitIds для сортировки по алфавиту при равенстве связей
var relatedUnitNames = await unitService.Get()
.AsNoTracking()
.Where(u => allPotentialRelatedUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString());
// 3. Для каждого unitInTemplateId, найти лучший relatedUnitId
var unitInTemplateToBestRelatedUnit = new Dictionary<Guid, Guid>(); // unitInTemplateId -> bestRelatedUnitId
foreach (var unitInTemplateId in allUnitInTemplateIds)
{
var candidates = potentialAssignments
.Where(kvp => kvp.Value.Contains(unitInTemplateId))
.Select(kvp => kvp.Key)
.ToList();
if (candidates.Count == 1)
{
// Только один кандидат, просто назначаем
unitInTemplateToBestRelatedUnit[unitInTemplateId] = candidates[0];
}
else if (candidates.Count > 1)
{
// Несколько кандидатов, применяем правила: 1. Больше связей -> лучше. 2. По алфавиту.
Guid bestCandidate = candidates[0]; // Инициализируем первым кандидатом
// Загрузим количество связей для каждого кандидата
// Количество связей - это общее число юнитов (в Parents или Children) в UnitFilterResultDto, связанном с *этим* relatedUnitId
var candidateRelationshipCounts = new Dictionary<Guid, int>();
foreach (var candidateId in candidates)
{
// Найдем все dto, которые привели к этому candidateId
// Это dto.Id, у которых candidateId был в Parents (если IsParentRelationships) или в Children (если !IsParentRelationships)
var relevantDtos = unitFilterResults.Where(dto =>
{
if (maxJob.IsParentRelationships == true)
{
return dto.Children.Any(c => c.UnitId == candidateId);
}
else
{
return dto.Parents.Any(p => p.UnitId == candidateId);
}
}).ToList();
// Общее количество связей для этого candidateId - это сумма связей (Parents.Count или Children.Count) из *всех* relevantDtos
int totalRelationships = 0;
foreach (var relevantDto in relevantDtos)
{
if (maxJob.IsParentRelationships == true)
{
totalRelationships += relevantDto.Children.Count;
}
else
{
totalRelationships += relevantDto.Parents.Count;
}
}
candidateRelationshipCounts[candidateId] = totalRelationships;
}
// Применяем правило 1: больше связей -> лучше
int bestCount = candidateRelationshipCounts[bestCandidate];
foreach (var candidateId in candidates.Skip(1))
{
int candidateCount = candidateRelationshipCounts[candidateId];
if (candidateCount > bestCount ||
(candidateCount == bestCount && string.Compare(relatedUnitNames.GetValueOrDefault(candidateId, candidateId.ToString()), relatedUnitNames.GetValueOrDefault(bestCandidate, bestCandidate.ToString()), StringComparison.OrdinalIgnoreCase) < 0))
{
bestCandidate = candidateId;
bestCount = candidateCount;
}
}
unitInTemplateToBestRelatedUnit[unitInTemplateId] = bestCandidate;
}
// else: если candidates.Count == 0 (что маловероятно, если dto.Id гарантированно связан), то unitInTemplateId не будет в unitInTemplateToBestRelatedUnit
}
logger.LogDebug("Разрешение конфликта завершено. Найдено {Count} однозначных назначений.", unitInTemplateToBestRelatedUnit.Count);
// 4. Построение reverseMapping на основе решённых конфликтов
var reverseMapping = new Dictionary<Guid, List<Guid>>();
foreach (var assignmentKvp in unitInTemplateToBestRelatedUnit)
{
var unitInTemplateId = assignmentKvp.Key;
var bestRelatedUnitId = assignmentKvp.Value;
if (!reverseMapping.ContainsKey(bestRelatedUnitId))
{
reverseMapping[bestRelatedUnitId] = new List<Guid>();
}
reverseMapping[bestRelatedUnitId].Add(unitInTemplateId);
}
return reverseMapping;
}
#endregion #endregion
} }

View File

@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
using PARR.TemplateMatcher.Services.Interfaces;
internal class UnitInTemplateConflictMapper : IUnitInTemplateConflictMapper
{
private readonly ILogger<UnitInTemplateConflictMapper> logger;
private readonly IUnitRepository unitRepository;
private readonly IUnitKiiUnitRepository unitKiiUnitRepository;
public UnitInTemplateConflictMapper(
ILogger<UnitInTemplateConflictMapper> logger,
IUnitRepository unitRepository,
IUnitKiiUnitRepository unitKiiUnitRepository)
{
this.logger = logger;
this.unitRepository = unitRepository;
this.unitKiiUnitRepository = unitKiiUnitRepository;
}
public async Task<Dictionary<Guid, List<Guid>>> BuildMappingAsync(
IEnumerable<UnitFilterResultDto> unitFilterResults,
Job maxJob,
CancellationToken cancellationToken = default)
{
logger.LogDebug("Начало разрешения конфликтов и построения маппинга для юнитов в шаблонах.");
var unitFilterResultsList = unitFilterResults.ToList();
if (!unitFilterResultsList.Any())
return new Dictionary<Guid, List<Guid>>();
var potentialAssignments = new Dictionary<Guid, List<Guid>>();
var allPotentialRelatedUnitIds = new HashSet<Guid>();
var allUnitInTemplateIds = new HashSet<Guid>();
foreach (var dto in unitFilterResultsList)
{
var relatedUnitIds = maxJob.IsParentRelationships == true
? dto.Children.Select(c => c.UnitId).ToList()
: dto.Parents.Select(p => p.UnitId).ToList();
var unitInTemplateId = dto.Id;
allUnitInTemplateIds.Add(unitInTemplateId);
allPotentialRelatedUnitIds.UnionWith(relatedUnitIds);
foreach (var relatedId in relatedUnitIds)
{
if (!potentialAssignments.TryGetValue(relatedId, out var list))
{
list = new List<Guid>();
potentialAssignments[relatedId] = list;
}
list.Add(unitInTemplateId);
}
}
if (!allPotentialRelatedUnitIds.Any())
return new Dictionary<Guid, List<Guid>>();
var relatedUnitNames = await unitRepository.Get()
.AsNoTracking()
.Where(u => allPotentialRelatedUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString(), cancellationToken);
// Загружаем идентификаторы КИИ через ваш реальный репозиторий
var kiiUnitIds = new HashSet<Guid>(await unitKiiUnitRepository.Get()
.Select(k => k.UnitId)
.ToListAsync(cancellationToken));
logger.LogDebug("Загружено {KiiCount} идентификаторов КИИ юнитов для приоритезации.", kiiUnitIds.Count);
var unitInTemplateToBestRelatedUnit = new Dictionary<Guid, Guid>();
foreach (var unitInTemplateId in allUnitInTemplateIds)
{
var candidates = potentialAssignments
.Where(kvp => kvp.Value.Contains(unitInTemplateId))
.Select(kvp => kvp.Key)
.ToList();
if (candidates.Count == 0) continue;
if (candidates.Count == 1)
{
unitInTemplateToBestRelatedUnit[unitInTemplateId] = candidates[0];
continue;
}
var kiiCandidates = candidates.Where(id => kiiUnitIds.Contains(id)).ToList();
var candidatesToConsider = kiiCandidates.Any() ? kiiCandidates : candidates;
var relationshipCounts = new Dictionary<Guid, int>();
foreach (var candidateId in candidatesToConsider)
{
int totalRelationships = 0;
foreach (var dto in unitFilterResultsList)
{
if (maxJob.IsParentRelationships == true && dto.Children.Any(c => c.UnitId == candidateId))
totalRelationships += dto.Children.Count;
else if (maxJob.IsParentRelationships == false && dto.Parents.Any(p => p.UnitId == candidateId))
totalRelationships += dto.Parents.Count;
}
relationshipCounts[candidateId] = totalRelationships;
}
var bestCandidate = candidatesToConsider[0];
int bestCount = relationshipCounts[bestCandidate];
foreach (var candidateId in candidatesToConsider.Skip(1))
{
int currentCount = relationshipCounts[candidateId];
if (currentCount > bestCount ||
(currentCount == bestCount && string.Compare(
relatedUnitNames.GetValueOrDefault(candidateId, candidateId.ToString()),
relatedUnitNames.GetValueOrDefault(bestCandidate, bestCandidate.ToString()),
StringComparison.OrdinalIgnoreCase) < 0))
{
bestCandidate = candidateId;
bestCount = currentCount;
}
}
unitInTemplateToBestRelatedUnit[unitInTemplateId] = bestCandidate;
}
logger.LogDebug("Разрешение конфликтов завершено. Найдено {Count} однозначных назначений.", unitInTemplateToBestRelatedUnit.Count);
var reverseMapping = new Dictionary<Guid, List<Guid>>();
foreach (var kvp in unitInTemplateToBestRelatedUnit)
{
if (!reverseMapping.TryGetValue(kvp.Value, out var list))
{
list = new List<Guid>();
reverseMapping[kvp.Value] = list;
}
list.Add(kvp.Key);
}
return reverseMapping;
}
}

View File

@@ -0,0 +1,23 @@
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.Job;
namespace PARR.TemplateMatcher.Services.Interfaces
{
/// <summary>
/// Разрешает конфликты при сопоставлении юнитов к шаблонам и строит итоговую карту связей.
/// </summary>
public interface IUnitInTemplateConflictMapper
{
/// <summary>
/// Строит маппинг: UnitId шаблона -> [Список юнитов в UnitsInTemplate].
/// При наличии нескольких кандидатов для одного юнита выбирается лучший по приоритету:
/// 1. Наличие в таблице UnitKiiUnit
/// 2. Наибольшее количество связей
/// 3. Алфавитный порядок имени юнита
/// </summary>
Task<Dictionary<Guid, List<Guid>>> BuildMappingAsync(
IEnumerable<UnitFilterResultDto> unitFilterResults,
Job maxJob,
CancellationToken ct = default);
}
}

View File

@@ -34,6 +34,7 @@ namespace PARR.TemplateMatcher
services.AddTransient<ITemplateNameNormalizer, TemplateNameNormalizer>(); services.AddTransient<ITemplateNameNormalizer, TemplateNameNormalizer>();
services.AddTransient<ITemplateUpdaterMqSender, TemplateUpdaterMqSender>(); services.AddTransient<ITemplateUpdaterMqSender, TemplateUpdaterMqSender>();
services.AddTransient<ITemplateDeactivator, TemplateDeactivator>(); services.AddTransient<ITemplateDeactivator, TemplateDeactivator>();
services.AddTransient<IUnitInTemplateConflictMapper, UnitInTemplateConflictMapper>();
services.AddTransient<ITemplateSynchronizer, SimpleTemplateSynchronizer>(); services.AddTransient<ITemplateSynchronizer, SimpleTemplateSynchronizer>();
services.AddTransient<ITemplateSynchronizer, GroupedTemplateSynchronizer>(); services.AddTransient<ITemplateSynchronizer, GroupedTemplateSynchronizer>();
} }