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.Domain.Entities.Base.History;
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;
/// <summary>
/// Сервис для синхронизации свойств Job внутри JobGroup.
/// Эталоном считается Job с максимальным MaxValueRelationships.
/// Синхронизируются: WorkName, маски, IsParentRelationships, AutoControl, UnitFilters.
/// НЕ синхронизируются: Id, Name, MinValueRelationships, MaxValueRelationships, TnkId.
/// </summary>
public class JobGroupUnitFilterSynchronizeHelper
{
private readonly IJobRepository jobRepository;
@@ -25,12 +36,13 @@ public class JobGroupUnitFilterSynchronizeHelper
.ThenInclude(uf => uf.FieldFilters)
.Include(j => j.UnitFilters)
.ThenInclude(uf => uf.RelationshipFilters)
.Include(j => j.AutoControl)
.Where(j => j.GroupId == jobGroupId)
.ToListAsync(ct);
if (jobs == null || jobs.Count < 2)
{
logger.LogDebug("JobGroup {JobGroupId} содержит менее двух Job. Синхронизация фильтров не требуется.", jobGroupId);
logger.LogDebug("JobGroup {JobGroupId} содержит менее двух Job. Синхронизация не требуется.", jobGroupId);
return;
}
@@ -41,7 +53,7 @@ public class JobGroupUnitFilterSynchronizeHelper
return;
}
logger.LogInformation("Начало синхронизации фильтров для JobGroup {JobGroupId}. Эталонный Job: {ReferenceJobId}.", jobGroupId, referenceJob.Id);
logger.LogInformation("Начало синхронизации для JobGroup {JobGroupId}. Эталонный Job: {ReferenceJobId}.", jobGroupId, referenceJob.Id);
foreach (var job in jobs)
{
@@ -49,31 +61,84 @@ public class JobGroupUnitFilterSynchronizeHelper
if (job.Id == referenceJob.Id) continue;
if (!AreUnitFiltersIdentical(job.UnitFilters, referenceJob.UnitFilters))
if (!AreJobsIdentical(job, referenceJob))
{
logger.LogDebug("Фильтры Job {JobId} отличаются от эталона. Запуск обновления.", job.Id);
await ApplyFilterCorrectionAsync(job.Id, referenceJob.UnitFilters ?? Enumerable.Empty<JobUnitFilter>(), initiator, ct);
logger.LogDebug("Свойства Job {JobId} отличаются от эталона. Запуск обновления.", job.Id);
await ApplyJobCorrectionAsync(job.Id, referenceJob, initiator, ct);
}
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()
.Include(j => j.UnitFilters)
.ThenInclude(uf => uf.FieldFilters)
.Include(j => j.UnitFilters)
.ThenInclude(uf => uf.RelationshipFilters)
.Include(j => j.AutoControl)
.FirstOrDefaultAsync(j => j.Id == jobId, ct);
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)
{
job.UnitFilters = new List<JobUnitFilter>();
@@ -83,14 +148,14 @@ public class JobGroupUnitFilterSynchronizeHelper
job.UnitFilters.Clear();
}
var clonedFilters = CloneFilters(referenceFilters, job.Id);
var clonedFilters = CloneFilters(referenceJob.UnitFilters ?? Enumerable.Empty<JobUnitFilter>(), job.Id);
foreach (var filter in clonedFilters)
{
job.UnitFilters!.Add(filter);
}
await jobRepository.CommitAsync(initiator);
logger.LogInformation("Фильтры Job {JobId} успешно обновлены.", jobId);
logger.LogInformation("Job {JobId} успешно синхронизирован с эталоном.", jobId);
}
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>
{
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
{
public UnitFieldRepository(DataContext dataContext, ILogger<UnitFieldRepository> logger) : base(logger, dataContext) { }
public async Task<UnitField?> GetByAihitNameAsync(string name)
public async Task<UnitField?> GetByCodeAsync(string code, CancellationToken ct = default)
{
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.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;
using PARR.TemplateMatcher.Services.Interfaces;
@@ -12,42 +15,44 @@ 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)
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)
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. Определяем поле для внутренней группировки
var isGroupByResponsible = jobGroup.IsGroupByResponsible == true;
var innerGroupingFieldName = isGroupByResponsible ? "ОТВЕТСТВЕННЫЙ_ЗАК" : "РАБОЧАЯ_ГР_ОТВ_ЗАК";
// 1. Определяем стратегию внутренней группировки
// Если IsGroupByResponsible != true, используем WorkGroupMask через ShortcodesService
bool useWorkGroupMask = jobGroup.IsGroupByResponsible != true;
var innerGroupingField = await unitFieldRepository.GetByAihitNameAsync(innerGroupingFieldName)
?? throw new InvalidOperationException($"Поле '{innerGroupingFieldName}' не найдено.");
var innerGroupingFieldId = innerGroupingField.Id;
var groupingFieldId = jobGroup.GroupingUnitFieldId!.Value;
logger.LogDebug("Стратегия внутренней группировки: {Strategy}",
useWorkGroupMask ? "WorkGroupMask" : "Поле 'Ответственный за ЭК'");
// 2. Собираем все исходные UnitId
var allSourceUnitIds = initialReverseMapping.Values.SelectMany(ids => ids).Distinct().ToList();
// 3. Загружаем UnitInValue для трансформации
// 3. Загружаем UnitInValue для трансформации по полю группировки из настроек JobGroup
var groupingFieldId = jobGroup.GroupingUnitFieldId!.Value;
var relevantUnitInValues = await unitInValueRepository.Get()
.AsNoTracking()
.Where(uiv => allSourceUnitIds.Contains(uiv.UnitId) && uiv.FieldId == groupingFieldId)
@@ -58,9 +63,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var uivLookup = relevantUnitInValues
.GroupBy(x => x.UnitId)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.ValueId).ToList());
.ToDictionary(g => g.Key, g => g.Select(x => x.ValueId).ToList());
// 4. Трансформируем в reverseMapping с парами
var reverseMapping = new Dictionary<Guid, List<(Guid UnitId, Guid UnitFieldValueId)>>();
@@ -83,7 +86,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var uniqueEntries = entries
.GroupBy(e => (e.UnitId, e.UnitFieldValueId))
.Select(g => g.First())
.OrderBy(e => e.UnitId) // ИСПРАВЛЕНО: стабильная сортировка
.OrderBy(e => e.UnitId)
.ThenBy(e => e.UnitFieldValueId)
.ToList();
@@ -94,18 +97,62 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
if (!reverseMapping.Any())
return new List<GroupedTemplateGroup>();
// 5. Загружаем значения для внутренней группировки
var allUnitsInTemplatePairs = reverseMapping.Values.SelectMany(list => list).ToList();
var allUnitIdsForInnerGrouping = allUnitsInTemplatePairs.Select(e => e.UnitId).Distinct().ToList();
// 5. Определяем значения для внутренней группировки
Dictionary<Guid, string> unitIdToGroupingValueMap;
var innerGroupingValues = await unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(
allUnitIdsForInnerGrouping,
new HashSet<Guid> { innerGroupingFieldId },
ct);
if (useWorkGroupMask)
{
// Используем ShortcodesService для получения финальных значений WorkGroupMask
unitIdToGroupingValueMap = new Dictionary<Guid, string>();
var unitIdToInnerGroupingValueMap = innerGroupingValues
.Where(uv => uv.Value != null && !string.IsNullOrEmpty(uv.Value.Value))
.ToDictionary(uv => uv.UnitId, uv => uv.Value!.Value);
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>();
@@ -117,7 +164,7 @@ internal class GroupedTemplateBuilder : IGroupedTemplateBuilder
var unitsInTemplateForThisPotentialUnitId = kvp.Value;
var innerGroupedUnits = unitsInTemplateForThisPotentialUnitId
.GroupBy(entry => unitIdToInnerGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных"))
.GroupBy(entry => unitIdToGroupingValueMap.GetValueOrDefault(entry.UnitId, "Нет данных"))
.OrderBy(g => g.Key, StringComparer.Ordinal)
.ToList();