feat(templateMatcher): Убран хардкод РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК при формировании шаблонов; добавлен метод для поиска аттрибутов ЭК по значению Code.

This commit is contained in:
Mikhail Kuznetsov
2026-05-28 16:54:51 +10:00
parent 04237cc826
commit 51bb383d06
5 changed files with 185 additions and 43 deletions

View File

@@ -3,9 +3,20 @@ using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Job; using PARR.Core.Repositories.Interfaces.Job;
using PARR.Domain.Entities.Base.History; using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.Job; using PARR.Domain.Entities.Job;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace PARR.Core.Common.Helpers; namespace PARR.Core.Common.Helpers;
/// <summary>
/// Сервис для синхронизации свойств Job внутри JobGroup.
/// Эталоном считается Job с максимальным MaxValueRelationships.
/// Синхронизируются: WorkName, маски, IsParentRelationships, AutoControl, UnitFilters.
/// НЕ синхронизируются: Id, Name, MinValueRelationships, MaxValueRelationships, TnkId.
/// </summary>
public class JobGroupUnitFilterSynchronizeHelper public class JobGroupUnitFilterSynchronizeHelper
{ {
private readonly IJobRepository jobRepository; private readonly IJobRepository jobRepository;
@@ -25,12 +36,13 @@ public class JobGroupUnitFilterSynchronizeHelper
.ThenInclude(uf => uf.FieldFilters) .ThenInclude(uf => uf.FieldFilters)
.Include(j => j.UnitFilters) .Include(j => j.UnitFilters)
.ThenInclude(uf => uf.RelationshipFilters) .ThenInclude(uf => uf.RelationshipFilters)
.Include(j => j.AutoControl)
.Where(j => j.GroupId == jobGroupId) .Where(j => j.GroupId == jobGroupId)
.ToListAsync(ct); .ToListAsync(ct);
if (jobs == null || jobs.Count < 2) if (jobs == null || jobs.Count < 2)
{ {
logger.LogDebug("JobGroup {JobGroupId} содержит менее двух Job. Синхронизация фильтров не требуется.", jobGroupId); logger.LogDebug("JobGroup {JobGroupId} содержит менее двух Job. Синхронизация не требуется.", jobGroupId);
return; return;
} }
@@ -41,7 +53,7 @@ public class JobGroupUnitFilterSynchronizeHelper
return; return;
} }
logger.LogInformation("Начало синхронизации фильтров для JobGroup {JobGroupId}. Эталонный Job: {ReferenceJobId}.", jobGroupId, referenceJob.Id); logger.LogInformation("Начало синхронизации для JobGroup {JobGroupId}. Эталонный Job: {ReferenceJobId}.", jobGroupId, referenceJob.Id);
foreach (var job in jobs) foreach (var job in jobs)
{ {
@@ -49,31 +61,84 @@ public class JobGroupUnitFilterSynchronizeHelper
if (job.Id == referenceJob.Id) continue; if (job.Id == referenceJob.Id) continue;
if (!AreUnitFiltersIdentical(job.UnitFilters, referenceJob.UnitFilters)) if (!AreJobsIdentical(job, referenceJob))
{ {
logger.LogDebug("Фильтры Job {JobId} отличаются от эталона. Запуск обновления.", job.Id); logger.LogDebug("Свойства Job {JobId} отличаются от эталона. Запуск обновления.", job.Id);
await ApplyFilterCorrectionAsync(job.Id, referenceJob.UnitFilters ?? Enumerable.Empty<JobUnitFilter>(), initiator, ct); await ApplyJobCorrectionAsync(job.Id, referenceJob, initiator, ct);
} }
else else
{ {
logger.LogDebug("Фильтры Job {JobId} идентичны эталону. Пропуск.", job.Id); logger.LogDebug("Свойства Job {JobId} идентичны эталону. Пропуск.", job.Id);
} }
} }
logger.LogInformation("Синхронизация фильтров для JobGroup {JobGroupId} завершена.", jobGroupId); logger.LogInformation("Синхронизация для JobGroup {JobGroupId} завершена.", jobGroupId);
} }
private async Task ApplyFilterCorrectionAsync(Guid jobId, IEnumerable<JobUnitFilter> referenceFilters, HistoryInitiator initiator, CancellationToken ct) private bool AreJobsIdentical(Job current, Job reference)
{
// Сравнение скалярных полей (кроме Id, Name, Min/MaxValueRelationships, TnkId)
if (current.WorkName != reference.WorkName) return false;
if (current.IsParentRelationships != reference.IsParentRelationships) return false;
if (current.TemplateNameMask != reference.TemplateNameMask) return false;
if (current.WorkGroupMask != reference.WorkGroupMask) return false;
if (current.ResponseAreaMask != reference.ResponseAreaMask) return false;
// Сравнение AutoControl
if (current.AutoControl == null && reference.AutoControl != null) return false;
if (current.AutoControl != null && reference.AutoControl == null) return false;
if (current.AutoControl != null && reference.AutoControl != null)
{
if (current.AutoControl.InitUsedTemplateState != reference.AutoControl.InitUsedTemplateState) return false;
if (current.AutoControl.InitUsedScheduleState != reference.AutoControl.InitUsedScheduleState) return false;
}
// Сравнение фильтров
if (!AreUnitFiltersIdentical(current.UnitFilters, reference.UnitFilters)) return false;
return true;
}
private async Task ApplyJobCorrectionAsync(Guid jobId, Job referenceJob, HistoryInitiator initiator, CancellationToken ct)
{ {
var job = await jobRepository.Get() var job = await jobRepository.Get()
.Include(j => j.UnitFilters) .Include(j => j.UnitFilters)
.ThenInclude(uf => uf.FieldFilters) .ThenInclude(uf => uf.FieldFilters)
.Include(j => j.UnitFilters) .Include(j => j.UnitFilters)
.ThenInclude(uf => uf.RelationshipFilters) .ThenInclude(uf => uf.RelationshipFilters)
.Include(j => j.AutoControl)
.FirstOrDefaultAsync(j => j.Id == jobId, ct); .FirstOrDefaultAsync(j => j.Id == jobId, ct);
if (job == null) return; if (job == null) return;
// 1. Обновляем скалярные поля
job.WorkName = referenceJob.WorkName;
job.IsParentRelationships = referenceJob.IsParentRelationships;
job.TemplateNameMask = referenceJob.TemplateNameMask;
job.WorkGroupMask = referenceJob.WorkGroupMask;
job.ResponseAreaMask = referenceJob.ResponseAreaMask;
job.DateModified = DateTimeOffset.UtcNow;
// 2. Обновляем AutoControl
if (referenceJob.AutoControl != null)
{
if (job.AutoControl == null)
{
job.AutoControl = new JobAutoControl
{
JobId = job.Id,
InitUsedTemplateState = referenceJob.AutoControl.InitUsedTemplateState,
InitUsedScheduleState = referenceJob.AutoControl.InitUsedScheduleState
};
}
else
{
job.AutoControl.InitUsedTemplateState = referenceJob.AutoControl.InitUsedTemplateState;
job.AutoControl.InitUsedScheduleState = referenceJob.AutoControl.InitUsedScheduleState;
}
}
// 3. Обновляем UnitFilters
if (job.UnitFilters == null) if (job.UnitFilters == null)
{ {
job.UnitFilters = new List<JobUnitFilter>(); job.UnitFilters = new List<JobUnitFilter>();
@@ -83,14 +148,14 @@ public class JobGroupUnitFilterSynchronizeHelper
job.UnitFilters.Clear(); job.UnitFilters.Clear();
} }
var clonedFilters = CloneFilters(referenceFilters, job.Id); var clonedFilters = CloneFilters(referenceJob.UnitFilters ?? Enumerable.Empty<JobUnitFilter>(), job.Id);
foreach (var filter in clonedFilters) foreach (var filter in clonedFilters)
{ {
job.UnitFilters!.Add(filter); job.UnitFilters!.Add(filter);
} }
await jobRepository.CommitAsync(initiator); await jobRepository.CommitAsync(initiator);
logger.LogInformation("Фильтры Job {JobId} успешно обновлены.", jobId); logger.LogInformation("Job {JobId} успешно синхронизирован с эталоном.", jobId);
} }
private bool AreUnitFiltersIdentical(IEnumerable<JobUnitFilter>? current, IEnumerable<JobUnitFilter>? reference) private bool AreUnitFiltersIdentical(IEnumerable<JobUnitFilter>? current, IEnumerable<JobUnitFilter>? reference)

