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:
Mikhail Kuznetsov
2026-06-11 15:09:46 +10:00
parent 16f4944a62
commit a438f18480
44 changed files with 1124 additions and 525 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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})";
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
namespace PARR.TemplateMatcher.Services.GroupedSync
{
/// <summary>
/// Этап групповой синхронизации с побочными эффектами (запись в БД, MQ).
/// В тестах не подключается — тип системы гарантирует безопасность.
/// </summary>
public interface IGroupedSyncWriteStage : IGroupedSyncStage { }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}