refactor(templateMatcher): Переход на Pipeline-архитектуру для SimpleSync и GroupedSync.
- SimpleTemplateSynchronizer и GroupedTemplateSynchronizer переведены на паттерн Pipeline с разделением на Read/Write этапы - Выделены контракты этапов (ISimpleSyncStage, IGroupedSyncStage) и контексты (SimpleSyncContext, GroupedSyncContext) - Read-этапы безопасны для тестов (не пишут в БД/MQ), Write-этапы изолированы через отдельные интерфейсы - Добавлено [Perf]-логирование каждого этапа с метриками времени выполнения - Логи приведены к человекочитаемому формату 'Имя' (ID) для Job, JobGroup и Unit - Устранено дублирование данных в контекстах (FilteredUnits перезаписывается, TemplateGroups строго типизирован) - Константы неиспользуемых шаблонов вынесены в UnusedTemplateConstants - Структура проекта реорганизована: SimpleSync, GroupedSync, Implementations, Interfaces
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class BuildGroupsStage : IGroupedSyncStage
|
||||
{
|
||||
private readonly IGroupedTemplateBuilder _builder;
|
||||
private readonly ILogger<BuildGroupsStage> _logger;
|
||||
|
||||
public string StageName => "Построение групп";
|
||||
|
||||
public BuildGroupsStage(IGroupedTemplateBuilder builder, ILogger<BuildGroupsStage> logger)
|
||||
{
|
||||
_builder = builder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var groups = await _builder.BuildAsync(context.ReverseMapping, context.JobGroup, context.MaxJob);
|
||||
|
||||
if (!groups.Any())
|
||||
throw new GroupedSyncEarlyExitException("Нет данных после построения групп");
|
||||
|
||||
context.TemplateGroups = groups;
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): построено {Count} групп",
|
||||
context.JobGroupName, context.JobGroupId, groups.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Repositories.Interfaces;
|
||||
using PARR.Domain.Enums;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
using PARR.TemplateMatcher.Services.Interfaces;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class DeactivateTemplatesStage : IGroupedSyncWriteStage
|
||||
{
|
||||
private readonly ITemplateRepository _templateRepository;
|
||||
private readonly ITemplateDeactivator _deactivator;
|
||||
private readonly ILogger<DeactivateTemplatesStage> _logger;
|
||||
|
||||
public string StageName => "Деактивация шаблонов";
|
||||
|
||||
public DeactivateTemplatesStage(
|
||||
ITemplateRepository templateRepository,
|
||||
ITemplateDeactivator deactivator,
|
||||
ILogger<DeactivateTemplatesStage> logger)
|
||||
{
|
||||
_templateRepository = templateRepository;
|
||||
_deactivator = deactivator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var allJobIds = context.JobsInGroup.Select(j => j.Id).ToHashSet();
|
||||
|
||||
var existingTemplates = await _templateRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Unit)
|
||||
.Include(t => t.Job)
|
||||
.Include(t => t.UnitsInTemplate)
|
||||
.Where(t => allJobIds.Contains(t.JobId)
|
||||
&& t.StatusTypeId == TemplateStatusTypeEnum.Used
|
||||
&& t.Job!.GroupId == context.JobGroupId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
int deactivated = 0;
|
||||
foreach (var template in existingTemplates)
|
||||
{
|
||||
var key = (template.JobId, template.UnitId, template.Index ?? -1);
|
||||
if (!context.ExpectedTemplateKeys.Contains(key))
|
||||
{
|
||||
var unitLogName = context.UnitNames.TryGetValue(template.UnitId, out var unitName)
|
||||
? $"'{unitName}' ({template.UnitId})"
|
||||
: template.Unit != null
|
||||
? $"'{template.Unit.Name}' ({template.UnitId})"
|
||||
: $"({template.UnitId})";
|
||||
|
||||
_logger.LogInformation(
|
||||
"JobGroup '{JobGroupName}' ({JobGroupId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Job '{JobName}' ({JobId}), Unit {Unit}",
|
||||
context.JobGroupName, context.JobGroupId,
|
||||
template.Name, template.Id,
|
||||
template.Job?.Name ?? string.Empty, template.JobId,
|
||||
unitLogName);
|
||||
|
||||
await _deactivator.DeactivateTemplateAsync(template, context.Initiator);
|
||||
deactivated++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): деактивировано {Count} шаблонов из {Total}",
|
||||
context.JobGroupName, context.JobGroupId, deactivated, existingTemplates.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Services.UnitFilterService;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class FilterUnitsStage : IGroupedSyncStage
|
||||
{
|
||||
private readonly IUnitFilterService _filterService;
|
||||
private readonly ILogger<FilterUnitsStage> _logger;
|
||||
|
||||
public string StageName => "Фильтрация юнитов";
|
||||
|
||||
public FilterUnitsStage(IUnitFilterService filterService, ILogger<FilterUnitsStage> logger)
|
||||
{
|
||||
_filterService = filterService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _filterService.GetUnitsByJobFilterAsync(context.MaxJob.Id, null, ct);
|
||||
|
||||
if (result == null || !result.Any())
|
||||
throw new GroupedSyncEarlyExitException("Фильтры не дали Unit'ов с подходящими связями");
|
||||
|
||||
context.FilteredUnits = result.ToList();
|
||||
context.UnitNames = result.ToDictionary(u => u.Id, u => u.Name);
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): отфильтровано {Count} юнитов",
|
||||
context.JobGroupName, context.JobGroupId, context.FilteredUnits.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class GroupFilterStage : IGroupedSyncStage
|
||||
{
|
||||
private readonly IGroupedTemplateUnitFilter _groupedFilter;
|
||||
private readonly ILogger<GroupFilterStage> _logger;
|
||||
|
||||
public string StageName => "Групповая фильтрация";
|
||||
|
||||
public GroupFilterStage(IGroupedTemplateUnitFilter groupedFilter, ILogger<GroupFilterStage> logger)
|
||||
{
|
||||
_groupedFilter = groupedFilter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var finalFiltered = await _groupedFilter.FilterAsync(context.FilteredUnits, context.JobGroup);
|
||||
|
||||
if (!finalFiltered.Any())
|
||||
throw new GroupedSyncEarlyExitException("Нет юнитов после групповой фильтрации");
|
||||
|
||||
context.FilteredUnits = finalFiltered;
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): после групповой фильтрации {Count} юнитов",
|
||||
context.JobGroupName, context.JobGroupId, finalFiltered.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using PARR.Core.Services.UnitFilterService.Models;
|
||||
using PARR.Domain.Entities.Base.History;
|
||||
using PARR.Domain.Entities.Job;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
/// <summary>
|
||||
/// Контекст групповой синхронизации. Передаётся между этапами.
|
||||
/// </summary>
|
||||
public class GroupedSyncContext
|
||||
{
|
||||
public Guid JobGroupId { get; init; }
|
||||
public string JobGroupName { get; set; } = string.Empty;
|
||||
public HistoryInitiator Initiator { get; init; } = null!;
|
||||
public JobGroup JobGroup { get; set; } = null!;
|
||||
public List<Job> JobsInGroup { get; set; } = new();
|
||||
public Job MaxJob { get; set; } = null!;
|
||||
public List<UnitFilterResultDto> FilteredUnits { get; set; } = new();
|
||||
public Dictionary<Guid, List<Guid>> ReverseMapping { get; set; } = new();
|
||||
public List<GroupedTemplateGroup> TemplateGroups { get; set; } = new();
|
||||
public HashSet<(Guid JobId, Guid UnitId, int Index)> ExpectedTemplateKeys { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Имена юнитов для логирования. Заполняется на этапе фильтрации.
|
||||
/// </summary>
|
||||
public Dictionary<Guid, string> UnitNames { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает строку вида 'Имя' (ID) для логирования.
|
||||
/// </summary>
|
||||
public string FormatUnit(Guid unitId)
|
||||
{
|
||||
return UnitNames.TryGetValue(unitId, out var name)
|
||||
? $"'{name}' ({unitId})"
|
||||
: $"({unitId})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync
|
||||
{
|
||||
/// <summary>
|
||||
/// Штатное прерывание пайплайна (нет данных после этапа).
|
||||
/// Не является ошибкой — оркестратор перехватывает и логирует как нормальное завершение.
|
||||
/// </summary>
|
||||
public class GroupedSyncEarlyExitException : Exception
|
||||
{
|
||||
public string Reason { get; }
|
||||
|
||||
public GroupedSyncEarlyExitException(string reason) : base(reason)
|
||||
{
|
||||
Reason = reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Repositories.Interfaces.Unit;
|
||||
using PARR.Core.Services.Shortcodes;
|
||||
using PARR.Domain.Constants;
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.Job;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
|
||||
{
|
||||
private readonly ILogger<GroupedTemplateBuilder> logger;
|
||||
private readonly IUnitInValueRepository unitInValueRepository;
|
||||
private readonly IUnitFieldRepository unitFieldRepository;
|
||||
private readonly IShortcodesService shortcodesService;
|
||||
|
||||
public GroupedTemplateBuilder(
|
||||
ILogger<GroupedTemplateBuilder> logger,
|
||||
IUnitInValueRepository unitInValueRepository,
|
||||
IUnitFieldRepository unitFieldRepository,
|
||||
IShortcodesService shortcodesService
|
||||
)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.unitInValueRepository = unitInValueRepository;
|
||||
this.unitFieldRepository = unitFieldRepository;
|
||||
this.shortcodesService = shortcodesService;
|
||||
}
|
||||
|
||||
public async Task<List<GroupedTemplateGroup>> BuildAsync(
|
||||
Dictionary<Guid, List<Guid>> initialReverseMapping,
|
||||
JobGroup jobGroup,
|
||||
Job maxJob,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
logger.LogDebug("Начало построения структуры групп для JobGroup {JobGroupId}.", jobGroup.Id);
|
||||
|
||||
if (!initialReverseMapping.Any())
|
||||
return new List<GroupedTemplateGroup>();
|
||||
|
||||
// 1. Определяем стратегию внутренней группировки
|
||||
// Если IsGroupByResponsible != true, используем WorkGroupMask через ShortcodesService
|
||||
bool useWorkGroupMask = jobGroup.IsGroupByResponsible != true;
|
||||
|
||||
logger.LogDebug("Стратегия внутренней группировки: {Strategy}",
|
||||
useWorkGroupMask ? "WorkGroupMask" : "Поле 'Ответственный за ЭК'");
|
||||
|
||||
// 2. Собираем все исходные UnitId
|
||||
var allSourceUnitIds = initialReverseMapping.Values.SelectMany(ids => ids).Distinct().ToList();
|
||||
|
||||
// 3. Загружаем UnitInValue для трансформации по полю группировки из настроек JobGroup
|
||||
var groupingFieldId = jobGroup.GroupingUnitFieldId!.Value;
|
||||
var relevantUnitInValues = await unitInValueRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Where(uiv => allSourceUnitIds.Contains(uiv.UnitId) && uiv.FieldId == groupingFieldId)
|
||||
.OrderBy(uiv => uiv.UnitId)
|
||||
.ThenBy(uiv => uiv.ValueId)
|
||||
.Select(uiv => new { uiv.UnitId, uiv.ValueId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var uivLookup = relevantUnitInValues
|
||||
.GroupBy(x => x.UnitId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.ValueId).ToList());
|
||||
|
||||
// 4. Трансформируем в reverseMapping с парами
|
||||
var reverseMapping = new Dictionary<Guid, List<(Guid UnitId, Guid UnitFieldValueId)>>();
|
||||
|
||||
foreach (var kvp in initialReverseMapping.OrderBy(k => k.Key))
|
||||
{
|
||||
var potentialUnitId = kvp.Key;
|
||||
var sourceDtoIds = kvp.Value;
|
||||
var entries = new List<(Guid UnitId, Guid UnitFieldValueId)>();
|
||||
|
||||
foreach (var dtoId in sourceDtoIds)
|
||||
{
|
||||
if (uivLookup.TryGetValue(dtoId, out var valueIds))
|
||||
{
|
||||
foreach (Guid valId in valueIds)
|
||||
entries.Add((UnitId: dtoId, UnitFieldValueId: valId));
|
||||
}
|
||||
}
|
||||
|
||||
var uniqueEntries = entries
|
||||
.GroupBy(e => (e.UnitId, e.UnitFieldValueId))
|
||||
.Select(g => g.First())
|
||||
.OrderBy(e => e.UnitId)
|
||||
.ThenBy(e => e.UnitFieldValueId)
|
||||
.ToList();
|
||||
|
||||
if (uniqueEntries.Any())
|
||||
reverseMapping[potentialUnitId] = uniqueEntries;
|
||||
}
|
||||
|
||||
if (!reverseMapping.Any())
|
||||
return new List<GroupedTemplateGroup>();
|
||||
|
||||
// 5. Определяем значения для внутренней группировки
|
||||
Dictionary<Guid, string> unitIdToGroupingValueMap;
|
||||
|
||||
if (useWorkGroupMask)
|
||||
{
|
||||
// Используем ShortcodesService для получения финальных значений WorkGroupMask
|
||||
unitIdToGroupingValueMap = new Dictionary<Guid, string>();
|
||||
|
||||
foreach (var potentialUnitId in reverseMapping.Keys)
|
||||
{
|
||||
var relatedUnitIds = reverseMapping[potentialUnitId].Select(e => e.UnitId).Distinct().ToList();
|
||||
|
||||
foreach (var relatedUnitId in relatedUnitIds)
|
||||
{
|
||||
// Создаем временный Template для применения шорткодов
|
||||
var tempTemplate = new Template
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "temp",
|
||||
JobId = maxJob.Id,
|
||||
UnitId = potentialUnitId, // Родительский юнит шаблона
|
||||
Job = maxJob,
|
||||
UnitsInTemplate = new List<UnitsInTemplate>
|
||||
{
|
||||
new UnitsInTemplate { UnitId = relatedUnitId, UnitFieldValueId = Guid.Empty }
|
||||
}
|
||||
};
|
||||
|
||||
var workGroupValue = await shortcodesService.ApplyShortcodesAsync(
|
||||
maxJob.WorkGroupMask,
|
||||
tempTemplate,
|
||||
nameof(GroupedTemplateBuilder));
|
||||
|
||||
unitIdToGroupingValueMap[relatedUnitId] = workGroupValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Логика разделения по ответственному за ЭК через служебный код поля
|
||||
var innerGroupingField = await unitFieldRepository.GetByCodeAsync(UnitFieldCodes.Responsible, ct)
|
||||
?? throw new InvalidOperationException($"Поле с кодом '{UnitFieldCodes.Responsible}' не найдено в справочнике UnitField.");
|
||||
|
||||
var innerGroupingFieldId = innerGroupingField.Id;
|
||||
var allUnitsInTemplatePairs = reverseMapping.Values.SelectMany(list => list).ToList();
|
||||
var allUnitIdsForInnerGrouping = allUnitsInTemplatePairs.Select(e => e.UnitId).Distinct().ToList();
|
||||
|
||||
var innerGroupingValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(
|
||||
allUnitIdsForInnerGrouping,
|
||||
new HashSet<Guid> { innerGroupingFieldId },
|
||||
ct);
|
||||
|
||||
unitIdToGroupingValueMap = innerGroupingValues
|
||||
.Where(uv => uv.Value != null && uv.Value.Value != null && !string.IsNullOrEmpty(uv.Value.Value))
|
||||
.ToDictionary(uv => uv.UnitId, uv => uv.Value!.Value!);
|
||||
}
|
||||
|
||||
// 6. Формируем итоговую структуру
|
||||
var templateGroups = new List<GroupedTemplateGroup>();
|
||||
int maxValueForSplitting = maxJob.MaxValueRelationships!.Value;
|
||||
|
||||
foreach (var kvp in reverseMapping.OrderBy(k => k.Key))
|
||||
{
|
||||
var potentialUnitId = kvp.Key;
|
||||
var unitsInTemplateForThisPotentialUnitId = kvp.Value;
|
||||
|
||||
var innerGroupedUnits = unitsInTemplateForThisPotentialUnitId
|
||||
.GroupBy(entry => unitIdToGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных"))
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var subGroups = new List<GroupedTemplateSubGroup>();
|
||||
int globalIndex = 1;
|
||||
|
||||
foreach (var innerGroup in innerGroupedUnits)
|
||||
{
|
||||
var innerGroupName = innerGroup.Key;
|
||||
if (innerGroupName == null)
|
||||
continue;
|
||||
|
||||
var unitsInInnerGroup = innerGroup.ToList();
|
||||
|
||||
var splitSubGroups = unitsInInnerGroup
|
||||
.Select((entry, index) => new { entry, groupIndex = index / maxValueForSplitting })
|
||||
.GroupBy(x => x.groupIndex)
|
||||
.Select(g => g.Select(x => x.entry).ToList())
|
||||
.ToList();
|
||||
|
||||
foreach (var subGroupEntries in splitSubGroups)
|
||||
{
|
||||
var sortedEntries = subGroupEntries
|
||||
.OrderBy(e => e.UnitId)
|
||||
.ThenBy(e => e.UnitFieldValueId)
|
||||
.ToList();
|
||||
|
||||
subGroups.Add(new GroupedTemplateSubGroup(
|
||||
Entries: sortedEntries,
|
||||
InnerGroupName: innerGroupName,
|
||||
GlobalIndex: globalIndex
|
||||
));
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (subGroups.Any())
|
||||
{
|
||||
templateGroups.Add(new GroupedTemplateGroup(
|
||||
PotentialUnitId: potentialUnitId,
|
||||
SubGroups: subGroups
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("Построено {Count} групп шаблонов.", templateGroups.Count);
|
||||
return templateGroups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Common.Interfaces.RabbitServices;
|
||||
using PARR.Core.Repositories.Interfaces;
|
||||
using PARR.Core.Repositories.Interfaces.Unit;
|
||||
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.Base.History;
|
||||
using PARR.Domain.Entities.Job;
|
||||
using PARR.Domain.Enums;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
using PARR.TemplateMatcher.Services.Interfaces;
|
||||
using PARR.TemplateMatcher.Settings;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
internal class GroupedTemplateProcessor : IGroupedTemplateProcessor
|
||||
{
|
||||
private readonly ILogger<GroupedTemplateProcessor> logger;
|
||||
private readonly ITemplateRepository templateRepository;
|
||||
private readonly IUnitRepository unitRepository;
|
||||
private readonly ITemplateNameNormalizer templateNameNormalizer;
|
||||
private readonly ITemplateAllocationService templateAllocationService;
|
||||
private readonly ITemplateMqPublisher templateMqPublisher;
|
||||
|
||||
public GroupedTemplateProcessor(
|
||||
ILogger<GroupedTemplateProcessor> logger,
|
||||
ITemplateRepository templateRepository,
|
||||
IUnitRepository unitRepository,
|
||||
ITemplateNameNormalizer templateNameNormalizer,
|
||||
ITemplateAllocationService templateAllocationService,
|
||||
ITemplateMqPublisher templateMqPublisher,
|
||||
MqSettings mqSettings,
|
||||
IRabbitService mqService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.templateRepository = templateRepository;
|
||||
this.unitRepository = unitRepository;
|
||||
this.templateNameNormalizer = templateNameNormalizer;
|
||||
this.templateAllocationService = templateAllocationService;
|
||||
this.templateMqPublisher = templateMqPublisher;
|
||||
}
|
||||
|
||||
public async Task<HashSet<(Guid JobId, Guid UnitId, int Index)>> ProcessAsync(
|
||||
List<GroupedTemplateGroup> groups,
|
||||
List<Job> jobsInGroup,
|
||||
Job maxJob,
|
||||
HistoryInitiator initiator,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var expectedTemplateKeys = new HashSet<(Guid JobId, Guid UnitId, int Index)>();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var potentialUnitId = group.PotentialUnitId;
|
||||
|
||||
foreach (var subGroup in group.SubGroups)
|
||||
{
|
||||
var unitsInTemplateSubGroup = subGroup.Entries;
|
||||
var globalIndex = subGroup.GlobalIndex;
|
||||
var originatingInnerGroupName = subGroup.InnerGroupName;
|
||||
|
||||
logger.LogDebug("Обработка подгруппы {Index} ('{GroupingValue}') для UnitId {PotentialUnitId}, размер {Size}.",
|
||||
globalIndex, originatingInnerGroupName, potentialUnitId, unitsInTemplateSubGroup.Count);
|
||||
|
||||
Job targetJob = SelectTargetJob(jobsInGroup, unitsInTemplateSubGroup.Count, maxJob);
|
||||
expectedTemplateKeys.Add((targetJob.Id, potentialUnitId, globalIndex));
|
||||
|
||||
// Поиск существующего шаблона
|
||||
var existingTemplate = await templateRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Unit)
|
||||
.Include(t => t.Job).ThenInclude(t => t!.Tnk)
|
||||
.Include(t => t.Job).ThenInclude(t => t!.Group).ThenInclude(t => t!.GroupType)
|
||||
.Include(t => t.UnitsInTemplate).ThenInclude(uit => uit.Unit)
|
||||
.Where(t => t.JobId == targetJob.Id &&
|
||||
t.UnitId == potentialUnitId &&
|
||||
t.Index == globalIndex &&
|
||||
t.StatusTypeId == TemplateStatusTypeEnum.Used)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (existingTemplate != null)
|
||||
{
|
||||
await HandleExistingTemplateAsync(existingTemplate, unitsInTemplateSubGroup, targetJob, globalIndex, initiator, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleNewOrReusableTemplateAsync(potentialUnitId, unitsInTemplateSubGroup, targetJob, globalIndex, initiator, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expectedTemplateKeys;
|
||||
}
|
||||
|
||||
private async Task HandleExistingTemplateAsync(
|
||||
Template existingTemplate,
|
||||
List<(Guid UnitId, Guid UnitFieldValueId)> proposedEntries,
|
||||
Job targetJob,
|
||||
int globalIndex,
|
||||
HistoryInitiator initiator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var currentEntries = existingTemplate.UnitsInTemplate
|
||||
.Select(uit => (uit.UnitId, uit.UnitFieldValueId))
|
||||
.ToList();
|
||||
|
||||
// Сравнение
|
||||
var allUnitIdsForSort = currentEntries.Select(e => e.UnitId)
|
||||
.Concat(proposedEntries.Select(e => e.UnitId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var unitNamesForSort = await unitRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Where(u => allUnitIdsForSort.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString(), ct);
|
||||
|
||||
var sortedCurrent = currentEntries
|
||||
.OrderBy(e => unitNamesForSort.GetValueOrDefault(e.UnitId, e.UnitId.ToString()))
|
||||
.ThenBy(e => e.UnitFieldValueId)
|
||||
.ToList();
|
||||
|
||||
var sortedProposed = proposedEntries
|
||||
.OrderBy(e => unitNamesForSort.GetValueOrDefault(e.UnitId, e.UnitId.ToString()))
|
||||
.ThenBy(e => e.UnitFieldValueId)
|
||||
.ToList();
|
||||
|
||||
bool unitsAreEqual = sortedCurrent.SequenceEqual(sortedProposed);
|
||||
|
||||
if (unitsAreEqual)
|
||||
{
|
||||
logger.LogDebug("Шаблон {TemplateId} актуален по составу.", existingTemplate.Id);
|
||||
|
||||
// Проверка имени
|
||||
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(existingTemplate);
|
||||
if (!string.Equals(existingTemplate.Name, expectedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogInformation("Шаблон {TemplateId} требует обновления имени.", existingTemplate.Id);
|
||||
var updateRequest = new TemplateUpdaterMessage
|
||||
{
|
||||
TemplateId = existingTemplate.Id,
|
||||
JobId = targetJob.Id,
|
||||
UnitId = existingTemplate.UnitId,
|
||||
Name = expectedName,
|
||||
IsActiveTemplate = existingTemplate.IsActiveTemplate,
|
||||
IsActiveSchedule = existingTemplate.IsActiveSchedule,
|
||||
IsNew = false,
|
||||
Index = globalIndex,
|
||||
StatusTypeId = TemplateStatusTypeEnum.Used,
|
||||
Initiator = initiator,
|
||||
UnitsInTemplate = sortedProposed.Select(t => new UnitInTemplateMessage
|
||||
{
|
||||
UnitId = t.UnitId,
|
||||
UnitFieldValueId = t.UnitFieldValueId
|
||||
}).ToList()
|
||||
};
|
||||
await templateMqPublisher.PublishUpdateAsync(updateRequest);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Шаблон {TemplateId} требует обновления состава.", existingTemplate.Id);
|
||||
await UpdateTemplateUnitsAsync(existingTemplate, sortedProposed, targetJob, globalIndex, initiator);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewOrReusableTemplateAsync(
|
||||
Guid potentialUnitId,
|
||||
List<(Guid UnitId, Guid UnitFieldValueId)> unitsInTemplateSubGroup,
|
||||
Job targetJob,
|
||||
int globalIndex,
|
||||
HistoryInitiator initiator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Вычисляем флаги активности
|
||||
var isActiveTemplate = targetJob.AutoControl?.InitUsedTemplateState ?? false;
|
||||
var isActiveSchedule = targetJob.AutoControl?.InitUsedScheduleState ?? false;
|
||||
|
||||
// Маппим кортежи в сообщения
|
||||
var unitsInTemplateMsg = unitsInTemplateSubGroup
|
||||
.Select(e => new UnitInTemplateMessage
|
||||
{
|
||||
UnitId = e.UnitId,
|
||||
UnitFieldValueId = e.UnitFieldValueId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var request = new TemplateAllocationRequest(
|
||||
TargetJob: targetJob,
|
||||
TargetUnitId: potentialUnitId,
|
||||
TargetUnit: null,
|
||||
Index: globalIndex,
|
||||
UnitsInTemplate: unitsInTemplateMsg,
|
||||
IsActiveTemplate: isActiveTemplate,
|
||||
IsActiveSchedule: isActiveSchedule,
|
||||
Initiator: initiator);
|
||||
|
||||
await templateAllocationService.AllocateAsync(request, ct);
|
||||
}
|
||||
|
||||
private async Task UpdateTemplateUnitsAsync(
|
||||
Template template,
|
||||
List<(Guid UnitId, Guid UnitFieldValueId)> newUnitEntries,
|
||||
Job targetJob,
|
||||
int newIndex,
|
||||
HistoryInitiator initiator)
|
||||
{
|
||||
// 1. Устанавливаем статус и дату
|
||||
template.StatusTypeId = TemplateStatusTypeEnum.Updating;
|
||||
template.DateModified = DateTimeOffset.UtcNow;
|
||||
|
||||
// 2. КОММИТ В БАЗУ СРАЗУ
|
||||
// Важно зафиксировать изменение статуса до отправки сообщения в очередь
|
||||
if (!await templateRepository.CommitAsync(initiator))
|
||||
{
|
||||
logger.LogError("Не удалось перевести шаблон {TemplateId} в Updating.", template.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Формируем временный объект для генерации имени
|
||||
var tempTemplateForName = new Template
|
||||
{
|
||||
Id = template.Id,
|
||||
Name = template.Name,
|
||||
JobId = targetJob.Id,
|
||||
UnitId = template.UnitId,
|
||||
Index = newIndex,
|
||||
Job = targetJob,
|
||||
Unit = template.Unit,
|
||||
UnitsInTemplate = newUnitEntries.Select(e => new UnitsInTemplate { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList()
|
||||
};
|
||||
|
||||
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName);
|
||||
|
||||
// 4. Отправляем сообщение в очередь
|
||||
var updateRequest = new TemplateUpdaterMessage
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
JobId = targetJob.Id,
|
||||
UnitId = template.UnitId,
|
||||
Name = expectedName,
|
||||
IsActiveTemplate = template.IsActiveTemplate,
|
||||
IsActiveSchedule = template.IsActiveSchedule,
|
||||
IsNew = false,
|
||||
Index = newIndex,
|
||||
StatusTypeId = TemplateStatusTypeEnum.Used,
|
||||
Initiator = initiator,
|
||||
UnitsInTemplate = newUnitEntries.Select(e => new UnitInTemplateMessage { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList()
|
||||
};
|
||||
|
||||
await templateMqPublisher.PublishUpdateAsync(updateRequest);
|
||||
}
|
||||
|
||||
|
||||
private static Job SelectTargetJob(List<Job> jobsInGroup, int subGroupSize, Job maxJob)
|
||||
{
|
||||
Job? targetJob = jobsInGroup
|
||||
.Where(j => j.MaxValueRelationships.HasValue && j.MaxValueRelationships.Value == subGroupSize)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (targetJob == null)
|
||||
{
|
||||
targetJob = jobsInGroup
|
||||
.Where(j => j.MaxValueRelationships.HasValue && j.MaxValueRelationships.Value >= subGroupSize)
|
||||
.OrderBy(j => j.MaxValueRelationships!.Value)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
return targetJob ?? maxJob;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Repositories.Interfaces.Unit;
|
||||
using PARR.Core.Services.UnitFilterService.Models;
|
||||
using PARR.Domain.Entities.Job;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
internal class GroupedTemplateUnitFilter : IGroupedTemplateUnitFilter
|
||||
{
|
||||
private readonly ILogger<GroupedTemplateUnitFilter> logger;
|
||||
private readonly IUnitInValueRepository unitInValueRepository;
|
||||
private readonly IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository;
|
||||
private readonly IUnitFieldRepository unitFieldRepository;
|
||||
|
||||
public GroupedTemplateUnitFilter(
|
||||
ILogger<GroupedTemplateUnitFilter> logger,
|
||||
IUnitInValueRepository unitInValueRepository,
|
||||
IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository,
|
||||
IUnitFieldRepository unitFieldRepository)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.unitInValueRepository = unitInValueRepository;
|
||||
this.regionalEkPtkGroupRepository = regionalEkPtkGroupRepository;
|
||||
this.unitFieldRepository = unitFieldRepository;
|
||||
}
|
||||
|
||||
public async Task<List<UnitFilterResultDto>> FilterAsync(
|
||||
IEnumerable<UnitFilterResultDto> initialUnits,
|
||||
JobGroup jobGroup,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var unitsList = initialUnits.ToList();
|
||||
if (!unitsList.Any())
|
||||
{
|
||||
logger.LogDebug("Входной список юнитов пуст. Фильтрация пропущена.");
|
||||
return unitsList;
|
||||
}
|
||||
|
||||
logger.LogDebug("Начало фильтрации юнитов для JobGroup {JobGroupId}.", jobGroup.Id);
|
||||
|
||||
// 1. Проверка наличия GroupingUnitFieldId
|
||||
if (!jobGroup.GroupingUnitFieldId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"JobGroup {jobGroup.Id} не имеет GroupingUnitFieldId, необходимого для группировки.");
|
||||
}
|
||||
|
||||
var groupingFieldId = jobGroup.GroupingUnitFieldId.Value;
|
||||
logger.LogDebug("Фильтрация по GroupingUnitFieldId (FieldId={FieldId}).", groupingFieldId);
|
||||
|
||||
// 2. Фильтрация по GroupingUnitFieldId
|
||||
var allUnitIds = unitsList.Select(u => u.Id).ToList();
|
||||
var groupingValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(allUnitIds, new HashSet<Guid> { groupingFieldId });
|
||||
|
||||
var validUnitIdsAfterGrouping = groupingValues
|
||||
.Where(uv => uv.Value != null && !string.IsNullOrEmpty(uv.Value.Value))
|
||||
.Select(uv => uv.UnitId)
|
||||
.ToHashSet();
|
||||
|
||||
var filteredByGrouping = unitsList
|
||||
.Where(u => validUnitIdsAfterGrouping.Contains(u.Id))
|
||||
.ToList();
|
||||
|
||||
logger.LogDebug("После фильтрации по GroupingUnitFieldId осталось {Count} юнитов.", filteredByGrouping.Count);
|
||||
|
||||
if (!filteredByGrouping.Any())
|
||||
{
|
||||
return filteredByGrouping;
|
||||
}
|
||||
|
||||
// 3. Фильтрация по РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК
|
||||
var workGroupField = await unitFieldRepository.GetByAihitNameAsync("РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК")
|
||||
?? throw new InvalidOperationException("Поле 'РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК' не найдено в справочнике полей.");
|
||||
|
||||
var workGroupFieldId = workGroupField.Id;
|
||||
logger.LogDebug("Фильтрация по полю 'РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК' (FieldId={FieldId}).", workGroupFieldId);
|
||||
|
||||
var filteredUnitIds = filteredByGrouping.Select(u => u.Id).ToList();
|
||||
var workGroupValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(filteredUnitIds, new HashSet<Guid> { workGroupFieldId });
|
||||
|
||||
var allowedValueIds = regionalEkPtkGroupRepository.Get()
|
||||
.Select(g => g.FieldValueId)
|
||||
.ToHashSet();
|
||||
|
||||
logger.LogDebug("Найдено {Count} разрешенных значений для поля 'РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК'.", allowedValueIds.Count);
|
||||
|
||||
var validUnitIdsAfterWorkGroup = workGroupValues
|
||||
.Where(uv => uv.Value != null && allowedValueIds.Contains(uv.Value.Id))
|
||||
.Select(uv => uv.UnitId)
|
||||
.ToHashSet();
|
||||
|
||||
var finalFiltered = filteredByGrouping
|
||||
.Where(u => validUnitIdsAfterWorkGroup.Contains(u.Id))
|
||||
.ToList();
|
||||
|
||||
logger.LogDebug("После фильтрации по 'РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК' осталось {Count} юнитов.", finalFiltered.Count);
|
||||
|
||||
return finalFiltered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync
|
||||
{
|
||||
/// <summary>
|
||||
/// Этап групповой синхронизации, который только читает данные.
|
||||
/// НЕ выполняет запись в БД, MQ или кэш.
|
||||
/// </summary>
|
||||
public interface IGroupedSyncStage
|
||||
{
|
||||
string StageName { get; }
|
||||
Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync
|
||||
{
|
||||
/// <summary>
|
||||
/// Этап групповой синхронизации с побочными эффектами (запись в БД, MQ).
|
||||
/// В тестах не подключается — тип системы гарантирует безопасность.
|
||||
/// </summary>
|
||||
public interface IGroupedSyncWriteStage : IGroupedSyncStage { }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using PARR.Domain.Entities.Job;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
public interface IGroupedTemplateBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Строит структуру групп для создания групповых шаблонов.
|
||||
/// Трансформирует связи в пары (UnitId, UnitFieldValueId),
|
||||
/// выполняет внутреннюю группировку и разбиение на подгруппы.
|
||||
/// </summary>
|
||||
Task<List<GroupedTemplateGroup>> BuildAsync(
|
||||
Dictionary<Guid, List<Guid>> initialReverseMapping,
|
||||
JobGroup jobGroup,
|
||||
Job maxJob,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using PARR.Domain.Entities.Base.History;
|
||||
using PARR.Domain.Entities.Job;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
public interface IGroupedTemplateProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Обрабатывает построенные группы шаблонов:
|
||||
/// 1. Ищет существующие шаблоны.
|
||||
/// 2. Сравнивает состав юнитов.
|
||||
/// 3. Обновляет, переиспользует или создает новые шаблоны.
|
||||
/// 4. Возвращает набор ключей ожидаемых шаблонов для последующей деактивации лишних.
|
||||
/// </summary>
|
||||
Task<HashSet<(Guid JobId, Guid UnitId, int Index)>> ProcessAsync(
|
||||
List<GroupedTemplateGroup> groups,
|
||||
List<Job> jobsInGroup,
|
||||
Job maxJob,
|
||||
HistoryInitiator initiator,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using PARR.Core.Services.UnitFilterService.Models;
|
||||
using PARR.Domain.Entities.Job;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync
|
||||
{
|
||||
public interface IGroupedTemplateUnitFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Применяет специфичные правила фильтрации для групповых шаблонов.
|
||||
/// 1. Отбирает юниты, имеющие заполненное значение в GroupingUnitFieldId.
|
||||
/// 2. Оставляет только юниты, значение которых в поле РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК разрешено.
|
||||
/// </summary>
|
||||
Task<List<UnitFilterResultDto>> FilterAsync(
|
||||
IEnumerable<UnitFilterResultDto> initialUnits,
|
||||
JobGroup jobGroup,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using PARR.Core.Services.UnitFilterService.Models;
|
||||
using PARR.Domain.Entities.Job;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.GroupedSync
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Repositories.Interfaces.Job;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class LoadJobGroupStage : IGroupedSyncStage
|
||||
{
|
||||
private readonly IJobGroupRepository _jobGroupRepository;
|
||||
private readonly ILogger<LoadJobGroupStage> _logger;
|
||||
|
||||
public string StageName => "Загрузка JobGroup";
|
||||
|
||||
public LoadJobGroupStage(IJobGroupRepository jobGroupRepository, ILogger<LoadJobGroupStage> logger)
|
||||
{
|
||||
_jobGroupRepository = jobGroupRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var jobGroup = await _jobGroupRepository.Get()
|
||||
.AsNoTracking()
|
||||
.AsSingleQuery()
|
||||
.Include(jg => jg.GroupType)
|
||||
.Include(jg => jg.Jobs).ThenInclude(j => j.AutoControl)
|
||||
.Include(jg => jg.Jobs).ThenInclude(j => j.UnitFilters)
|
||||
.ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.Include(jg => jg.Jobs).ThenInclude(jg => jg.Tnk)
|
||||
.FirstOrDefaultAsync(jg => jg.Id == context.JobGroupId, ct);
|
||||
|
||||
if (jobGroup == null || jobGroup.Jobs == null || !jobGroup.Jobs.Any())
|
||||
throw new GroupedSyncEarlyExitException("JobGroup не найден или пуст");
|
||||
|
||||
var jobsInGroup = jobGroup.Jobs.ToList();
|
||||
var maxJob = jobsInGroup
|
||||
.Where(j => j.MaxValueRelationships.HasValue)
|
||||
.OrderByDescending(j => j.MaxValueRelationships)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (maxJob == null)
|
||||
throw new GroupedSyncEarlyExitException("Не найден Job с MaxValueRelationships");
|
||||
|
||||
context.JobGroup = jobGroup;
|
||||
context.JobGroupName = jobGroup.GroupName;
|
||||
context.JobsInGroup = jobsInGroup;
|
||||
context.MaxJob = maxJob;
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): загружено {JobCount} Job'ов, эталонный Job '{MaxJobName}' ({MaxJobId})",
|
||||
jobGroup.GroupName, jobGroup.Id, jobsInGroup.Count, maxJob.Name, maxJob.Id);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.TemplateMatcher.Models;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class ProcessGroupsStage : IGroupedSyncWriteStage
|
||||
{
|
||||
private readonly IGroupedTemplateProcessor _processor;
|
||||
private readonly ILogger<ProcessGroupsStage> _logger;
|
||||
|
||||
public string StageName => "Обработка групп";
|
||||
|
||||
public ProcessGroupsStage(IGroupedTemplateProcessor processor, ILogger<ProcessGroupsStage> logger)
|
||||
{
|
||||
_processor = processor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
// Приведение типа обратно из object — пайплайн хранит как object для универсальности контекста
|
||||
var typedGroups = context.TemplateGroups
|
||||
.Cast<GroupedTemplateGroup>()
|
||||
.ToList();
|
||||
|
||||
var expectedKeys = await _processor.ProcessAsync(
|
||||
typedGroups, context.JobsInGroup, context.MaxJob, context.Initiator);
|
||||
|
||||
context.ExpectedTemplateKeys = expectedKeys;
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): обработано групп, ожидаемых ключей={Count}",
|
||||
context.JobGroupName, context.JobGroupId, expectedKeys.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.TemplateMatcher.Services.GroupedSync;
|
||||
|
||||
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
|
||||
|
||||
internal class ResolveConflictsStage : IGroupedSyncStage
|
||||
{
|
||||
private readonly IUnitInTemplateConflictMapper _conflictMapper;
|
||||
private readonly ILogger<ResolveConflictsStage> _logger;
|
||||
|
||||
public string StageName => "Разрешение конфликтов";
|
||||
|
||||
public ResolveConflictsStage(IUnitInTemplateConflictMapper conflictMapper, ILogger<ResolveConflictsStage> logger)
|
||||
{
|
||||
_conflictMapper = conflictMapper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
|
||||
{
|
||||
var mapping = await _conflictMapper.BuildMappingAsync(context.FilteredUnits, context.MaxJob, ct);
|
||||
|
||||
if (!mapping.Any())
|
||||
throw new GroupedSyncEarlyExitException("Нет связей после разрешения конфликтов");
|
||||
|
||||
context.ReverseMapping = mapping;
|
||||
|
||||
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): разрешено конфликтов, связей={Count}",
|
||||
context.JobGroupName, context.JobGroupId, mapping.Count);
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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.GroupedSync;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user