View File

@@ -5,6 +5,7 @@ namespace PARR.Core.Repositories.Interfaces.Unit
{ {
public interface IUnitFieldRepository : IBaseRepository<UnitField> public interface IUnitFieldRepository : IBaseRepository<UnitField>
{ {
Task<UnitField?> GetByAihitNameAsync(string name); Task<UnitField?> GetByCodeAsync(string code, CancellationToken ct = default);
Task<UnitField?> GetByAihitNameAsync(string aihitName, CancellationToken ct = default);
} }
} }

View File

@@ -10,10 +10,24 @@ namespace PARR.DAL.Repositories.Unit
internal class UnitFieldRepository : BaseRepository<UnitField>, IUnitFieldRepository internal class UnitFieldRepository : BaseRepository<UnitField>, IUnitFieldRepository
{ {
public UnitFieldRepository(DataContext dataContext, ILogger<UnitFieldRepository> logger) : base(logger, dataContext) { } public UnitFieldRepository(DataContext dataContext, ILogger<UnitFieldRepository> logger) : base(logger, dataContext) { }
public async Task<UnitField?> GetByCodeAsync(string code, CancellationToken ct = default)
public async Task<UnitField?> GetByAihitNameAsync(string name)
{ {
return await EntitySet.FirstOrDefaultAsync(uf => uf.AihitName.ToLower().Trim() == name.ToLower().Trim()); if (string.IsNullOrWhiteSpace(code))
return null;
return await Get()
.AsNoTracking()
.FirstOrDefaultAsync(f => f.Code != null && EF.Functions.ILike(f.Code, code), ct);
}
public async Task<UnitField?> GetByAihitNameAsync(string aihitName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(aihitName))
return null;
return await Get()
.AsNoTracking()
.FirstOrDefaultAsync(f => EF.Functions.ILike(f.AihitName, aihitName), ct);
} }
} }
} }

