From 83c6a5f605cd7fcd615d8fd9a8faf689dcd5cc75 Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Wed, 9 Sep 2026 17:09:57 +1000 Subject: [PATCH] =?UTF-8?q?feat(templateMatcher):=20-=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20DryRun=20-=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=85=D0=BE=D0=B4=20=D0=BA=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=B9=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D0=B5=20-=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B8=D0=B7=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D1=8D=D1=82=D0=B0=D0=BF=20=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=80?= =?UTF-8?q?=D0=B3=D1=83=D0=BF=D0=BF/=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=20-=20=D0=BF=D1=80=D0=B8=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=BE=D0=B2=20=D0=B0=D0=BB=D1=8C=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D1=85=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D1=83=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Shortcodes/ShortcodesService.cs | 2 +- .../Services/GroupedSync/BuildGroupsStage.cs | 14 ++- .../GroupedSync/GroupedSyncContext.cs | 3 + .../GroupedSync/GroupedTemplateBuilder.cs | 93 +++++++++++--- .../GroupedSync/GroupedTemplateUnitFilter.cs | 49 ++------ .../GroupedSync/IGroupedTemplateBuilder.cs | 1 + .../GroupedTemplateSynchronizer.cs | 93 +++++++++++--- .../SimpleTemplateSynchronizer.cs | 119 ++++++++++++++---- .../Interfaces/ITemplateSynchronizer.cs | 6 +- .../Services/SimpleSync/SimpleSyncContext.cs | 4 + 10 files changed, 280 insertions(+), 104 deletions(-) diff --git a/PARR.Core/Services/Shortcodes/ShortcodesService.cs b/PARR.Core/Services/Shortcodes/ShortcodesService.cs index 90b7c946..280d21fe 100644 --- a/PARR.Core/Services/Shortcodes/ShortcodesService.cs +++ b/PARR.Core/Services/Shortcodes/ShortcodesService.cs @@ -278,7 +278,7 @@ internal class ShortcodesService : IShortcodesService { UnitName = unitName ?? string.Empty, Job = jobData, - UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate, + UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate ?? new List(), UnitTags = unitTags ?? currentData.UnitTags, RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags }; diff --git a/PARR.TemplateMatcher/Services/GroupedSync/BuildGroupsStage.cs b/PARR.TemplateMatcher/Services/GroupedSync/BuildGroupsStage.cs index e8ed1908..4e585e0c 100644 --- a/PARR.TemplateMatcher/Services/GroupedSync/BuildGroupsStage.cs +++ b/PARR.TemplateMatcher/Services/GroupedSync/BuildGroupsStage.cs @@ -18,15 +18,17 @@ internal class BuildGroupsStage : IGroupedSyncStage public async Task ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default) { - var groups = await _builder.BuildAsync(context.ReverseMapping, context.JobGroup, context.MaxJob); + var templateGroups = await _builder.BuildAsync( + context.ReverseMapping, + context.JobGroup, + context.MaxJob, + context.UnitNames, + ct); - if (!groups.Any()) - throw new GroupedSyncEarlyExitException("Нет данных после построения групп"); - - context.TemplateGroups = groups; + context.TemplateGroups = templateGroups; _logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): построено {Count} групп", - context.JobGroupName, context.JobGroupId, groups.Count); + context.JobGroupName, context.JobGroupId, templateGroups.Count); return context; } diff --git a/PARR.TemplateMatcher/Services/GroupedSync/GroupedSyncContext.cs b/PARR.TemplateMatcher/Services/GroupedSync/GroupedSyncContext.cs index bc3f332d..25b3f081 100644 --- a/PARR.TemplateMatcher/Services/GroupedSync/GroupedSyncContext.cs +++ b/PARR.TemplateMatcher/Services/GroupedSync/GroupedSyncContext.cs @@ -22,6 +22,9 @@ public class GroupedSyncContext public List TemplateGroups { get; set; } = new(); public HashSet<(Guid JobId, Guid UnitId, int Index)> ExpectedTemplateKeys { get; set; } = new(); + // Режим только для чтения — без записи в БД и отправки в MQ + public bool DryRun { get; set; } = false; + /// /// Имена юнитов для логирования. Заполняется на этапе фильтрации. /// diff --git a/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateBuilder.cs b/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateBuilder.cs index fa85da21..cd40b171 100644 --- a/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateBuilder.cs +++ b/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateBuilder.cs @@ -16,24 +16,28 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder private readonly IUnitInValueRepository unitInValueRepository; private readonly IUnitFieldRepository unitFieldRepository; private readonly IShortcodesService shortcodesService; + private readonly IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository; public GroupedTemplateBuilder( ILogger logger, IUnitInValueRepository unitInValueRepository, IUnitFieldRepository unitFieldRepository, - IShortcodesService shortcodesService + IShortcodesService shortcodesService, + IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository ) { this.logger = logger; this.unitInValueRepository = unitInValueRepository; this.unitFieldRepository = unitFieldRepository; this.shortcodesService = shortcodesService; + this.regionalEkPtkGroupRepository = regionalEkPtkGroupRepository; } public async Task> BuildAsync( Dictionary> initialReverseMapping, JobGroup jobGroup, Job maxJob, + Dictionary unitNames, CancellationToken ct = default) { logger.LogDebug("Начало построения структуры групп для JobGroup {JobGroupId}.", jobGroup.Id); @@ -42,7 +46,6 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder return new List(); // 1. Определяем стратегию внутренней группировки - // Если IsGroupByResponsible != true, используем WorkGroupMask через ShortcodesService bool useWorkGroupMask = jobGroup.IsGroupByResponsible != true; logger.LogDebug("Стратегия внутренней группировки: {Strategy}", @@ -102,22 +105,50 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder if (useWorkGroupMask) { - // Используем ShortcodesService для получения финальных значений WorkGroupMask + // Определяем, нужно ли выполнять фильтрацию по списку рабочих групп ПТК. + // Если маска содержит тег с "_в_ЗО_РГ", фильтрация не выполняется. + var skipRegionalFilter = maxJob.WorkGroupMask.Contains("_в_ЗО_РГ", StringComparison.OrdinalIgnoreCase); + + HashSet? allowedWorkGroupSet = null; + + if (!skipRegionalFilter) + { + // Загружаем разрешённые значения из regionalEkPtkGroupRepository + var allowedWorkGroupValues = await regionalEkPtkGroupRepository.Get() + .AsNoTracking() + .Include(g => g.FieldValue) + .Where(g => g.FieldValue != null && g.FieldValue.Value != null) + .Select(g => g.FieldValue!.Value!) + .ToListAsync(ct); + + allowedWorkGroupSet = new HashSet(allowedWorkGroupValues, StringComparer.OrdinalIgnoreCase); + + logger.LogDebug("Загружено {Count} разрешённых значений рабочих групп из RegionalEkPtkGroup.", allowedWorkGroupSet.Count); + } + else + { + logger.LogDebug("Маска содержит тег с '_в_ЗО_РГ'. Фильтрация по списку рабочих групп ПТК пропущена."); + } + + // Применяем WorkGroupMask для каждого юнита отдельно unitIdToGroupingValueMap = new Dictionary(); foreach (var potentialUnitId in reverseMapping.Keys) { - var relatedUnitIds = reverseMapping[potentialUnitId].Select(e => e.UnitId).Distinct().ToList(); + 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, // Родительский юнит шаблона + UnitId = potentialUnitId, Job = maxJob, UnitsInTemplate = new List { @@ -130,6 +161,27 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder tempTemplate, nameof(GroupedTemplateBuilder)); + if (string.IsNullOrEmpty(workGroupValue)) + { + logger.LogDebug( + "Юнит {UnitId} исключён: маска вернула пустую строку.", + relatedUnitId); + continue; + } + + // Фильтрация по списку рабочих групп ПТК выполняется только если не используется тег с "_в_ЗО_РГ" + if (!skipRegionalFilter && !allowedWorkGroupSet!.Contains(workGroupValue)) + { + logger.LogDebug( // Изменено с LogDebug на LogInformation для тестирования + "[FILTER] Юнит {UnitId} ИСКЛЮЧЁН: рабочая группа '{WorkGroup}' не найдена в списке разрешённых ({AllowedCount} значений).", + relatedUnitId, workGroupValue, allowedWorkGroupSet!.Count); + continue; + } + + logger.LogDebug( // Добавляем лог для успешного прохождения + "[FILTER] Юнит {UnitId} ПРОШЁЛ: рабочая группа '{WorkGroup}' для PotentialUnit {PotentialUnitId}.", + relatedUnitId, workGroupValue, potentialUnitId); + unitIdToGroupingValueMap[relatedUnitId] = workGroupValue; } } @@ -163,7 +215,18 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder var potentialUnitId = kvp.Key; var unitsInTemplateForThisPotentialUnitId = kvp.Value; - var innerGroupedUnits = unitsInTemplateForThisPotentialUnitId + // Фильтруем записи: оставляем только те, которые прошли проверку + var filteredUnits = unitsInTemplateForThisPotentialUnitId + .Where(entry => unitIdToGroupingValueMap.ContainsKey(entry.UnitId)) + .ToList(); + + if (!filteredUnits.Any()) + { + logger.LogDebug("PotentialUnitId {PotentialUnitId} исключён: нет юнитов после фильтрации.", potentialUnitId); + continue; + } + + var innerGroupedUnits = filteredUnits .GroupBy(entry => unitIdToGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных")) .OrderBy(g => g.Key, StringComparer.Ordinal) .ToList(); @@ -179,21 +242,23 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder var unitsInInnerGroup = innerGroup.ToList(); - var splitSubGroups = unitsInInnerGroup + // Сортируем по имени юнита, потом по UnitFieldValueId + var sortedUnitsInInnerGroup = unitsInInnerGroup + .OrderBy(e => unitNames.GetValueOrDefault(e.UnitId, e.UnitId.ToString())) + .ThenBy(e => e.UnitFieldValueId) + .ToList(); + + var splitSubGroups = sortedUnitsInInnerGroup .Select((entry, index) => new { entry, groupIndex = index / maxValueForSplitting }) .GroupBy(x => x.groupIndex) .Select(g => g.Select(x => x.entry).ToList()) .ToList(); + // ВАЖНО: добавляем подгруппы в subGroups foreach (var subGroupEntries in splitSubGroups) { - var sortedEntries = subGroupEntries - .OrderBy(e => e.UnitId) - .ThenBy(e => e.UnitFieldValueId) - .ToList(); - subGroups.Add(new GroupedTemplateSubGroup( - Entries: sortedEntries, + Entries: subGroupEntries, InnerGroupName: innerGroupName, GlobalIndex: globalIndex )); diff --git a/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateUnitFilter.cs b/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateUnitFilter.cs index 61bb2539..67dff6f7 100644 --- a/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateUnitFilter.cs +++ b/PARR.TemplateMatcher/Services/GroupedSync/GroupedTemplateUnitFilter.cs @@ -9,19 +9,13 @@ internal class GroupedTemplateUnitFilter : IGroupedTemplateUnitFilter { private readonly ILogger logger; private readonly IUnitInValueRepository unitInValueRepository; - private readonly IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository; - private readonly IUnitFieldRepository unitFieldRepository; public GroupedTemplateUnitFilter( ILogger logger, - IUnitInValueRepository unitInValueRepository, - IUnitRegionalEkPtkGroupRepository regionalEkPtkGroupRepository, - IUnitFieldRepository unitFieldRepository) + IUnitInValueRepository unitInValueRepository) { this.logger = logger; this.unitInValueRepository = unitInValueRepository; - this.regionalEkPtkGroupRepository = regionalEkPtkGroupRepository; - this.unitFieldRepository = unitFieldRepository; } public async Task> FilterAsync( @@ -47,53 +41,24 @@ internal class GroupedTemplateUnitFilter : IGroupedTemplateUnitFilter var groupingFieldId = jobGroup.GroupingUnitFieldId.Value; logger.LogDebug("Фильтрация по GroupingUnitFieldId (FieldId={FieldId}).", groupingFieldId); - // 2. Фильтрация по GroupingUnitFieldId + // 2. Загружаем значения поля группировки для всех юнитов var allUnitIds = unitsList.Select(u => u.Id).ToList(); - var groupingValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(allUnitIds, new HashSet { groupingFieldId }); + var groupingValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync( + allUnitIds, new HashSet { groupingFieldId }, ct); + // 3. Определяем юниты, у которых есть непустое значение поля группировки var validUnitIdsAfterGrouping = groupingValues .Where(uv => uv.Value != null && !string.IsNullOrEmpty(uv.Value.Value)) .Select(uv => uv.UnitId) .ToHashSet(); + // 4. Фильтруем исходный список 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 { 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; + return filteredByGrouping; } } \ No newline at end of file diff --git a/PARR.TemplateMatcher/Services/GroupedSync/IGroupedTemplateBuilder.cs b/PARR.TemplateMatcher/Services/GroupedSync/IGroupedTemplateBuilder.cs index 9ac61f6e..db866bc0 100644 --- a/PARR.TemplateMatcher/Services/GroupedSync/IGroupedTemplateBuilder.cs +++ b/PARR.TemplateMatcher/Services/GroupedSync/IGroupedTemplateBuilder.cs @@ -15,5 +15,6 @@ public interface IGroupedTemplateBuilder Dictionary> initialReverseMapping, JobGroup jobGroup, Job maxJob, + Dictionary unitNames, CancellationToken ct = default); } \ No newline at end of file diff --git a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs index f902253a..85e1670b 100644 --- a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateSynchronizer.cs @@ -30,9 +30,9 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer _logger = logger; } - public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator) + public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator, bool dryRun = false) { - _logger.LogInformation("Начало синхронизации шаблонов для JobGroup {JobGroupId}", jobGroupId); + _logger.LogInformation("Начало синхронизации шаблонов для JobGroup {JobGroupId} (DryRun={DryRun})", jobGroupId, dryRun); var existingStatus = await _matchingStatusService.GetStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); if (existingStatus.DetailsJobGroups?.Count > 0) @@ -41,14 +41,20 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer return; } - await SetStatusAsync(jobGroupId, "Начало синхронизации"); + await SetStatusAsync(jobGroupId, dryRun ? "DryRun: Начало анализа" : "Начало синхронизации"); var totalSw = Stopwatch.StartNew(); try { - var context = new GroupedSyncContext { JobGroupId = jobGroupId, Initiator = initiator }; + var context = new GroupedSyncContext + { + JobGroupId = jobGroupId, + Initiator = initiator, + DryRun = dryRun + }; + // Read-этапы выполняются всегда foreach (var stage in _readStages) { var stageSw = Stopwatch.StartNew(); @@ -58,23 +64,34 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer context.JobGroupName, jobGroupId, stage.StageName, stageSw.ElapsedMilliseconds); } - foreach (var stage in _writeStages) + // Подробный отчёт по результатам read-этапов + PrintDryRunReport(context); + + // Write-этапы пропускаем при DryRun + if (!dryRun) { - var stageSw = Stopwatch.StartNew(); - await stage.ExecuteAsync(context); - stageSw.Stop(); - _logger.LogDebug("[Perf] JobGroup '{JobGroupName}' ({JobGroupId}) | Этап: {Stage} | Время: {Ms} мс", - context.JobGroupName, jobGroupId, stage.StageName, stageSw.ElapsedMilliseconds); + foreach (var stage in _writeStages) + { + var stageSw = Stopwatch.StartNew(); + await stage.ExecuteAsync(context); + stageSw.Stop(); + _logger.LogDebug("[Perf] JobGroup '{JobGroupName}' ({JobGroupId}) | Этап: {Stage} | Время: {Ms} мс", + context.JobGroupName, jobGroupId, stage.StageName, stageSw.ElapsedMilliseconds); + } + + await SetStatusAsync(jobGroupId, "Синхронизация завершена успешно"); + await _matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); + } + else + { + _logger.LogInformation("[DryRun] Write-этапы пропущены. Изменения в БД и MQ не выполнены."); + await SetStatusAsync(jobGroupId, "DryRun: Анализ завершён без изменений"); + await _matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); } totalSw.Stop(); _logger.LogInformation("[Perf] JobGroup '{JobGroupName}' ({JobGroupId}) | ИТОГО: {TotalMs} мс", context.JobGroupName, jobGroupId, totalSw.ElapsedMilliseconds); - - await SetStatusAsync(jobGroupId, "Синхронизация завершена успешно"); - await _matchingStatusService.DeleteMatchingStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); - _logger.LogInformation("Синхронизация шаблонов завершена для JobGroup '{JobGroupName}' ({JobGroupId})", - context.JobGroupName, jobGroupId); } catch (GroupedSyncEarlyExitException ex) { @@ -94,6 +111,52 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer } } + private void PrintDryRunReport(GroupedSyncContext context) + { + _logger.LogInformation("========== [DryRun] ОТЧЁТ по JobGroup '{JobGroupName}' ({JobGroupId}) ==========", + context.JobGroupName, context.JobGroupId); + + _logger.LogInformation("[DryRun] Отфильтровано юнитов: {Count}", context.FilteredUnits.Count); + _logger.LogInformation("[DryRun] Разрешено конфликтов (potentialUnits): {Count}", context.ReverseMapping.Count); + _logger.LogInformation("[DryRun] Сформировано групп: {Count}", context.TemplateGroups.Count); + + int totalSubGroups = 0; + int totalUnitsInTemplates = 0; + + foreach (var group in context.TemplateGroups.OrderBy(g => g.PotentialUnitId)) + { + _logger.LogInformation( + "[DryRun] PotentialUnit {Unit}: {SubGroupCount} подгрупп", + context.FormatUnit(group.PotentialUnitId), + group.SubGroups.Count); + + foreach (var subGroup in group.SubGroups.OrderBy(s => s.GlobalIndex)) + { + _logger.LogInformation( + "[DryRun] Index={Index}, группа='{Group}', экземпляров: {Count}", + subGroup.GlobalIndex, + subGroup.InnerGroupName, + subGroup.Entries.Count); + + foreach (var entry in subGroup.Entries) + { + _logger.LogInformation( + "[DryRun] - {Unit} (ValueId: {ValueId})", + context.FormatUnit(entry.UnitId), + entry.UnitFieldValueId); + } + + totalSubGroups++; + totalUnitsInTemplates += subGroup.Entries.Count; + } + } + + _logger.LogInformation("[DryRun] ИТОГО: {GroupCount} групп, {SubGroupCount} подгрупп (=шаблонов), {UnitCount} экземпляров в шаблонах", + context.TemplateGroups.Count, totalSubGroups, totalUnitsInTemplates); + + _logger.LogInformation("========== [DryRun] КОНЕЦ ОТЧЁТА =========="); + } + public Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator) { _logger.LogWarning("GroupedTemplateSynchronizer: SyncTemplatesForJob вызван для JobId {JobId}. Это не поддерживаемая операция.", jobId); diff --git a/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs index 724299f3..bb00e23f 100644 --- a/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs @@ -76,12 +76,12 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer this._unitRepository = unitRepository; } - public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator) + public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator, bool dryRun = false) { if (jobId == _settingsFromDb.JobIdForUnusedTemplates) { _logger.LogInformation("Обработка синхронизации для Job неиспользуемых шаблонов '{JobId}'", jobId); - await SyncUnusedTemplatesAsync(jobId, initiator); + await SyncUnusedTemplatesAsync(jobId, initiator, dryRun); return; } @@ -120,38 +120,95 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds); } - foreach (var stage in _writeStages) + // Подробный отчёт по результатам read-этапов + PrintDryRunReport(context); + + if (!dryRun) { - var stageSw = Stopwatch.StartNew(); - await stage.ExecuteAsync(context); - stageSw.Stop(); - _logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс", - context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds); + foreach (var stage in _writeStages) + { + var stageSw = Stopwatch.StartNew(); + await stage.ExecuteAsync(context); + stageSw.Stop(); + _logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс", + context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds); + } + + await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно"); + await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); + _logger.LogInformation("Синхронизация шаблонов завершена для Job '{JobName}' ({JobId})", context.JobName, jobId); + } + else + { + _logger.LogInformation("[DryRun] Write-этапы пропущены. Изменения в БД и MQ не выполнены."); + await UpdateMatchingStatusAsync(jobId, "DryRun: Анализ завершён без изменений"); + await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); } totalSw.Stop(); _logger.LogInformation("[Perf] Job '{JobName}' ({JobId}) | ИТОГО: {TotalMs} мс", context.JobName, jobId, totalSw.ElapsedMilliseconds); - - await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно"); - await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); - _logger.LogInformation("Синхронизация шаблонов завершена для Job '{JobName}' ({JobId})", - context.JobName, jobId); } catch (Exception ex) { totalSw.Stop(); _logger.LogError(ex, "Ошибка при синхронизации Job '{JobName}' ({JobId}) через {ElapsedMs} мс", - string.Empty, jobId, totalSw.ElapsedMilliseconds); + context.JobName ?? string.Empty, jobId, totalSw.ElapsedMilliseconds); await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}"); throw; } } - public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator) + private void PrintDryRunReport(SimpleSyncContext context) { - _logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId); + _logger.LogInformation("========== [DryRun] ОТЧЁТ по Job '{JobName}' ({JobId}) ==========", + context.JobName, context.JobId); + + _logger.LogInformation("[DryRun] Отфильтровано юнитов: {Count}", context.FilteredUnitIds.Count); + _logger.LogInformation("[DryRun] Существующих активных шаблонов: {Count}", context.ExistingUsedTemplates.Count); + _logger.LogInformation("[DryRun] Новых юнитов (требуют аллокации): {Count}", context.NewUnitIds.Count); + _logger.LogInformation("[DryRun] Шаблонов для деактивации (Unused): {Count}", context.UnusedTemplates.Count); + _logger.LogInformation("[DryRun] Шаблонов для переименования: {Count}", context.TemplatesToRename.Count); + + if (context.NewUnitIds.Any()) + { + _logger.LogInformation("[DryRun] --- Новые юниты (будут созданы шаблоны) ---"); + foreach (var unitId in context.NewUnitIds) + { + _logger.LogInformation("[DryRun] + {Unit}", context.FormatUnit(unitId)); + } + } + + if (context.UnusedTemplates.Any()) + { + _logger.LogInformation("[DryRun] --- Шаблоны для деактивации ---"); + foreach (var template in context.UnusedTemplates) + { + _logger.LogInformation("[DryRun] - {TemplateId} (Unit: {Unit}, Name: '{Name}')", + template.Id, context.FormatUnit(template.UnitId), template.Name); + } + } + + if (context.TemplatesToRename.Any()) + { + _logger.LogInformation("[DryRun] --- Шаблоны для переименования ---"); + foreach (var (template, expectedName) in context.TemplatesToRename) + { + _logger.LogInformation("[DryRun] ~ {TemplateId} (Unit: {Unit}): '{OldName}' -> '{NewName}'", + template.Id, context.FormatUnit(template.UnitId), template.Name, expectedName); + } + } + + _logger.LogInformation("========== [DryRun] КОНЕЦ ОТЧЁТА =========="); + } + + public Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator, bool dryRun = false) + { + _logger.LogWarning( + "SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId} (DryRun={DryRun}). Это не поддерживаемая операция.", + jobGroupId, dryRun); + return Task.CompletedTask; } @@ -278,7 +335,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer } - private async Task SyncUnusedTemplatesAsync(Guid unusedJobId, HistoryInitiator initiator, CancellationToken ct = default) + private async Task SyncUnusedTemplatesAsync(Guid unusedJobId, HistoryInitiator initiator, bool dryRun = false, CancellationToken ct = default) { // Проверка отмены в самом начале ct.ThrowIfCancellationRequested(); @@ -467,9 +524,18 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer continue; } - await SendUpdateRequest(template, unusedJobId, expectedName, initiator, finalUnitId); - _logger.LogDebug("Отправлен запрос на обновление шаблона {TemplateId}. Изменения: Unit={U}, Job={J}, Name={N}", - template.Id, unitChanged, jobChanged, nameChanged); + // Условие DryRun для отправки в MQ + if (!dryRun) + { + await SendUpdateRequest(template, unusedJobId, expectedName, initiator, finalUnitId); + _logger.LogDebug("Отправлен запрос на обновление шаблона {TemplateId}. Изменения: Unit={U}, Job={J}, Name={N}", + template.Id, unitChanged, jobChanged, nameChanged); + } + else + { + _logger.LogInformation("[DryRun] Пропущена отправка запроса на обновление шаблона {TemplateId}. Изменения: Unit={U}, Job={J}, Name={N}, ExpectedName='{Name}'", + template.Id, unitChanged, jobChanged, nameChanged, expectedName); + } processed++; } @@ -482,13 +548,20 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer totalSw.Stop(); - totalSw.Stop(); _logger.LogInformation("[Perf] Job '{JobName}' ({JobId}) | ИТОГО: {TotalMs} мс", unusedJob.Name, unusedJob.Id, totalSw.ElapsedMilliseconds); - await UpdateMatchingStatusAsync(unusedJobId, "Синхронизация неиспользуемых шаблонов завершена"); + if (dryRun) + { + await UpdateMatchingStatusAsync(unusedJobId, "DryRun: Анализ неиспользуемых шаблонов завершён без изменений"); + } + else + { + await UpdateMatchingStatusAsync(unusedJobId, "Синхронизация неиспользуемых шаблонов завершена"); + } + await _matchingStatusService.DeleteMatchingStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job); - totalSw.Stop(); + _logger.LogInformation( "Синхронизация неиспользуемых шаблонов завершена. Обработано {Count} шаблонов", unusedTemplates.Count); diff --git a/PARR.TemplateMatcher/Services/Interfaces/ITemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Interfaces/ITemplateSynchronizer.cs index f2bd3b1d..b931f818 100644 --- a/PARR.TemplateMatcher/Services/Interfaces/ITemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Interfaces/ITemplateSynchronizer.cs @@ -4,8 +4,8 @@ namespace PARR.TemplateMatcher.Services.Interfaces { public interface ITemplateSynchronizer { - Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator); - Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator); + Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator, bool dryRun = false); + Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator, bool dryRun = false); Task UpdateTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator); } -} +} diff --git a/PARR.TemplateMatcher/Services/SimpleSync/SimpleSyncContext.cs b/PARR.TemplateMatcher/Services/SimpleSync/SimpleSyncContext.cs index a64ffda7..ca96cfae 100644 --- a/PARR.TemplateMatcher/Services/SimpleSync/SimpleSyncContext.cs +++ b/PARR.TemplateMatcher/Services/SimpleSync/SimpleSyncContext.cs @@ -12,6 +12,10 @@ namespace PARR.TemplateMatcher.Services.SimpleSync public Guid JobId { get; init; } public string JobName { get; set; } = string.Empty; public HistoryInitiator Initiator { get; init; } = null!; + + // Флаг режима сухого запуска + public bool DryRun { get; init; } + public Job Job { get; set; } = null!; public HashSet FilteredUnitIds { get; set; } = new();