From b267dfe7c57487a9ee04bd182cacd6585ba1d328 Mon Sep 17 00:00:00 2001 From: Mikhail Kuznetsov Date: Tue, 19 May 2026 09:47:35 +1000 Subject: [PATCH] =?UTF-8?q?feat(core):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D0=BE=D0=BC=D0=BE=D1=89=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BA=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E=20JobGroup=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20Job=20=D0=B2=20JobGroup=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D1=82=D0=B0=D1=80=D1=88=D0=B5=D0=BC=D1=83=20Max?= =?UTF-8?q?ValueRelationships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Helpers/JobGroupCloneHelper.cs | 182 +++++++++++++++++ .../JobGroupUnitFilterSynchronizeHelper.cs | 188 ++++++++++++++++++ PARR.Core/DependencyInjection.cs | 8 + .../Repositories/Base/IBaseRepository.cs | 1 - .../UnitFilterService/UnitFilterService.cs | 2 +- PARR.Domain/Entities/Schedule/EsppSchValue.cs | 10 +- 6 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 PARR.Core/Common/Helpers/JobGroupCloneHelper.cs create mode 100644 PARR.Core/Common/Helpers/JobGroupUnitFilterSynchronizeHelper.cs diff --git a/PARR.Core/Common/Helpers/JobGroupCloneHelper.cs b/PARR.Core/Common/Helpers/JobGroupCloneHelper.cs new file mode 100644 index 00000000..8098d222 --- /dev/null +++ b/PARR.Core/Common/Helpers/JobGroupCloneHelper.cs @@ -0,0 +1,182 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces.Job; +using PARR.Domain.Entities.Base.History; +using PARR.Domain.Entities.Job; +using PARR.Domain.Entities.Schedule; + +namespace PARR.Core.Common.Helpers; + +/// +/// Сервис для полного клонирования JobGroup со всеми вложенными Job, фильтрами, AutoControl и EsppSchValues. +/// Шаблоны (Template) не клонируются. Идентификаторы генерируются стратегией EF Core. +/// +public class JobGroupCloneHelper +{ + private readonly IJobGroupRepository groupRepository; + private readonly IJobRepository jobRepository; + private readonly ILogger logger; + + public JobGroupCloneHelper( + IJobGroupRepository groupRepository, + IJobRepository jobRepository, + ILogger logger) + { + this.groupRepository = groupRepository; + this.jobRepository = jobRepository; + this.logger = logger; + } + + public async Task CloneAsync( + Guid sourceJobGroupId, + string? newGroupName, + HistoryInitiator initiator, + CancellationToken ct = default) + { + var sourceGroup = await groupRepository.Get() + .AsNoTracking() + .Include(g => g.Jobs) + .ThenInclude(j => j.UnitFilters) + .ThenInclude(uf => uf.FieldFilters) + .Include(g => g.Jobs) + .ThenInclude(j => j.UnitFilters) + .ThenInclude(uf => uf.RelationshipFilters) + .Include(g => g.Jobs) + .ThenInclude(j => j.AutoControl) + .Include(g => g.EsppSchValues) + .FirstOrDefaultAsync(g => g.Id == sourceJobGroupId, ct); + + if (sourceGroup == null) + { + throw new InvalidOperationException($"JobGroup {sourceJobGroupId} not found."); + } + + logger.LogInformation("Начало клонирования JobGroup {JobGroupId}.", sourceJobGroupId); + + var newGroup = MapJobGroup(sourceGroup, newGroupName); + + foreach (var sourceJob in sourceGroup.Jobs ?? Enumerable.Empty()) + { + var newJob = MapJob(sourceJob); + newGroup.Jobs.Add(newJob); + + CloneJobFilters(sourceJob.UnitFilters, newJob); + + if (sourceJob.AutoControl != null) + { + newJob.AutoControl = MapAutoControl(sourceJob.AutoControl); + } + } + + // Клонирование значений расписания + foreach (var sourceSchValue in sourceGroup.EsppSchValues ?? Enumerable.Empty()) + { + newGroup.EsppSchValues.Add(new EsppSchValue + { + TypeValueId = sourceSchValue.TypeValueId, + TypeConfigId = sourceSchValue.TypeConfigId + }); + } + + var isSuccess = await groupRepository.CreateAsync(newGroup); + if (!isSuccess) + { + throw new InvalidOperationException("Не удалось сохранить клон JobGroup в базу данных."); + } + + await groupRepository.CommitAsync(initiator); + + logger.LogInformation("JobGroup успешно клонирована. Новый ID: {NewGroupId}.", newGroup.Id); + + return newGroup.Id; + } + + private JobGroup MapJobGroup(JobGroup source, string? newName) + { + string finalName = string.IsNullOrWhiteSpace(newName) + ? $"{source.GroupName?.TrimEnd()} КЛОН" + : newName.Trim(); + + return new JobGroup + { + GroupName = finalName, + ShortDescription = source.ShortDescription, + FullDescription = source.FullDescription, + Solution = source.Solution, + TemplateDuration = source.TemplateDuration, + ReferenceDate = source.ReferenceDate, + UserTimeZoneOffsetMinutes = source.UserTimeZoneOffsetMinutes, + IsResponseAreaTimezone = source.IsResponseAreaTimezone, + IsAutoDistributionEnabled = source.IsAutoDistributionEnabled, + IsAgent = source.IsAgent, + AgentName = source.AgentName, + AgentTimeOutSec = source.AgentTimeOutSec, + AgentScript = source.AgentScript, + GroupTypeId = source.GroupTypeId, + GroupingUnitFieldId = source.GroupingUnitFieldId, + IsGroupByResponsible = source.IsGroupByResponsible, + ScheduleExcludeTypeId = source.ScheduleExcludeTypeId, + ScheduleExcludeTypeCalendarId = source.ScheduleExcludeTypeCalendarId, + DateCreated = DateTimeOffset.UtcNow, + Jobs = new HashSet(), + EsppSchValues = new HashSet() + }; + } + + private Job MapJob(Job source) + { + return new Job + { + Name = source.Name, + WorkName = source.WorkName, + MinValueRelationships = source.MinValueRelationships, + MaxValueRelationships = source.MaxValueRelationships, + IsParentRelationships = source.IsParentRelationships, + TemplateNameMask = source.TemplateNameMask, + WorkGroupMask = source.WorkGroupMask, + ResponseAreaMask = source.ResponseAreaMask, + TnkId = source.TnkId, + DateCreated = DateTimeOffset.UtcNow, + UnitFilters = new HashSet() + }; + } + + private JobAutoControl MapAutoControl(JobAutoControl source) + { + return new JobAutoControl + { + InitUsedTemplateState = source.InitUsedTemplateState, + InitUsedScheduleState = source.InitUsedScheduleState + }; + } + + private void CloneJobFilters(IEnumerable? sourceFilters, Job targetJob) + { + if (sourceFilters == null) return; + + foreach (var sourceFilter in sourceFilters) + { + var newFilter = new JobUnitFilter + { + UnitFilter = sourceFilter.UnitFilter, + DateCreated = DateTimeOffset.UtcNow, + FieldFilters = sourceFilter.FieldFilters?.Select(f => new JobFieldFilter + { + FieldId = f.FieldId, + ValueMask = f.ValueMask, + IsInverse = f.IsInverse, + DateCreated = DateTimeOffset.UtcNow + }).ToList() ?? new List(), + RelationshipFilters = sourceFilter.RelationshipFilters?.Select(r => new JobRelationshipFilter + { + FieldId = r.FieldId, + ValueMask = r.ValueMask, + IsInverse = r.IsInverse, + IsParent = r.IsParent, + IsFullMatch = r.IsFullMatch + }).ToList() ?? new List() + }; + targetJob.UnitFilters.Add(newFilter); + } + } +} \ No newline at end of file diff --git a/PARR.Core/Common/Helpers/JobGroupUnitFilterSynchronizeHelper.cs b/PARR.Core/Common/Helpers/JobGroupUnitFilterSynchronizeHelper.cs new file mode 100644 index 00000000..290dff6f --- /dev/null +++ b/PARR.Core/Common/Helpers/JobGroupUnitFilterSynchronizeHelper.cs @@ -0,0 +1,188 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces.Job; +using PARR.Domain.Entities.Base.History; +using PARR.Domain.Entities.Job; + +namespace PARR.Core.Common.Helpers; + +public class JobGroupUnitFilterSynchronizeHelper +{ + private readonly IJobRepository jobRepository; + private readonly ILogger logger; + + public JobGroupUnitFilterSynchronizeHelper(IJobRepository jobRepository, ILogger logger) + { + this.jobRepository = jobRepository; + this.logger = logger; + } + + public async Task SynchronizeGroupFiltersAsync(Guid jobGroupId, HistoryInitiator initiator, CancellationToken ct = default) + { + var jobs = await jobRepository.Get() + .AsNoTracking() + .Include(j => j.UnitFilters) + .ThenInclude(uf => uf.FieldFilters) + .Include(j => j.UnitFilters) + .ThenInclude(uf => uf.RelationshipFilters) + .Where(j => j.GroupId == jobGroupId) + .ToListAsync(ct); + + if (jobs == null || jobs.Count < 2) + { + logger.LogDebug("JobGroup {JobGroupId} содержит менее двух Job. Синхронизация фильтров не требуется.", jobGroupId); + return; + } + + var referenceJob = jobs.OrderByDescending(j => j.MaxValueRelationships).FirstOrDefault(); + if (referenceJob == null) + { + logger.LogWarning("Не удалось определить эталонный Job для JobGroup {JobGroupId}.", jobGroupId); + return; + } + + logger.LogInformation("Начало синхронизации фильтров для JobGroup {JobGroupId}. Эталонный Job: {ReferenceJobId}.", jobGroupId, referenceJob.Id); + + foreach (var job in jobs) + { + ct.ThrowIfCancellationRequested(); + + if (job.Id == referenceJob.Id) continue; + + if (!AreUnitFiltersIdentical(job.UnitFilters, referenceJob.UnitFilters)) + { + logger.LogDebug("Фильтры Job {JobId} отличаются от эталона. Запуск обновления.", job.Id); + await ApplyFilterCorrectionAsync(job.Id, referenceJob.UnitFilters ?? Enumerable.Empty(), initiator, ct); + } + else + { + logger.LogDebug("Фильтры Job {JobId} идентичны эталону. Пропуск.", job.Id); + } + } + + logger.LogInformation("Синхронизация фильтров для JobGroup {JobGroupId} завершена.", jobGroupId); + } + + private async Task ApplyFilterCorrectionAsync(Guid jobId, IEnumerable referenceFilters, HistoryInitiator initiator, CancellationToken ct) + { + var job = await jobRepository.Get() + .Include(j => j.UnitFilters) + .ThenInclude(uf => uf.FieldFilters) + .Include(j => j.UnitFilters) + .ThenInclude(uf => uf.RelationshipFilters) + .FirstOrDefaultAsync(j => j.Id == jobId, ct); + + if (job == null) return; + + if (job.UnitFilters == null) + { + job.UnitFilters = new List(); + } + else + { + job.UnitFilters.Clear(); + } + + var clonedFilters = CloneFilters(referenceFilters, job.Id); + foreach (var filter in clonedFilters) + { + job.UnitFilters!.Add(filter); + } + + await jobRepository.CommitAsync(initiator); + logger.LogInformation("Фильтры Job {JobId} успешно обновлены.", jobId); + } + + private bool AreUnitFiltersIdentical(IEnumerable? current, IEnumerable? reference) + { + var currentList = (current ?? Enumerable.Empty()).ToList(); + var refList = (reference ?? Enumerable.Empty()).ToList(); + + if (currentList.Count != refList.Count) return false; + + for (int i = 0; i < currentList.Count; i++) + { + if (!AreFiltersEqual(currentList[i], refList[i])) + return false; + } + return true; + } + + private bool AreFiltersEqual(JobUnitFilter a, JobUnitFilter b) + { + if (!AreFieldFiltersEqual(a.FieldFilters, b.FieldFilters)) return false; + if (!AreRelationshipFiltersEqual(a.RelationshipFilters, b.RelationshipFilters)) return false; + return true; + } + + private bool AreFieldFiltersEqual(IEnumerable? a, IEnumerable? b) + { + var listA = (a ?? Enumerable.Empty()).OrderBy(f => f.FieldId).ToList(); + var listB = (b ?? Enumerable.Empty()).OrderBy(f => f.FieldId).ToList(); + + if (listA.Count != listB.Count) return false; + + for (int i = 0; i < listA.Count; i++) + { + if (listA[i].FieldId != listB[i].FieldId || + !string.Equals(listA[i].ValueMask, listB[i].ValueMask, StringComparison.OrdinalIgnoreCase) || + listA[i].IsInverse != listB[i].IsInverse) + { + return false; + } + } + return true; + } + + private bool AreRelationshipFiltersEqual(IEnumerable? a, IEnumerable? b) + { + var listA = (a ?? Enumerable.Empty()).OrderBy(f => f.FieldId).ToList(); + var listB = (b ?? Enumerable.Empty()).OrderBy(f => f.FieldId).ToList(); + + if (listA.Count != listB.Count) return false; + + for (int i = 0; i < listA.Count; i++) + { + if (listA[i].FieldId != listB[i].FieldId || + !string.Equals(listA[i].ValueMask, listB[i].ValueMask, StringComparison.OrdinalIgnoreCase) || + listA[i].IsInverse != listB[i].IsInverse || + listA[i].IsParent != listB[i].IsParent || + listA[i].IsFullMatch != listB[i].IsFullMatch) + { + return false; + } + } + return true; + } + + private List CloneFilters(IEnumerable source, Guid targetJobId) + { + var result = new List(); + foreach (var src in source ?? Enumerable.Empty()) + { + var newFilter = new JobUnitFilter + { + JobId = targetJobId, + UnitFilter = src.UnitFilter!, + DateCreated = DateTimeOffset.UtcNow, + FieldFilters = src.FieldFilters?.Select(f => new JobFieldFilter + { + FieldId = f.FieldId, + ValueMask = f.ValueMask, + IsInverse = f.IsInverse, + DateCreated = DateTimeOffset.UtcNow + }).ToList() ?? new List(), + RelationshipFilters = src.RelationshipFilters?.Select(r => new JobRelationshipFilter + { + FieldId = r.FieldId, + ValueMask = r.ValueMask, + IsInverse = r.IsInverse, + IsParent = r.IsParent, + IsFullMatch = r.IsFullMatch + }).ToList() ?? new List() + }; + result.Add(newFilter); + } + return result; + } +} \ No newline at end of file diff --git a/PARR.Core/DependencyInjection.cs b/PARR.Core/DependencyInjection.cs index 078c0ea2..de21bc6f 100644 --- a/PARR.Core/DependencyInjection.cs +++ b/PARR.Core/DependencyInjection.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using PARR.Core.Common.Helpers; using PARR.Core.Common.Implementations; using PARR.Core.Common.Interfaces; using PARR.Core.Services.MatchingStatusService; @@ -49,6 +50,13 @@ namespace PARR.Core services.AddTransient(); + #region Helpers + + services.AddScoped(); + services.AddScoped(); + + #endregion + #endregion #region Task diff --git a/PARR.Core/Repositories/Base/IBaseRepository.cs b/PARR.Core/Repositories/Base/IBaseRepository.cs index 74cfce0c..1c1beb04 100644 --- a/PARR.Core/Repositories/Base/IBaseRepository.cs +++ b/PARR.Core/Repositories/Base/IBaseRepository.cs @@ -20,7 +20,6 @@ namespace PARR.Core.Repositories.Base bool Delete(T obj); - //Task CommitAsync(); Task CommitAsync(IHistoryInitiator? initiator = null); } } diff --git a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs index e12d2cca..ee02368a 100644 --- a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs +++ b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs @@ -16,7 +16,7 @@ namespace PARR.Core.Services.UnitFilterService; internal class UnitFilterService : IUnitFilterService { #if DEBUG - private readonly Guid debugTargetUnitId = Guid.Parse("f2017292-193c-48e9-b333-3a00e737c6fc"); + private readonly Guid debugTargetUnitId = Guid.Parse("3494fbec-adb9-4167-8619-b2a8b6b6e36a"); #endif private const int DebugMaxUnitsToLog = 10; diff --git a/PARR.Domain/Entities/Schedule/EsppSchValue.cs b/PARR.Domain/Entities/Schedule/EsppSchValue.cs index 7a9af3f8..1ddfc3ff 100644 --- a/PARR.Domain/Entities/Schedule/EsppSchValue.cs +++ b/PARR.Domain/Entities/Schedule/EsppSchValue.cs @@ -6,26 +6,18 @@ using System.ComponentModel.DataAnnotations.Schema; namespace PARR.Domain.Entities.Schedule { /// - /// Расписание ЕСПП: значения заданий для ApplicationsInWorks + /// Расписание ЕСПП: значения заданий для JobGroup /// [Table("EsppSchValues", Schema = DatabaseSchemas.Schedule)] - //[Index(nameof(ApplicationsInWorkId), nameof(TypeValueId), nameof(TypeConfigId), IsUnique = true)] - //[PrimaryKey(nameof(ApplicationsInWorkId), nameof(TypeValueId), nameof(TypeConfigId))] [PrimaryKey(nameof(JobGroupId), nameof(TypeValueId), nameof(TypeConfigId))] public class EsppSchValue { - //public Guid ApplicationsInWorkId { get; set; } - public Guid TypeValueId { get; set; } public Guid TypeConfigId { get; set; } public Guid JobGroupId { get; set; } - - //[ForeignKey(nameof(ApplicationsInWorkId))] - //public ApplicationsInWork? ApplicationsInWork { get; set; } - [ForeignKey(nameof(TypeValueId))] public EsppSchTypeValue? EsppSchTypeValue { get; set; }