View File

@@ -0,0 +1,15 @@
namespace PARR.Domain.Constants
{
public class UnitFieldCodes
{
public const string IP = "ip";
public const string IsActive = "isActive";
public const string ResponseArea = "responseArea";
public const string WorkGroupResponseArea = "workGroupResponseArea";
public const string OS = "os";
public const string Responsible = "responsible";
public const string Tag = "tag";
public const string WorkGroup = "workGroup";
public const string EkStatus = "ekStatus";
}
}

View File

@@ -1,6 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit; 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.Domain.Entities.Job;
using PARR.TemplateMatcher.Models; using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Services.Interfaces;
@@ -12,15 +15,19 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
private readonly ILogger<GroupedTemplateBuilder> logger; private readonly ILogger<GroupedTemplateBuilder> logger;
private readonly IUnitInValueRepository unitInValueRepository; private readonly IUnitInValueRepository unitInValueRepository;
private readonly IUnitFieldRepository unitFieldRepository; private readonly IUnitFieldRepository unitFieldRepository;
private readonly IShortcodesService shortcodesService;
public GroupedTemplateBuilder( public GroupedTemplateBuilder(
ILogger<GroupedTemplateBuilder> logger, ILogger<GroupedTemplateBuilder> logger,
IUnitInValueRepository unitInValueRepository, IUnitInValueRepository unitInValueRepository,
IUnitFieldRepository unitFieldRepository) IUnitFieldRepository unitFieldRepository,
IShortcodesService shortcodesService
)
{ {
this.logger = logger; this.logger = logger;
this.unitInValueRepository = unitInValueRepository; this.unitInValueRepository = unitInValueRepository;
this.unitFieldRepository = unitFieldRepository; this.unitFieldRepository = unitFieldRepository;
this.shortcodesService = shortcodesService;
} }
public async Task<List<GroupedTemplateGroup>> BuildAsync( public async Task<List<GroupedTemplateGroup>> BuildAsync(
@@ -34,20 +41,18 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
if (!initialReverseMapping.Any()) if (!initialReverseMapping.Any())
return new List<GroupedTemplateGroup>(); return new List<GroupedTemplateGroup>();
// 1. Определяем поле для внутренней группировки // 1. Определяем стратегию внутренней группировки
var isGroupByResponsible = jobGroup.IsGroupByResponsible == true; // Если IsGroupByResponsible != true, используем WorkGroupMask через ShortcodesService
var innerGroupingFieldName = isGroupByResponsible ? "ОТВЕТСТВЕННЫЙ_ЗАК" : "РАБОЧАЯ_ГР_ОТВ_ЗАК"; bool useWorkGroupMask = jobGroup.IsGroupByResponsible != true;
var innerGroupingField = await unitFieldRepository.GetByAihitNameAsync(innerGroupingFieldName) logger.LogDebug("Стратегия внутренней группировки: {Strategy}",
?? throw new InvalidOperationException($"Поле '{innerGroupingFieldName}' не найдено."); useWorkGroupMask ? "WorkGroupMask" : "Поле 'Ответственный за ЭК'");
var innerGroupingFieldId = innerGroupingField.Id;
var groupingFieldId = jobGroup.GroupingUnitFieldId!.Value;
// 2. Собираем все исходные UnitId // 2. Собираем все исходные UnitId
var allSourceUnitIds = initialReverseMapping.Values.SelectMany(ids => ids).Distinct().ToList(); var allSourceUnitIds = initialReverseMapping.Values.SelectMany(ids => ids).Distinct().ToList();
// 3. Загружаем UnitInValue для трансформации // 3. Загружаем UnitInValue для трансформации по полю группировки из настроек JobGroup
var groupingFieldId = jobGroup.GroupingUnitFieldId!.Value;
var relevantUnitInValues = await unitInValueRepository.Get() var relevantUnitInValues = await unitInValueRepository.Get()
.AsNoTracking() .AsNoTracking()
.Where(uiv => allSourceUnitIds.Contains(uiv.UnitId) && uiv.FieldId == groupingFieldId) .Where(uiv => allSourceUnitIds.Contains(uiv.UnitId) && uiv.FieldId == groupingFieldId)
@@ -58,9 +63,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var uivLookup = relevantUnitInValues var uivLookup = relevantUnitInValues
.GroupBy(x => x.UnitId) .GroupBy(x => x.UnitId)
.ToDictionary( .ToDictionary(g => g.Key, g => g.Select(x => x.ValueId).ToList());
g => g.Key,
g => g.Select(x => x.ValueId).ToList());
// 4. Трансформируем в reverseMapping с парами // 4. Трансформируем в reverseMapping с парами
var reverseMapping = new Dictionary<Guid, List<(Guid UnitId, Guid UnitFieldValueId)>>(); var reverseMapping = new Dictionary<Guid, List<(Guid UnitId, Guid UnitFieldValueId)>>();
@@ -83,7 +86,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var uniqueEntries = entries var uniqueEntries = entries
.GroupBy(e => (e.UnitId, e.UnitFieldValueId)) .GroupBy(e => (e.UnitId, e.UnitFieldValueId))
.Select(g => g.First()) .Select(g => g.First())
.OrderBy(e => e.UnitId) // ИСПРАВЛЕНО: стабильная сортировка .OrderBy(e => e.UnitId)
.ThenBy(e => e.UnitFieldValueId) .ThenBy(e => e.UnitFieldValueId)
.ToList(); .ToList();
@@ -94,7 +97,50 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
if (!reverseMapping.Any()) if (!reverseMapping.Any())
return new List<GroupedTemplateGroup>(); return new List<GroupedTemplateGroup>();
// 5. Загружаем значения для внутренней группировки // 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 allUnitsInTemplatePairs = reverseMapping.Values.SelectMany(list => list).ToList();
var allUnitIdsForInnerGrouping = allUnitsInTemplatePairs.Select(e => e.UnitId).Distinct().ToList(); var allUnitIdsForInnerGrouping = allUnitsInTemplatePairs.Select(e => e.UnitId).Distinct().ToList();
@@ -103,9 +149,10 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
new HashSet<Guid> { innerGroupingFieldId }, new HashSet<Guid> { innerGroupingFieldId },
ct); ct);
var unitIdToInnerGroupingValueMap = innerGroupingValues unitIdToGroupingValueMap = innerGroupingValues
.Where(uv => uv.Value != null && !string.IsNullOrEmpty(uv.Value.Value)) .Where(uv => uv.Value != null && uv.Value.Value != null && !string.IsNullOrEmpty(uv.Value.Value))
.ToDictionary(uv => uv.UnitId, uv => uv.Value!.Value); .ToDictionary(uv => uv.UnitId, uv => uv.Value!.Value!);
}
// 6. Формируем итоговую структуру // 6. Формируем итоговую структуру
var templateGroups = new List<GroupedTemplateGroup>(); var templateGroups = new List<GroupedTemplateGroup>();
@@ -117,7 +164,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var unitsInTemplateForThisPotentialUnitId = kvp.Value; var unitsInTemplateForThisPotentialUnitId = kvp.Value;
var innerGroupedUnits = unitsInTemplateForThisPotentialUnitId var innerGroupedUnits = unitsInTemplateForThisPotentialUnitId
.GroupBy(entry => unitIdToInnerGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных")) .GroupBy(entry => unitIdToGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных"))
.OrderBy(g => g.Key, StringComparer.Ordinal) .OrderBy(g => g.Key, StringComparer.Ordinal)
.ToList(); .ToList();