Compare commits

23 Commits

Author SHA1 Message Date
Mikhail Kuznetsov
fb20395e53 refactor(unitFilterService): логика фильтрации связанных ЭК перенесена в класс, который за это отвечает. 2026-07-31 10:46:48 +10:00
Mikhail Kuznetsov
94bea5c46d Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-30 14:18:14 +10:00
Mikhail Kuznetsov
349cf55862 feat(shortcodesService): Дополнительная детерминация списка в шорткоде СВЯЗИ(-ПН). 2026-07-30 14:18:04 +10:00
Mikhail Trubnikov
4cd6134ad4 fix(core): Исправлена логика выдачи заданий роботам при множественном переименовании, можно брать шаблоны в статусах used и unused 2026-07-30 10:53:41 +10:00
Mikhail Trubnikov
f10a50edae fix(core): Исправлена логика выдачи заданий роботам при множественном переименовании 2026-07-30 10:07:56 +10:00
Mikhail Trubnikov
0ab5b49371 Merge branch 'robot-task-details-2' into dev 2026-07-29 16:32:37 +10:00
Mikhail Trubnikov
acdb6ec893 feat(core,dal,domain,templateUpdater): Задания роботам выдаюстя с учетом подмены и множества переименований 2026-07-29 16:31:50 +10:00
Mikhail Trubnikov
7d5fb23daf feat(core,api): Детали по заданиями роботов 2026-07-29 09:50:21 +10:00
Mikhail Kuznetsov
e36e08aa73 Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-28 16:34:13 +10:00
Mikhail Kuznetsov
343468cf71 feat(unitFilter): Оптимизация фильрации юнитов. Проверка значений аттрибутов теперь выполняется в 2 запроса:
- проверяем доступные в базе значения
- проверяем наличие значений в юнитах по id значений
2026-07-28 16:34:04 +10:00
Mikhail Trubnikov
56cdca0502 feat(core): Задание роботам с подменой на переименование шаблона, выдается только для шаблонов в статусе StatusTypeId==Used 2026-07-28 16:15:01 +10:00
Mikhail Trubnikov
8798529b4d fix(api,core): Исправлена логика выдачи переименованных шаблонов роботам 2026-07-28 14:41:01 +10:00
Mikhail Trubnikov
556d895c7c fix(api): Исправлен маппинг для Template 2026-07-27 09:57:17 +10:00
Mikhail Trubnikov
58275d73f4 Merge branch 'template-rename-pending' into dev
# Conflicts:
#	PARR.DAL/Repositories/TemplateRepository.cs
2026-07-27 09:06:36 +10:00
Mikhail Trubnikov
eacda75649 refactor(core,domain): Переименованы shared dto 2026-07-24 13:35:56 +10:00
Mikhail Trubnikov
90e9f80505 feat(esppSync): При синхронизации шаблонов и расписаний не меняется статус у объектов находящихся на переименовании 2026-07-24 11:59:20 +10:00
Mikhail Trubnikov
b3879062a6 feat(api,core,domain): Изменен контроллер RobotTaskRobotStatusController, метод изменения статуса задания вынесен в сервисы. Добавлена логика успрешного переименования шаблона. 2026-07-23 14:39:55 +10:00
Mikhail Trubnikov
58b4d98b16 feat(dal,core): Рефакторинг методов и логики выдачи заданий роботом при переименовании шаблонов 2026-07-23 10:48:59 +10:00
Mikhail Kuznetsov
d1889460de feat(templateMatcher): Выбирать неактуальные шаблоны по дате последнего изменения, а не создания. Дата создания не гарантирует, что шаблон недавно освободился. 2026-07-23 09:34:59 +10:00
Mikhail Kuznetsov
5f384947a6 fix(templateMatcher): Добавлено управление транзакцией в момент захват неиспользуемого шаблона, для устранения гонки. 2026-07-23 08:36:17 +10:00
Mikhail Trubnikov
1cc2beb9ca feat(core): Логика выдачи задания роботу для переименованных шаблонов 2026-07-22 16:02:04 +10:00
Mikhail Trubnikov
0b0c0b04ad feat(dal,templateUpdater): Сохранение шаблонов для переименования в таблицу TemplateRenamePending 2026-07-22 09:49:27 +10:00
Mikhail Trubnikov
5094945c8e feat(dal,domain): Таблицы роботов перенесены в схему robot. Создана таблица TemplateRenamePendings - Шаблоны находящиеся в процессе переименования 2026-07-21 11:48:21 +10:00
94 changed files with 24198 additions and 940 deletions

View File

@@ -196,7 +196,6 @@ namespace PARR.AIHITMainSyncer.Services
}
// Поиск конфигурации поля для тега
// Рекомендация: если метод вызывается в цикле, передавайте найденное поле внешним слоем
var tagUnitField = fieldsFromDB.FirstOrDefault(f => f.Code == "tag");
bool isTagProperty = tagUnitField != null && multiValueProperty.Key == tagUnitField.AihitName;

View File

@@ -212,11 +212,21 @@ namespace PARR.API.Contracts.V1
public const string GetPeriodStatistics = BaseStat + "/robot-tasks/{robot}/period/";
}
public static class StatRobotTaskDetails
{
public const string Details = BaseStat + "/robot-tasks/details/{robot}/{task}";
}
public static class StatRobotStatus
{
public const string Get = BaseStat + "/robot-statuses/";
}
public static class StatRobotStatusDetails
{
public const string Details = BaseStat + "/robot-statuses/details/{robot}/{status}";
}
public static class StatRobotHistory
{
public const string Get = BaseStat + "/robot-histories/";

View File

@@ -1,12 +1,17 @@
namespace PARR.API.Contracts.V1.Responses
{
public class JobGroupBaseResponse
public class JobGroupShortResponse
{
public Guid Id { get; set; }
public required string Name { get; set; }
}
//public bool? IsUmbrella { get; set; }
public class JobGroupBaseResponse : JobGroupShortResponse
{
//public Guid Id { get; set; }
//public required string Name { get; set; }
public required string ShortDescription { get; set; }

View File

@@ -0,0 +1,16 @@
namespace PARR.API.Contracts.V1.Responses.Statistics
{
public record StatRobotTaskDetailsResponse
{
public RobotResponse Robot { get; init; } = null!;
public TaskStatusResponse Task { get; init; } = null!;
public List<StatRobotTaskGroupDetailsResponse> Details { get; init; } = null!;
}
public record StatRobotTaskGroupDetailsResponse
{
public JobGroupShortResponse JobGroup { get; init; } = null!;
public int TemplatesCount { get; init; }
}
}

View File

@@ -14,7 +14,7 @@ using PARR.API.Services.Interfaces;
using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Common.Pagination;
using PARR.Domain.Common.Roles;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.API.Controllers.V1
{

View File

@@ -1,39 +1,34 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Requests;
using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Controllers.V1.Base;
using PARR.API.Services.Interfaces;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.RobotTaskRobotStatus.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Entities;
using PARR.Domain.Enums;
using PARR.Domain.DTOs.RobotTaskRobotStatus;
namespace PARR.API.Controllers.V1
{
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class RobotTaskRobotStatusController : BaseApiController
{
private readonly IRobotConfigurationRepository robotConfigurationService;
private readonly IRobotHistoryRepository robotHistoryService;
private readonly IMapper mapper;
private readonly IClientService clientService;
private readonly IMapper _mapper;
private readonly IClientService _clientService;
private readonly IRobotTaskRobotStatusService _robotTaskRobotStatusService;
public RobotTaskRobotStatusController(
IRobotConfigurationRepository robotConfigurationService,
IRobotHistoryRepository robotHistoryService,
IMapper mapper,
IClientService clientService
IClientService clientService,
IRobotTaskRobotStatusService robotTaskRobotStatusService
)
{
this.robotConfigurationService = robotConfigurationService;
this.robotHistoryService = robotHistoryService;
this.mapper = mapper;
this.clientService = clientService;
_mapper = mapper;
_clientService = clientService;
_robotTaskRobotStatusService = robotTaskRobotStatusService;
}
@@ -45,47 +40,58 @@ namespace PARR.API.Controllers.V1
[HttpPut(ApiRoutes.RobotTaskRobotStatus.ChangeRobotStatus)]
public async Task<IActionResult> ChangeStatus([FromRoute] Guid taskId, [FromBody] RobotTaskChangeRobotStatusRequest request)
{
var config = await robotConfigurationService.Get()
.FirstOrDefaultAsync(t => t.Id == taskId);
#region Old
if (config == null)
return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = $"Не найдено задание с id: {taskId}" } }));
//var config = await _robotConfigurationRepository.Get()
// .FirstOrDefaultAsync(t => t.Id == taskId);
//изменение статуса робота
robotConfigurationService.ChangeRobotStatus(request.RobotStatusCode, config);
//if (config == null)
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = $"Не найдено задание с id: {taskId}" } }));
//если успех, изменяем статус задания на успех
if (request.RobotStatusCode == RobotStatusEnum.Complete)
robotConfigurationService.ChangeTaskStatus(TaskStatusEnum.Ok, config);
////изменение статуса робота
//_robotConfigurationRepository.ChangeRobotStatus(request.RobotStatusCode, config);
if (!await robotConfigurationService.CommitAsync())
return BadRequest("Ошибка при изменении статуса работы робота.");
////если успех, изменяем статус задания на успех
//if (request.RobotStatusCode == RobotStatusEnum.Complete)
// _robotConfigurationRepository.ChangeTaskStatus(TaskStatusEnum.Ok, config);
//записываем в лог робота
if (request.RobotStatusCode == RobotStatusEnum.InProgress || request.RobotStatusCode == RobotStatusEnum.Complete)
{
var historyLevel = request.RobotStatusCode == RobotStatusEnum.InProgress ? RobotHistoryLevelEnum.Start : RobotHistoryLevelEnum.Complete;
//if (!await _robotConfigurationRepository.CommitAsync())
// return BadRequest("Ошибка при изменении статуса работы робота.");
var history = new RobotHistory
{
Id = Guid.NewGuid(),
HistoryLevel = (int)historyLevel,
TaskStatusCode = config.TaskStatusCode,
RobotConfigurationId = config.Id,
RobotIp = clientService.GetClientIp()?.ToString(),
RobotId = request.RobotId
};
await robotHistoryService.CreateAsync(history);
await robotHistoryService.CommitAsync();
}
////записываем в лог робота
//if (request.RobotStatusCode == RobotStatusEnum.InProgress || request.RobotStatusCode == RobotStatusEnum.Complete)
//{
// var historyLevel = request.RobotStatusCode == RobotStatusEnum.InProgress ? RobotHistoryLevelEnum.Start : RobotHistoryLevelEnum.Complete;
var configToResponse = await robotConfigurationService.Get()
.Include(t => t.Robot)
.Include(t => t.TaskStatus)
.Include(t => t.RobotStatus)
.FirstOrDefaultAsync(t => t.Id == taskId);
// var history = new RobotHistory
// {
// Id = Guid.NewGuid(),
// HistoryLevel = (int)historyLevel,
// TaskStatusCode = config.TaskStatusCode,
// RobotConfigurationId = config.Id,
// RobotIp = _clientService.GetClientIp()?.ToString(),
// RobotId = request.RobotId
// };
// await _robotHistoryRepository.CreateAsync(history);
// await _robotHistoryRepository.CommitAsync();
//}
var response = mapper.Map<RobotConfigurationResponse>(configToResponse);
//var configToResponse = await _robotConfigurationRepository.Get()
// .Include(t => t.Robot)
// .Include(t => t.TaskStatus)
// .Include(t => t.RobotStatus)
// .FirstOrDefaultAsync(t => t.Id == taskId);
//var response = _mapper.Map<RobotConfigurationResponse>(configToResponse);
//return Ok(new Response<RobotConfigurationResponse>(response, true));
#endregion
var changeRequest = new ChangeRobotStatus(taskId, request.RobotStatusCode, request.RobotId, _clientService.GetClientIp()?.ToString());
var result = await _robotTaskRobotStatusService.ChangeStatusAsync(changeRequest);
var response = _mapper.Map<RobotConfigurationResponse>(result);
return Ok(new Response<RobotConfigurationResponse>(response, true));
}

View File

@@ -12,7 +12,7 @@ using PARR.API.Controllers.V1.Base;
using PARR.API.Helpers;
using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.API.Controllers.V1.Statistics
{

View File

@@ -0,0 +1,48 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Services.RobotStatusDetails.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Enums;
namespace PARR.API.Controllers.V1.Statistics
{
/// <summary>
/// Детальная статистика по статусам заданий роботам
/// </summary>
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class StatRobotStatusDetailsController : BaseApiController
{
private readonly IMapper _mapper;
private readonly IRobotStatusDetailsService _robotStatusDetailsService;
public StatRobotStatusDetailsController(
IMapper mapper,
IRobotStatusDetailsService robotStatusDetailsService
)
{
_mapper = mapper;
_robotStatusDetailsService = robotStatusDetailsService;
}
/// <summary>
/// Список групп работ по статусам заданий роботам
/// </summary>
/// <param name="robot"></param>
/// <param name="status"></param>
/// <returns></returns>
[HttpGet(ApiRoutes.StatRobotStatusDetails.Details)]
public async Task<IActionResult> Details([FromRoute] RobotsEnum robot, [FromRoute] RobotStatusEnum status)
{
var details = await _robotStatusDetailsService.GetDetailsAsync(robot, status);
var response = _mapper.Map<StatRobotStatusDetailsResponse>(details);
return Ok(new Response<StatRobotStatusDetailsResponse>(response, true));
}
}
}

View File

@@ -0,0 +1,19 @@
using PARR.API.Contracts.V1.Responses;
namespace PARR.API.Controllers.V1.Statistics
{
public record StatRobotStatusDetailsResponse
{
public RobotResponse Robot { get; init; } = null!;
public RobotStatusResponse Status { get; init; } = null!;
public List<StatRobotStatusGroupDetailsResponse> Details { get; init; } = null!;
}
public record StatRobotStatusGroupDetailsResponse
{
public JobGroupShortResponse JobGroup { get; init; } = null!;
public int TemplatesCount { get; init; }
}
}

View File

@@ -0,0 +1,49 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Services.RobotTaskDetailsServices.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Enums;
namespace PARR.API.Controllers.V1.Statistics
{
/// <summary>
/// Детальная статистика по заданиям роботам
/// </summary>
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class StatRobotTaskDetailsController : BaseApiController
{
private readonly IMapper _mapper;
private readonly IRobotTaskDetailsService _robotTaskDetailsService;
public StatRobotTaskDetailsController(
IMapper mapper,
IRobotTaskDetailsService robotTaskDetailsService
)
{
_mapper = mapper;
_robotTaskDetailsService = robotTaskDetailsService;
}
/// <summary>
/// Список групп работ по заданиям роботам
/// </summary>
/// <param name="robot"></param>
/// <param name="task"></param>
/// <returns></returns>
[HttpGet(ApiRoutes.StatRobotTaskDetails.Details)]
public async Task<IActionResult> Details([FromRoute] RobotsEnum robot, [FromRoute] TaskStatusEnum task)
{
var details = await _robotTaskDetailsService.GetDetailsAsync(robot, task);
var response = _mapper.Map<StatRobotTaskDetailsResponse>(details);
return Ok(new Response<StatRobotTaskDetailsResponse>(response, true));
}
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Requests.BaseRequests;
@@ -8,10 +9,12 @@ using PARR.API.Controllers.V1.Base;
using PARR.API.Helpers;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.NextRunServices;
using PARR.Domain.Common.Roles;
using PARR.Domain.Enums;
namespace PARR.API.Controllers.V1.Statistics
{
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class StatTemplateAutoControlController : BaseApiController
{
private readonly ITemplateRepository templateService;

View File

@@ -18,7 +18,7 @@ namespace PARR.API.Infrastructure.Middleware
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
logger.LogError(exception, "Ошибка во время запроса {TraceId}: {Message}", httpContext.TraceIdentifier, exception.Message);
logger.LogDebug(exception, "Ошибка во время запроса {TraceId}: {Message}", httpContext.TraceIdentifier, exception.Message);
// определяем статус код, в зависимости от типа исключения

View File

@@ -2,12 +2,17 @@
using PARR.API.Authentication.Models;
using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Statistics;
using PARR.API.MappingProfiles.Resolvers;
using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Domain.DTOs.Matching;
using PARR.Domain.DTOs.RobotMetrics;
using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.DTOs.RobotStatusDetails;
using PARR.Domain.DTOs.RobotTask;
using PARR.Domain.DTOs.RobotTaskDetails;
using PARR.Domain.DTOs.RobotTaskRobotStatus;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.DTOs.Shortcode;
using PARR.Domain.DTOs.TaskDTO;
using PARR.Domain.DTOs.User;
@@ -16,6 +21,7 @@ using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.JobGroupEntities;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Entities.Schedule;
using PARR.Domain.Entities.Unit;
@@ -79,7 +85,7 @@ namespace PARR.API.MappingProfiles
.ForMember(d => d.Script, o => o.MapFrom(s => s.AgentScript))
.ForMember(d => d.TimeOutSec, o => o.MapFrom(s => s.AgentTimeOutSec));
CreateMap<PARR.Domain.Entities.TaskStatus, TaskStatusResponse>();
CreateMap<PARR.Domain.Entities.RobotEntities.TaskStatus, TaskStatusResponse>();
#region ScheduleResponseAreaTimeOffsetResponse
@@ -255,12 +261,21 @@ namespace PARR.API.MappingProfiles
CreateMap<Robot, RobotResponse>();
CreateMap<PARR.Domain.Entities.TaskStatus, TaskStatusResponse>();
CreateMap<PARR.Domain.Entities.RobotEntities.TaskStatus, TaskStatusResponse>();
//TODO: удалить этот маппинг, пока он нужен для шаблонов. TemplateController
CreateMap<RobotConfiguration, RobotConfigurationResponse>()
.ForMember(d => d.Robot, o => o.MapFrom(s => s.Robot))
.ForMember(d => d.TaskStatus, o => o.MapFrom(s => s.TaskStatus))
.ForMember(d => d.RobotStatus, o => o.MapFrom(s => s.RobotStatus));
//---
CreateMap<RobotResult, RobotResponse>();
CreateMap<RobotTaskStatusResult, TaskStatusResponse>();
CreateMap<RobotStatusResult, RobotStatusResponse>();
CreateMap<RobotConfigurationResult, RobotConfigurationResponse>();
// === RobotConfiguration ===
#endregion
@@ -364,6 +379,9 @@ namespace PARR.API.MappingProfiles
#region JobGroup
CreateMap<JobGroupShortResult, JobGroupShortResponse>()
.ForMember(d => d.Name, o => o.MapFrom(s => s.GroupName));
CreateMap<JobGroup, JobGroupBaseResponse>()
.Include<JobGroup, JobGroupResponse>()
.Include<JobGroup, JobGroupWithDistributionConfigResponse>()
@@ -509,6 +527,27 @@ namespace PARR.API.MappingProfiles
#endregion
#region StatRobotTaskDetailsResponse
CreateMap<RobotTaskGroupDetailsResult, StatRobotTaskGroupDetailsResponse>();
//todo: ForMember не нужен?
//.ForMember(d => d.JobGroup, o => o.MapFrom(s => s.JobGroup));
CreateMap<RobotTaskDetailsResult, StatRobotTaskDetailsResponse>();
//todo: ForMember не нужен?
//.ForMember(d => d.Details, o => o.MapFrom(s => s.Details));
#endregion
#region StatRobotStatusDetailsResponse
CreateMap<RobotStatusDetailsResult, StatRobotStatusDetailsResponse>();
CreateMap<RobotStatusGroupDetailsResult, StatRobotStatusGroupDetailsResponse>();
#endregion
}
}

View File

@@ -6,7 +6,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None"
}
},
"Serilog": {
@@ -14,7 +15,8 @@
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
}
},
@@ -111,7 +113,7 @@
}
],
"RabbitMq": {
"ThresholdConnections": 33
"ThresholdConnections": 32
}
},
"CommonSettings": {

View File

@@ -10,8 +10,14 @@ using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.NextRunServices.Subservices;
using PARR.Core.Services.RobotMetrics;
using PARR.Core.Services.RobotSnapshotServices;
using PARR.Core.Services.RobotStatusDetails.Implementations;
using PARR.Core.Services.RobotStatusDetails.Interfaces;
using PARR.Core.Services.RobotTask.Implementations;
using PARR.Core.Services.RobotTask.Interfaces;
using PARR.Core.Services.RobotTaskDetailsServices.Implementations;
using PARR.Core.Services.RobotTaskDetailsServices.Interfaces;
using PARR.Core.Services.RobotTaskRobotStatus.Implemetations;
using PARR.Core.Services.RobotTaskRobotStatus.Interfaces;
using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.Shortcodes.Handlers;
using PARR.Core.Services.Snapshots.Implementations;
@@ -111,6 +117,9 @@ namespace PARR.Core
services.AddScoped<IRobotTaskService, RobotTaskService>();
services.AddScoped<IRobotSnapshotService, RobotSnapshotService>();
services.AddScoped<IRobotTaskRobotStatusService, RobotTaskRobotStatusService>();
services.AddScoped<IRobotTaskDetailsService, RobotTaskDetailsService>();
services.AddScoped<IRobotStatusDetailsService, RobotStatusDetailsService>();
services.AddScoped<IUnitService, UnitService>();
services.AddScoped<UnitCacheService>();

View File

@@ -0,0 +1,17 @@
using AutoMapper;
using PARR.Domain.DTOs.RobotTaskRobotStatus;
using PARR.Domain.Entities;
namespace PARR.Core.Infrastructure.Mapping.RobotTaskRobotStatus
{
internal class RobotConfigurationResultMappingProfile : Profile
{
public RobotConfigurationResultMappingProfile()
{
CreateMap<RobotConfiguration, RobotConfigurationResult>()
.ForMember(d => d.Robot, o => o.MapFrom(s => s.Robot))
.ForMember(d => d.TaskStatus, o => o.MapFrom(s => s.TaskStatus))
.ForMember(d => d.RobotStatus, o => o.MapFrom(s => s.RobotStatus));
}
}
}

View File

@@ -0,0 +1,17 @@
using AutoMapper;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.Entities.JobGroupEntities;
namespace PARR.Core.Infrastructure.Mapping.Shared
{
public class JobGroupResultMappingProfile: Profile
{
public JobGroupResultMappingProfile()
{
CreateMap<JobGroup, JobGroupShortResult>()
.Include<JobGroup, JobGroupResult>();
CreateMap<JobGroup, JobGroupResult>();
}
}
}

View File

@@ -0,0 +1,14 @@
using AutoMapper;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Infrastructure.Mapping.Shared
{
public class RobotResultMappingProfile : Profile
{
public RobotResultMappingProfile()
{
CreateMap<Robot, RobotResult>();
}
}
}

View File

@@ -0,0 +1,14 @@
using AutoMapper;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Infrastructure.Mapping.Shared
{
public class RobotStatusResultMappingProfile : Profile
{
public RobotStatusResultMappingProfile()
{
CreateMap<RobotStatus, RobotStatusResult>();
}
}
}

View File

@@ -0,0 +1,13 @@
using AutoMapper;
using PARR.Domain.DTOs.Shared;
namespace PARR.Core.Infrastructure.Mapping.Shared
{
public class RobotTaskStatusResultMappingProfile : Profile
{
public RobotTaskStatusResultMappingProfile()
{
CreateMap<PARR.Domain.Entities.RobotEntities.TaskStatus, RobotTaskStatusResult>();
}
}
}

View File

@@ -49,5 +49,11 @@ namespace PARR.Core.Repositories.Interfaces
/// <param name="id"></param>
/// <returns></returns>
Task<bool> SetInProgressStatusAsync(Guid id);
/// <summary>
/// Установить статус робота - Ошибка, и поставить максимальное значение попыток
/// </summary>
/// <param name="configuration"></param>
void SetErrorRobotStatusAndMaxAttempts(RobotConfiguration configuration);
}
}

View File

@@ -1,4 +1,4 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Repositories.Interfaces
{

View File

@@ -1,5 +1,5 @@
using PARR.Core.Repositories.Base;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Repositories.Interfaces
{

View File

@@ -1,4 +1,4 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Repositories.Interfaces
{

View File

@@ -1,4 +1,4 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Repositories.Interfaces
{

View File

@@ -2,6 +2,6 @@
{
public interface IStatusTemplateRepository
{
IQueryable<Domain.Entities.TaskStatus> Get();
IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get();
}
}

View File

@@ -2,6 +2,6 @@
{
public interface ITaskStatusRepository
{
IQueryable<Domain.Entities.TaskStatus> Get();
IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get();
}
}

View File

@@ -0,0 +1,11 @@
using PARR.Domain.Entities.TemplateEntities;
namespace PARR.Core.Repositories.Interfaces.TemplateRepositories
{
public interface ITemplateRenamePendingRepository
{
Task<bool> CreateAsync(TemplateRenamePending obj);
IQueryable<TemplateRenamePending> Get();
void Remove(TemplateRenamePending obj);
}
}

View File

@@ -3,8 +3,8 @@ using PARR.Domain.Entities.Unit;
namespace PARR.Core.Repositories.Interfaces.Unit
{
public interface IUnitFieldValueRepository: IBaseRepository<UnitFieldValue>
public interface IUnitFieldValueRepository : IBaseRepository<UnitFieldValue>
{
Task<UnitFieldValue?> GetByValueNameAsync(string? value);
Task<List<Guid>> FindValueIdsByMaskAsync(string mask, CancellationToken ct = default);
}
}

View File

@@ -4,19 +4,11 @@ namespace PARR.Core.Repositories.Interfaces.Unit
{
public interface IUnitInUnitRepository
{
Task<List<UnitInUnit>> GetByParentIdAsync(Guid parentId);
Task<List<UnitInUnit>> GetByChildIdAsync(Guid childId);
/// <summary>
/// Получает связи, где ChildUnitId unitIds (для IsParent=True).
/// Возвращает все связанные UnitId для заданного юнита в обоих направлениях.
/// Единая точка загрузки связей.
/// </summary>
Task<List<UnitInUnit>> GetParentLinksByChildIdsAsync(IEnumerable<Guid> childUnitIds);
/// <summary>
/// Получает связи, где ParentUnitId unitIds (для IsParent=False).
/// </summary>
Task<List<UnitInUnit>> GetChildLinksByParentIdsAsync(IEnumerable<Guid> parentUnitIds);
Task<List<Guid>> GetRelatedUnitIdsAsync(Guid unitId, CancellationToken ct = default);
IQueryable<UnitInUnit> Get();
}

View File

@@ -4,18 +4,18 @@ namespace PARR.Core.Repositories.Interfaces.Unit
{
public interface IUnitRepository : IBaseRepository<Domain.Entities.Unit.Unit>
{
/// <summary>
/// Поиск юнитов по списку ID значений.
/// Используется для эффективной фильтрации после предварительного поиска ValueId.
/// </summary>
Task<List<Guid>> FindUnitIdsByValueIdsAsync(
IReadOnlyList<Guid> unitIds,
Guid fieldId,
IReadOnlyList<Guid> valueIds,
CancellationToken ct = default);
IQueryable<Guid> GetInitialUnitIds(string dbValueMask);
/// <summary>
/// Получить юниты по Id атрибута и маски значения
/// </summary>
/// <param name="query"></param>
/// <param name="fieldId"></param>
/// <param name="valueMask"></param>
/// <param name="isInverse">true - не содержит, false - содержит</param>
/// <returns></returns>
IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false);
IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes();
}
}

View File

@@ -0,0 +1,88 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.RobotStatusDetails.Interfaces;
using PARR.Domain.DTOs.RobotStatusDetails;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.Enums;
using PARR.Domain.Exceptions;
namespace PARR.Core.Services.RobotStatusDetails.Implementations
{
internal class RobotStatusDetailsService : IRobotStatusDetailsService
{
private readonly ILogger<RobotStatusDetailsService> _logger;
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly IMapper _mapper;
private readonly IRobotRepository _robotRepository;
private readonly IRobotStatusRepository _robotStatusRepository;
public RobotStatusDetailsService(
ILogger<RobotStatusDetailsService> logger,
IRobotConfigurationRepository robotConfigurationRepository,
IMapper mapper,
IRobotRepository robotRepository,
IRobotStatusRepository robotStatusRepository
)
{
_logger = logger;
_robotConfigurationRepository = robotConfigurationRepository;
_mapper = mapper;
_robotRepository = robotRepository;
_robotStatusRepository = robotStatusRepository;
}
public async Task<RobotStatusDetailsResult> GetDetailsAsync(RobotsEnum robot, RobotStatusEnum status, CancellationToken cancellationToken = default)
{
var groupedDetails = await _robotConfigurationRepository.Get()
.AsNoTracking()
.Where(config => config.RobotCode == (int)robot && config.RobotStatusCode == (int)status)
.GroupBy(config => config.Template!.Job!.Group)
.Select(t => new
{
JobGroup = t.Key,
TemplatesCount = t.Count()
})
.ToListAsync(cancellationToken);
var robotEntity = await _robotRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Code == (int)robot, cancellationToken);
var robotStatusEntity = await _robotStatusRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Code == (int)status, cancellationToken);
if (robotEntity == null)
{
_logger.LogWarning("Робот с кодом {RobotCode} не найден в БД", robot);
throw new AppValidationException($"Робот с кодом {(int)robot} не найден");
}
if (robotStatusEntity == null)
{
_logger.LogWarning("Статус робота с кодом {StatusCode} не найден в БД", status);
throw new AppValidationException($"Статус робота с кодом {(int)status} не найден");
}
var result = new RobotStatusDetailsResult
{
Robot = _mapper.Map<RobotResult>(robotEntity),
Status = _mapper.Map<RobotStatusResult>(robotStatusEntity),
Details = groupedDetails
.Select(t => new RobotStatusGroupDetailsResult
{
JobGroup = _mapper.Map<JobGroupShortResult>(t.JobGroup),
TemplatesCount = t.TemplatesCount
}).OrderBy(t => t.JobGroup.GroupName)
.ToList()
};
return result;
}
}
}

View File

@@ -0,0 +1,10 @@
using PARR.Domain.DTOs.RobotStatusDetails;
using PARR.Domain.Enums;
namespace PARR.Core.Services.RobotStatusDetails.Interfaces
{
public interface IRobotStatusDetailsService
{
Task<RobotStatusDetailsResult> GetDetailsAsync(RobotsEnum robot, RobotStatusEnum status, CancellationToken cancellationToken = default);
}
}

View File

@@ -3,12 +3,15 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.BLL.Helpers;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.RobotTask.Interfaces;
using PARR.Core.Services.RobotTask.Models;
using PARR.Core.Services.Shortcodes;
using PARR.Domain.DTOs.RobotTask;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums;
using PARR.Domain.Exceptions;
using PARR.Domain.Settings;
@@ -19,16 +22,18 @@ namespace PARR.Core.Services.RobotTask.Implementations
{
/// <summary>
/// Количество заданий которые рассматриваем для взятия в работу.
/// Рекомендованное значение, кол-во роботов * 3
/// </summary>
private readonly int TakeTasks = 10;
private readonly int TakeTasks = 15 * 3;
private readonly ILogger<RobotTaskService> logger;
private readonly IRobotConfigurationRepository robotConfigurationRepository;
private readonly SettingsFromDb settingsFromDb;
private readonly IRobotHistoryRepository robotHistoryRepository;
private readonly IMapper mapper;
private readonly IShortcodesService shortcodesService;
private readonly INextRunService nextRunService;
private readonly ILogger<RobotTaskService> _logger;
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly SettingsFromDb _settingsFromDb;
private readonly IRobotHistoryRepository _robotHistoryRepository;
private readonly IMapper _mapper;
private readonly IShortcodesService _shortcodesService;
private readonly INextRunService _nextRunService;
private readonly ITemplateRenamePendingRepository _templateRenamePendingRepository;
public RobotTaskService(
ILogger<RobotTaskService> logger,
@@ -37,16 +42,18 @@ namespace PARR.Core.Services.RobotTask.Implementations
IRobotHistoryRepository robotHistoryRepository,
IMapper mapper,
IShortcodesService shortcodesService,
INextRunService nextRunService
INextRunService nextRunService,
ITemplateRenamePendingRepository templateRenamePendingRepository
)
{
this.logger = logger;
this.robotConfigurationRepository = robotConfigurationRepository;
this.settingsFromDb = settingsFromDb;
this.robotHistoryRepository = robotHistoryRepository;
this.mapper = mapper;
this.shortcodesService = shortcodesService;
this.nextRunService = nextRunService;
_logger = logger;
_robotConfigurationRepository = robotConfigurationRepository;
_settingsFromDb = settingsFromDb;
_robotHistoryRepository = robotHistoryRepository;
_mapper = mapper;
_shortcodesService = shortcodesService;
_nextRunService = nextRunService;
_templateRenamePendingRepository = templateRenamePendingRepository;
}
@@ -54,19 +61,19 @@ namespace PARR.Core.Services.RobotTask.Implementations
{
var templateTask = await GetTaskAsync(RobotsEnum.TemplateOrder, taskStatusCode, acquireTask, robotIp, robotId, TimeSpan.Zero);
var task = mapper.Map<RobotTaskTemplate>(templateTask);
var task = _mapper.Map<RobotTaskTemplate>(templateTask);
task = task with { FullDescription = NormalizeLineEndingsToCrlf(await shortcodesService.ApplyShortcodesAsync(task.FullDescription, templateTask.Template!)) };
task = task with { ShortDescription = await shortcodesService.ApplyShortcodesAsync(task.ShortDescription, templateTask.Template!) };
task = task with { Solution = NormalizeLineEndingsToCrlf(await shortcodesService.ApplyShortcodesAsync(task.Solution, templateTask.Template!)) };
task = task with { TnkName = await shortcodesService.ApplyShortcodesAsync(task.TnkName, templateTask.Template!) };
task = task with { WorkName = await shortcodesService.ApplyShortcodesAsync(task.WorkName, templateTask.Template!) };
task = task with { WorkGroup = await shortcodesService.ApplyShortcodesAsync(task.WorkGroup, templateTask.Template!) };
task = task with { ResponseArea = await shortcodesService.ApplyShortcodesAsync(task.ResponseArea, templateTask.Template!) };
task = task with { FullDescription = NormalizeLineEndingsToCrlf(await _shortcodesService.ApplyShortcodesAsync(task.FullDescription, templateTask.Template!)) };
task = task with { ShortDescription = await _shortcodesService.ApplyShortcodesAsync(task.ShortDescription, templateTask.Template!) };
task = task with { Solution = NormalizeLineEndingsToCrlf(await _shortcodesService.ApplyShortcodesAsync(task.Solution, templateTask.Template!)) };
task = task with { TnkName = await _shortcodesService.ApplyShortcodesAsync(task.TnkName, templateTask.Template!) };
task = task with { WorkName = await _shortcodesService.ApplyShortcodesAsync(task.WorkName, templateTask.Template!) };
task = task with { WorkGroup = await _shortcodesService.ApplyShortcodesAsync(task.WorkGroup, templateTask.Template!) };
task = task with { ResponseArea = await _shortcodesService.ApplyShortcodesAsync(task.ResponseArea, templateTask.Template!) };
task = task with { ClosingCode = settingsFromDb.ClosingCode };
task = task with { Initiator = settingsFromDb.Initiator };
task = task with { Category = settingsFromDb.Category };
task = task with { ClosingCode = _settingsFromDb.ClosingCode };
task = task with { Initiator = _settingsFromDb.Initiator };
task = task with { Category = _settingsFromDb.Category };
return task;
}
@@ -81,22 +88,22 @@ namespace PARR.Core.Services.RobotTask.Implementations
var resultUpdateNextRun = await UpdateNextRunAsync(scheduleTask, historyInitiator);
if (!resultUpdateNextRun)
{
logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}", scheduleTask.TemplateId);
_logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}", scheduleTask.TemplateId);
throw new NextRunException($"Ошибка при расчете NextRun для templateId: {scheduleTask.TemplateId}");
}
var task = mapper.Map<RobotTaskSchedule>(scheduleTask);
var task = _mapper.Map<RobotTaskSchedule>(scheduleTask);
task = task with { Timezone = settingsFromDb.EsppScheduleTimezone };
task = task with { WorkGroup = await shortcodesService.ApplyShortcodesAsync(task.WorkGroup, scheduleTask.Template!) };
task = task with { ResponseArea = await shortcodesService.ApplyShortcodesAsync(task.ResponseArea, scheduleTask.Template!) };
task = task with { Timezone = _settingsFromDb.EsppScheduleTimezone };
task = task with { WorkGroup = await _shortcodesService.ApplyShortcodesAsync(task.WorkGroup, scheduleTask.Template!) };
task = task with { ResponseArea = await _shortcodesService.ApplyShortcodesAsync(task.ResponseArea, scheduleTask.Template!) };
//nextRun в часовой зоне УЗ Робота ЕСПП
var nextRunWithRobotTz = scheduleTask.Template!.NextRun.Add(nextRunService.GetEsppAccountOffset());
var nextRunWithRobotTz = scheduleTask.Template!.NextRun.Add(_nextRunService.GetEsppAccountOffset());
//на всякий случай еще раз проверяем, что дата не устарела и отправляем задание
if (nextRunWithRobotTz < DateTimeOffset.UtcNow)
{
logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}, итоговое значение для робота, меньше чем сейчас {nextRunWithRobotTz}<{now}",
_logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}, итоговое значение для робота, меньше чем сейчас {nextRunWithRobotTz}<{now}",
task.TemplateId, nextRunWithRobotTz, DateTimeOffset.UtcNow);
throw new NextRunException($"Ошибка при расчете NextRun для templateId: {scheduleTask.TemplateId}");
}
@@ -104,7 +111,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
task = task with { NextStart = EsppScheduleHelpers.GetNextRun(nextRunWithRobotTz) };
task = task with { GenerationTime = EsppScheduleHelpers.GetGenerationTime(nextRunWithRobotTz) };
task = task with { RepeatRange = settingsFromDb.ScheduleRepeatRange };
task = task with { RepeatRange = _settingsFromDb.ScheduleRepeatRange };
task = task with { };
return task;
@@ -124,7 +131,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
private async Task<RobotConfiguration> GetTaskAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, TimeSpan scheduleCooldownDuration)
{
// 1. Ищем все задания с превышенным кол-вом попыток и просроченным временем, ставим им статус ошибки
await robotConfigurationRepository.MarkExpiredTasksAsFailedAsync(settingsFromDb.RobotAttemptsNumber, settingsFromDb.RobotWaitTime);
await _robotConfigurationRepository.MarkExpiredTasksAsFailedAsync(_settingsFromDb.RobotAttemptsNumber, _settingsFromDb.RobotWaitTime);
// 2. Ищем доступные задания
@@ -147,7 +154,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
{
// Берем первую задачу из списка доступных
acquiredTaskId = availableTasks.First();
logger.LogDebug("Задача не требует захвата, взята первая из доступных: {TaskId}", acquiredTaskId);
_logger.LogDebug("Задача не требует захвата, взята первая из доступных: {TaskId}", acquiredTaskId);
}
@@ -167,20 +174,14 @@ namespace PARR.Core.Services.RobotTask.Implementations
/// <returns></returns>
private async Task<List<Guid>> GetAvailableTasksAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode, TimeSpan scheduleCooldownDuration)
{
var query = robotConfigurationRepository.Get()
var query = _robotConfigurationRepository.Get()
.AsNoTracking()
.Where(t => t.RobotCode == (int)robotCode/* && t.TaskStatusCode == (int)taskStatusCode*/);
.Where(t => t.RobotCode == (int)robotCode);
// Если это задание для робота расписаний
if (robotCode == RobotsEnum.ScheduleOrder)
{
// Выбираем только записи с созданными шаблонами (у которых статус 30), а только потом ищем у них расписания
#region Старый не оптимизированный запрос
//var createdTemplates = robotConfigurationRepository.Get()
// .Where(t => t.RobotCode == (int)RobotsEnum.TemplateOrder && t.TaskStatusCode == (int)TaskStatusEnum.Ok)
// .Select(t => t.TemplateId);
//query = query.Where(t => createdTemplates.Contains(t.TemplateId));
#endregion
query = query.Where(t => t.Template!.RobotConfigurations.Any(x => x.RobotCode == (int)RobotsEnum.TemplateOrder && x.TaskStatusCode == (int)TaskStatusEnum.Ok));
@@ -199,20 +200,22 @@ namespace PARR.Core.Services.RobotTask.Implementations
// Сортируем по nextRun, чтобы те, у кого nextRun ближе к текущей, выполнились скорее
query = query.OrderBy(t => t.Template!.NextRun).ThenBy(t => t.Template!.IsActiveSchedule).ThenBy(t => t.Template!.IsActiveTemplate);
// Кандидаты заданий
var tasks = new List<Guid>();
// Кандидаты заданий, Id задания и имя шаблона
//var tasks = new List<Guid>();
var tasks = new List<RobotTaskDetails>();
// Ещем первые 10 заданий в статусе ОЖИДАНИЕ
// Ищем первые TakeTasks заданий в статусе ОЖИДАНИЕ
tasks = await query
.Where(t =>
t.RobotStatusCode == (int)RobotStatusEnum.Wait
&& t.TaskStatusCode == (int)taskStatusCode
).Take(TakeTasks)
.Select(t => t.Id)
//.Select(t => t.Id)
.Select(t => new RobotTaskDetails(t.Id, t.Template!.Name, t.Template.NextRun))
.ToListAsync();
logger.LogDebug("Найдено заданий в статусе 'Ожидание' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString());
_logger.LogDebug("Найдено заданий в статусе 'Ожидание' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString());
if (tasks.Count == 0)
{
@@ -221,20 +224,274 @@ namespace PARR.Core.Services.RobotTask.Implementations
// Далее проверяется `LastStatusUpdated`, что время последнего смены статуса не превышает допустимого(берется из настроек, поле `RobotWaitTime`)
// и что текущая попытка не больше разрешенной(берется из настроек, поле `RobotAttemptsNumber`) - если это так, берется эта запись.
var endDate = DateTimeOffset.UtcNow.Add(-settingsFromDb.RobotWaitTime);
var endDate = DateTimeOffset.UtcNow.Add(-_settingsFromDb.RobotWaitTime);
tasks = await query.Where(t => t.RobotStatusCode == (int)RobotStatusEnum.InProgress
&& t.TaskStatusCode==(int)taskStatusCode
&& t.AttemptsNumber < settingsFromDb.RobotAttemptsNumber
&& t.TaskStatusCode == (int)taskStatusCode
&& t.AttemptsNumber < _settingsFromDb.RobotAttemptsNumber
&& t.LastRobotStatusUpdated < endDate)
.Take(TakeTasks)
.Select(t => t.Id)
//.Select(t => t.Id)
.Select(t => new RobotTaskDetails(t.Id, t.Template!.Name, t.Template.NextRun))
.ToListAsync();
logger.LogDebug("Найдено заданий в статусе 'В работе' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString());
_logger.LogDebug("Найдено заданий в статусе 'В работе' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString());
}
return tasks;
if (robotCode == RobotsEnum.TemplateOrder)
{
// Если запрашиваем шаблоны, смотрим корректируем список заданий в зависимости от статуса переименования.
// Это не относится к расписаниям, потому что у переименованных расписаний статус Updating, а оно не возьмется в работу, пока не обновится шаблон
tasks = await ReplaceTemplateTasksForRenameAsync(tasks, robotCode);
}
return tasks.Select(t => t.TaskId).ToList();
}
/// <summary>
/// Проверяет наличие шаблонов в процессе переименования и заменяет обычные задания на задания по переименованию.
/// Если связанный шаблон не переименован, и у него статус ошибки, целевому шаблону устанавливается статус ошибки.
/// </summary>
/// <param name="tasks"></param>
/// <returns></returns>
private async Task<List<RobotTaskDetails>> ReplaceTemplateTasksForRenameAsync(List<RobotTaskDetails> tasks, RobotsEnum robotCode)
{
if (tasks.Count == 0 || robotCode != RobotsEnum.TemplateOrder)
return tasks;
_logger.LogDebug("Исходный пул задач для проверки переименования: {Tasks}",
string.Join(" | ", tasks.Select(t => $"[Id: {t.TaskId}, Name: '{t.TemplateName}']")));
// Ищем есть ли связанные шаблоны с таким имененм на переименование
var taskTemplateNames = tasks.Select(t => t.TemplateName).Distinct().ToList();
// Ищем записи в таблице переименований, где OldName совпадает с именами наших новых задач
var templatesToRename = await _templateRenamePendingRepository.Get()
.AsNoTracking()
.Where(t => taskTemplateNames.Contains(t.OldName))
.ToListAsync();
_logger.LogDebug("Найдено записей в TemplateRenamePending для текущих задач: {Count} шт.", templatesToRename.Count);
if (templatesToRename.Count == 0)
return tasks;
// Создаем словарь маппинга TemplateId -> OldName.
var templateIdToOldName = templatesToRename.ToDictionary(t => t.TemplateId, t => t.OldName);
// Ищем конфигурации роботов для СТАРЫХ шаблонов (которые переименовываются) по ИД, смотрим, можем ли взять их в работу
var renameTemplateIds = templatesToRename.Select(t => t.TemplateId).ToList();
var renameTasks = await _robotConfigurationRepository.Get()
.AsNoTracking()
.Include(t => t.Template)
.Where(t =>
t.RobotCode == (int)robotCode
&& renameTemplateIds.Contains(t.TemplateId)
// Это может быть только обновление. Так как переименования для создаваемого шаблона быть не может
&& t.TaskStatusCode == (int)TaskStatusEnum.Updating
).ToListAsync();
// =========================================================================
// БЛОК 1: ОБРАБОТКА ОШИБОК (Правило: если ХОТЯ БЫ ОДНА упала в ошибку -> оригинал в ошибку)
// =========================================================================
// Если старый шаблон в ошибке и лимит попыток исчерпан, ставим ошибку и новому шаблону
var errorTasks = renameTasks
.Where(t =>
t.RobotStatusCode == (int)RobotStatusEnum.Error
&& t.AttemptsNumber >= _settingsFromDb.RobotAttemptsNumber
).ToList();
var tasksToSetErrorStatus = new List<Guid>();
if (errorTasks.Count > 0)
{
_logger.LogDebug("Найдено связанных заданий на переименование с ошибками: {ErrorCount}. Ставим ошибку целевым (новым) заданиям.", errorTasks.Count);
// Собираем ВСЕ OldName, для которых есть хотя бы одна упавшая в ошибку задача.
// Использование ToHashSet() гарантирует, что если 1 или 10 задач в ошибке, OldName попадет в набор один раз.
var errorOldNames = errorTasks
.Where(t => templateIdToOldName.ContainsKey(t.TemplateId))
.Select(t => templateIdToOldName[t.TemplateId])
.ToHashSet();
// Находим оригинальные задачи, чье имя совпадает с любым из "ошибочных" OldName
tasksToSetErrorStatus = tasks
.Where(t => errorOldNames.Contains(t.TemplateName))
.Select(t => t.TaskId)
.ToList();
if (tasksToSetErrorStatus.Count > 0)
{
// Устанавливаем ошибку целевым + пишем комментарий от робота + нажимаем комит
var logMessage = "[RobotTaskService] Установлен статус ошибки, так как хотя бы одна из связанных задач переименования не была успешно выполнена.";
await SetErrorStatusAsync(tasksToSetErrorStatus, logMessage);
}
}
// =========================================================================
// БЛОК 2: ПОДМЕНА ЗАДАЧ (Правило: берем ПЕРВУЮ валидную задачу для подмены)
// =========================================================================
var endDate = DateTimeOffset.UtcNow.Add(-_settingsFromDb.RobotWaitTime);
// Фильтруем старые задачи, которые МОЖНО взять в работу. Смотрим статусы роботов, можно взять в работу, только если (RobotStatus == Wait) или (InProgress но которые еще не просрочены)
var allowedTasks = renameTasks.Where(t =>
t.RobotStatusCode == (int)RobotStatusEnum.Wait
|| (t.RobotStatusCode == (int)RobotStatusEnum.InProgress
&& t.AttemptsNumber < _settingsFromDb.RobotAttemptsNumber
&& t.LastRobotStatusUpdated < endDate)
).ToList();
// Проверим StatusTypeId у старых шаблонов в процессе переименования
// 1. Находим задачи переименования, у которых StatusTypeId шаблона НЕ является допустимым (!= Used и != Unused)
var invalidRenameTasks = allowedTasks
.Where(t => t.Template != null && t.Template.StatusTypeId != TemplateStatusTypeEnum.Used && t.Template.StatusTypeId != TemplateStatusTypeEnum.Unused)
.ToList();
// 2. Создаем словарь для быстрого поиска и логирования: OldName -> StatusTypeId
// Так как OldName не уникален, используем GroupBy, чтобы избежать ArgumentException, при наличии нескольких невалидных задач с одинаковым OldName.
//var invalidOldNamesWithStatus = invalidRenameTasks
// .Where(t => templateIdToOldName.ContainsKey(t.TemplateId))
// .Select(t => new { OldName = templateIdToOldName[t.TemplateId], StatusTypeId = t.Template!.StatusTypeId })
// .ToDictionary(x => x.OldName, x => x.StatusTypeId);
var invalidOldNamesWithStatus = invalidRenameTasks
.Where(t => templateIdToOldName.ContainsKey(t.TemplateId))
.GroupBy(t => templateIdToOldName[t.TemplateId]) // Группируем по OldName
.ToDictionary(
g => g.Key, // Ключ = OldName
g => g.First().Template!.StatusTypeId // Значение = StatusTypeId первой задачи в группе (для лога)
);
// 3. Оставляем для подмены только те задачи, у которых StatusTypeId является допустимым (== Used или == Unused)
var validAllowedTasks = allowedTasks
.Where(t => t.Template != null && (t.Template.StatusTypeId == TemplateStatusTypeEnum.Used || t.Template.StatusTypeId == TemplateStatusTypeEnum.Unused))
.ToList();
// Формируем список заданий
var originalCount = tasks.Count;
var errorTaskIdsSet = tasksToSetErrorStatus.ToHashSet();
var errorCount = errorTaskIdsSet.Count;
// Создаем словарь подмены ТОЛЬКО из валидных задач (где StatusTypeId == Used или Unused)
// ГРУППИРУЕМ по OldName и берем .First()!
// Это реализует правило: "если записей несколько, берем из них первую и подменяем ей оригинальное задание".
var renameTasksToDictionary = validAllowedTasks
.Where(t => templateIdToOldName.ContainsKey(t.TemplateId))
.GroupBy(t => templateIdToOldName[t.TemplateId])
.ToDictionary(
g => g.Key, // Ключ = OldName
g => new RobotTaskDetails(g.First().Id, g.First().Template!.Name, g.First().Template!.NextRun)
);
// Проходим по ИСХОДНОМУ списку, чтобы сохранить порядок сортировки
var finalTasks = new List<RobotTaskDetails>(tasks.Count);
int replacedCount = 0;
int excludedByStatusCount = 0; // Счетчик для логов
foreach (var task in tasks)
{
// 1. Если задаче нужно поставить ошибку, пропускаем ее
if (errorTaskIdsSet.Contains(task.TaskId))
{
continue;
}
// 2. Если этот шаблон связан с переименованием, но у старого шаблона StatusTypeId != Used
if (invalidOldNamesWithStatus.TryGetValue(task.TemplateName, out var badStatusId))
{
_logger.LogWarning(
"Задача для шаблона '{TemplateName}' (TaskId: {TaskId}) ИСКЛЮЧЕНА из выдачи. " +
"Связанный шаблон в процессе переименования имеет недопустимый StatusTypeId = {StatusTypeId} (ожидалось Used или Unused). " +
"Исходная задача также не выполняется.",
task.TemplateName, task.TaskId, badStatusId);
excludedByStatusCount++;
continue; // Не добавляем ни старую, ни новую задачу в итоговый список
}
// 3. Если для этого имени шаблона есть разрешенная задача на переименование (и она валидна) - вставляем ее
// Подменяем оригинальную задачу на ПЕРВУЮ валидную задачу переименования
if (renameTasksToDictionary.TryGetValue(task.TemplateName, out var renameTask))
{
_logger.LogDebug("ПОДМЕНА ЗАДАЧИ: Исходная [Id: {OriginalId}, Name: '{OriginalName}'] " +
"-> Заменена на [Id: {NewId}, Name: '{NewName}']",
task.TaskId, task.TemplateName, renameTask.TaskId, renameTask.TemplateName);
finalTasks.Add(renameTask);
replacedCount++;
}
else
{
// Иначе оставляем исходную задачу на месте
finalTasks.Add(task);
}
}
_logger.LogDebug("Итоговый пул задач после трансформации: {Tasks}",
string.Join(" | ", finalTasks.Select(t => $"[Id: {t.TaskId}, Name: '{t.TemplateName}']")));
_logger.LogInformation(
"Трансформация пула задач завершена. Исходных: {OriginalCount} шт. " +
"Отклонено (ошибка): {ErrorCount} шт. Исключено (невалидный StatusTypeId): {ExcludedCount} шт. " +
"Заменено на старые (взята первая из группы): {ReplacedCount} шт. Итого к выдаче: {FinalCount} шт.",
originalCount, errorCount, excludedByStatusCount, replacedCount, finalTasks.Count);
// Возвращаем без дополнительной сортировки по NextRun. Порядок сохранен начального списка
return finalTasks;
}
/// <summary>
/// Установить статус задания - ошибка
/// </summary>
/// <param name="taskIds"></param>
/// <returns></returns>
private async Task SetErrorStatusAsync(List<Guid> taskIds, string logMessage)
{
if (taskIds == null || taskIds.Count == 0)
return;
var tasks = await _robotConfigurationRepository.Get()
.Include(t => t.Template)
.Where(t => taskIds.Contains(t.Id))
.ToListAsync();
if (tasks.Count == 0)
return;
foreach (var task in tasks)
{
// Так как это целевой шаблон, то ставим ему сразу максимальное кол-во попыток и ошибку, чтоб больше он не выдавался в заданиях, пока не исправим связанный
// Устанавливаем статус ошибки
_robotConfigurationRepository.SetErrorRobotStatusAndMaxAttempts(task);
// Пишем в лог роботу
var history = new RobotHistory
{
Id = Guid.NewGuid(),
HistoryLevel = (int)RobotStatusEnum.Error,
TaskStatusCode = task.TaskStatusCode,
RobotConfigurationId = task.Id,
RobotIp = null,
RobotId = ParrComponentsEnum.Api.ToString(),
RobotMessage = logMessage
};
await _robotHistoryRepository.CreateAsync(history);
_logger.LogInformation("Для целевого задания {TaskId} (шаблон '{TemplateName}') установлен статус ошибки, " +
"так как связанное задание со старым шаблоном не было успешно выполнено.",
task.Id, task.Template!.Name);
}
if (await _robotHistoryRepository.CommitAsync())
{
_logger.LogDebug("Установлен статус 'Ошибка', для заданий {TaskCount} шт.", tasks.Count);
}
else
{
_logger.LogError("Ошибка при установке статуса задания 'Ошибка', для заданий {TaskCount} шт. Транзакция отменена", tasks.Count);
throw new DbErrorException("Не удалось сохранить изменения статусов заданий при обработке переименования шаблона.");
}
}
@@ -247,12 +504,12 @@ namespace PARR.Core.Services.RobotTask.Implementations
{
foreach (var taskId in tasks)
{
var isChangedStatus = await robotConfigurationRepository.SetInProgressStatusAsync(taskId);
var isChangedStatus = await _robotConfigurationRepository.SetInProgressStatusAsync(taskId);
if (isChangedStatus)
{
logger.LogDebug("Захвачена задача {TaskId}", taskId);
_logger.LogDebug("Захвачена задача {TaskId}", taskId);
var task = await robotConfigurationRepository.Get()
var task = await _robotConfigurationRepository.Get()
.AsNoTracking()
.FirstAsync(t => t.Id == taskId);
@@ -267,18 +524,18 @@ namespace PARR.Core.Services.RobotTask.Implementations
RobotId = robotId
};
if (!await robotHistoryRepository.CreateAsync(history) || !await robotHistoryRepository.CommitAsync())
if (!await _robotHistoryRepository.CreateAsync(history) || !await _robotHistoryRepository.CommitAsync())
throw new DbErrorException("Ошибка при добавлении истории робота, при взятии задания в работу.");
return taskId;
}
else
{
logger.LogDebug("Не удалось захватить задачу {TaskId}", taskId);
_logger.LogDebug("Не удалось захватить задачу {TaskId}", taskId);
}
}
logger.LogDebug("Не удалось захватить ни одну из доступных задач для робота");
_logger.LogDebug("Не удалось захватить ни одну из доступных задач для робота");
return null;
}
@@ -292,7 +549,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
/// <returns></returns>
private async Task<RobotConfiguration> GetTaskWithAllDataAsync(Guid taskId, RobotsEnum robotCode)
{
IQueryable<RobotConfiguration> query = robotConfigurationRepository.Get()
IQueryable<RobotConfiguration> query = _robotConfigurationRepository.Get()
//.AsNoTracking() // нужно обязательно трекать, так как может измениться nextRun и его нужно будет сохранить
.AsSingleQuery()
// Общие инклуды для шаблонов и расписаний
@@ -371,23 +628,23 @@ namespace PARR.Core.Services.RobotTask.Implementations
var template = task.Template!;
//var nextRun = await esppScheduleTransformService.GetNextDateAsync(template.Job!.GroupId, template!.Job!.Group!.ReferenceDate);
var nextRun = await nextRunService.GetNextRunForTemplateAsync(template.Id, false);
var nextRun = await _nextRunService.GetNextRunForTemplateAsync(template.Id, false);
if (!nextRun.HasValue)
{
logger.LogError("При обновлении nextRun для шаблона {templateId}, расчитанный nextRun=null, ошибка в расчетах.", template.Id);
_logger.LogError("При обновлении nextRun для шаблона {TemplateId}, расчитанный nextRun=null, ошибка в расчетах.", template.Id);
return false;
}
if (nextRun.Value < DateTimeOffset.UtcNow)
{
logger.LogError("При обновлении nextRun для шаблона {templateId}, расчитанный nextRun<Now [{nextRun}<{now}], ошибка в расчетах.", template.Id, nextRun.Value, DateTimeOffset.UtcNow);
_logger.LogError("При обновлении nextRun для шаблона {TemplateId}, расчитанный nextRun<Now [{NextRun}<{Now}], ошибка в расчетах.", template.Id, nextRun.Value, DateTimeOffset.UtcNow);
return false;
}
if (nextRun != template.NextRun)
{
logger.LogDebug($"Для шаблона id {template.Id} обновляю nextRun, новое значение {nextRun}, старое значение {template.NextRun}");
_logger.LogDebug("Для шаблона {TemplateId} обновляю nextRun. Новое: {NewNextRun}, старое: {OldNextRun}", template.Id, nextRun, template.NextRun);
template.LastRun = template.NextRun;
template.NextRun = nextRun.Value;
@@ -398,7 +655,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
? suffix
: $"{historyInitiator.InitiatorComment}. {suffix}";
if (!await robotConfigurationRepository.CommitAsync(historyInitiator))
if (!await _robotConfigurationRepository.CommitAsync(historyInitiator))
throw new DbErrorException("Ошибка при сохранении изменения NextRun");
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Core.Services.RobotTask.Models
{
/// <summary>
/// Модель задания для робота
/// </summary>
/// <param name="TaskId"></param>
/// <param name="TemplateName"></param>
internal record RobotTaskDetails(Guid TaskId, string TemplateName, DateTimeOffset NextRun);
}

View File

@@ -0,0 +1,90 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.RobotTaskDetailsServices.Interfaces;
using PARR.Domain.DTOs.RobotTaskDetails;
using PARR.Domain.DTOs.Shared;
using PARR.Domain.Enums;
using PARR.Domain.Exceptions;
namespace PARR.Core.Services.RobotTaskDetailsServices.Implementations
{
internal class RobotTaskDetailsService : IRobotTaskDetailsService
{
private readonly ILogger<RobotTaskDetailsService> _logger;
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly IMapper _mapper;
private readonly IRobotRepository _robotRepository;
private readonly ITaskStatusRepository _taskStatusRepository;
public RobotTaskDetailsService(
ILogger<RobotTaskDetailsService> logger,
IRobotConfigurationRepository robotConfigurationRepository,
IMapper mapper,
IRobotRepository robotRepository,
ITaskStatusRepository taskStatusRepository
)
{
_logger = logger;
_robotConfigurationRepository = robotConfigurationRepository;
_mapper = mapper;
_robotRepository = robotRepository;
_taskStatusRepository = taskStatusRepository;
}
public async Task<RobotTaskDetailsResult> GetDetailsAsync(RobotsEnum robot, TaskStatusEnum task, CancellationToken cancellationToken = default)
{
// 1. Получаем группировку конфигураций
var groupedDetails = await _robotConfigurationRepository.Get()
.AsNoTracking()
.Where(config => config.RobotCode == (int)robot && config.TaskStatusCode == (int)task)
.GroupBy(config => config.Template!.Job!.Group)
.Select(t => new
{
JobGroup = t.Key,
TemplatesCount = t.Count()
})
.ToListAsync(cancellationToken);
// 2. Получаем сущности робота и статуса задачи)
var robotEntity = await _robotRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Code == (int)robot, cancellationToken);
var taskStatusEntity = await _taskStatusRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Code == (int)task, cancellationToken);
if (robotEntity == null)
{
_logger.LogWarning("Робот с кодом {RobotCode} не найден в БД", robot);
throw new AppValidationException($"Робот с кодом {(int)robot} не найден");
}
if (taskStatusEntity == null)
{
_logger.LogWarning("Статус задачи с кодом {TaskCode} не найден в БД", task);
throw new AppValidationException($"Статус задачи с кодом {(int)task} не найден");
}
// 3. Маппинг и сборка результирующего DTO
var result = new RobotTaskDetailsResult
{
Robot = _mapper.Map<RobotResult>(robotEntity),
Task = _mapper.Map<RobotTaskStatusResult>(taskStatusEntity),
Details = groupedDetails
.Select(t => new RobotTaskGroupDetailsResult
{
JobGroup = _mapper.Map<JobGroupShortResult>(t.JobGroup),
TemplatesCount = t.TemplatesCount
})
.OrderBy(d => d.JobGroup.GroupName)
.ToList()
};
return result;
}
}
}

View File

@@ -0,0 +1,16 @@
using PARR.Domain.DTOs.RobotTaskDetails;
using PARR.Domain.Enums;
namespace PARR.Core.Services.RobotTaskDetailsServices.Interfaces
{
public interface IRobotTaskDetailsService
{
/// <summary>
/// Список групп работ по заданиям робота
/// </summary>
/// <param name="robot"></param>
/// <param name="task"></param>
/// <returns></returns>
Task<RobotTaskDetailsResult> GetDetailsAsync(RobotsEnum robot, TaskStatusEnum task, CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,98 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Services.RobotTaskRobotStatus.Interfaces;
using PARR.Domain.DTOs.RobotTaskRobotStatus;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums;
using PARR.Domain.Exceptions;
namespace PARR.Core.Services.RobotTaskRobotStatus.Implemetations
{
internal class RobotTaskRobotStatusService : IRobotTaskRobotStatusService
{
private readonly ILogger<RobotTaskRobotStatusService> _logger;
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly IRobotHistoryRepository _robotHistoryRepository;
private readonly ITemplateRenamePendingRepository _templateRenamePendingRepository;
private readonly IMapper _mapper;
public RobotTaskRobotStatusService(
ILogger<RobotTaskRobotStatusService> logger,
IRobotConfigurationRepository robotConfigurationRepository,
IRobotHistoryRepository robotHistoryRepository,
ITemplateRenamePendingRepository templateRenamePendingRepository,
IMapper mapper
)
{
_logger = logger;
_robotConfigurationRepository = robotConfigurationRepository;
_robotHistoryRepository = robotHistoryRepository;
_templateRenamePendingRepository = templateRenamePendingRepository;
_mapper = mapper;
}
public async Task<RobotConfigurationResult> ChangeStatusAsync(ChangeRobotStatus request)
{
var config = await _robotConfigurationRepository.Get()
.FirstOrDefaultAsync(t => t.Id == request.TaskId);
if (config == null)
throw new NotFoundException($"Не найдено задание с id: {request.TaskId}");
// изменение статуса робота
_robotConfigurationRepository.ChangeRobotStatus(request.RobotStatusCode, config);
// если успех, изменяем статус задания на успех
if (request.RobotStatusCode == RobotStatusEnum.Complete)
{
_robotConfigurationRepository.ChangeTaskStatus(TaskStatusEnum.Ok, config);
// Тут нужно посмотреть, если этот шаблон был на переименование, удалить у него старое название, так как он успешно переименовался
if (config.RobotCode == (int)RobotsEnum.TemplateOrder)
{
var renaming = await _templateRenamePendingRepository.Get().FirstOrDefaultAsync(t => t.TemplateId == config.TemplateId);
if (renaming != null)
{
// Удаляем
_templateRenamePendingRepository.Remove(renaming);
_logger.LogInformation("Шаблон {TemplateId} успешно переименован. Запись TemplateRenamePending удалена.", config.TemplateId);
}
}
}
if (!await _robotConfigurationRepository.CommitAsync())
throw new DbErrorException("Ошибка при сохранении в БД");
//записываем в лог робота
if (request.RobotStatusCode == RobotStatusEnum.InProgress || request.RobotStatusCode == RobotStatusEnum.Complete)
{
var historyLevel = request.RobotStatusCode == RobotStatusEnum.InProgress ? RobotHistoryLevelEnum.Start : RobotHistoryLevelEnum.Complete;
var history = new RobotHistory
{
Id = Guid.NewGuid(),
HistoryLevel = (int)historyLevel,
TaskStatusCode = config.TaskStatusCode,
RobotConfigurationId = config.Id,
RobotIp = request.RobotIp,
RobotId = request.RobotId
};
await _robotHistoryRepository.CreateAsync(history);
await _robotHistoryRepository.CommitAsync();
}
var configToResponse = await _robotConfigurationRepository.Get()
.AsNoTracking()
.Include(t => t.Robot)
.Include(t => t.TaskStatus)
.Include(t => t.RobotStatus)
.FirstOrDefaultAsync(t => t.Id == request.TaskId);
return _mapper.Map<RobotConfigurationResult>(configToResponse);
}
}
}

View File

@@ -0,0 +1,17 @@
using PARR.Domain.DTOs.RobotTaskRobotStatus;
namespace PARR.Core.Services.RobotTaskRobotStatus.Interfaces
{
/// <summary>
/// Сервис по изменению статуса выполнения задания роботами
/// </summary>
public interface IRobotTaskRobotStatusService
{
/// <summary>
/// Изменить статус выполнения задания роботом по ИД задания
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
Task<RobotConfigurationResult> ChangeStatusAsync(ChangeRobotStatus request);
}
}

View File

@@ -26,15 +26,20 @@ internal class RelationshipsShortcodeHandler : IShortcodeHandler
var hasNumbered = input.Contains("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase);
if (!hasPlain && !hasNumbered) return input;
var relatedNames = await unitFilterService.GetRelatedUnitNamesAsync(template.JobId, template.UnitId, ct);
var result = input;
var relatedNames = await unitFilterService.GetRelatedUnitNamesAsync(template.JobId, template.UnitId, ct);
var orderedNames = relatedNames
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
if (hasPlain)
result = result.Replace("%СВЯЗИ%", string.Join("\n", relatedNames), StringComparison.OrdinalIgnoreCase);
result = result.Replace("%СВЯЗИ%", string.Join("\n", orderedNames), StringComparison.OrdinalIgnoreCase);
if (hasNumbered)
{
var numbered = relatedNames.Select((n, i) => $"{i + 1}. {n}");
var numbered = orderedNames.Select((n, i) => $"{i + 1}. {n}");
result = result.Replace("%СВЯЗИ-ПН%", string.Join("\n", numbered), StringComparison.OrdinalIgnoreCase);
}

View File

@@ -1,26 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Domain.Entities.JobEntities;
using System.Diagnostics;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Сопоставляет юниты с фильтрами по атрибутам.
/// Использует двухэтапный поиск: сначала ValueId, затем UnitId.
/// </summary>
internal class UnitFieldMatcher : IUnitFieldMatcher
{
private const int chunkSize = 200;
private readonly IUnitRepository unitRepository;
private readonly ILogger<UnitFieldMatcher> logger;
private readonly IUnitRepository _unitRepository;
private readonly IUnitFieldValueRepository _unitFieldValueRepository;
private readonly ILogger<UnitFieldMatcher> _logger;
public UnitFieldMatcher(
IUnitRepository unitRepository,
IUnitFieldValueRepository unitFieldValueRepository,
ILogger<UnitFieldMatcher> logger)
{
this.unitRepository = unitRepository;
this.logger = logger;
_unitRepository = unitRepository;
_unitFieldValueRepository = unitFieldValueRepository;
_logger = logger;
}
public async Task<List<Guid>> MatchAsync(
@@ -32,32 +35,63 @@ internal class UnitFieldMatcher : IUnitFieldMatcher
if (filters == null || filters.Count == 0)
return new List<Guid>(unitIds);
logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
_logger.LogDebug("UnitFieldMatcher: вход {UnitCount} юнитов, фильтров: {FilterCount}",
unitIds.Count, filters.Count);
var result = new List<Guid>(unitIds.Count);
var currentIds = new HashSet<Guid>(unitIds);
foreach (var chunk in unitIds.Chunk(chunkSize))
for (int i = 0; i < filters.Count; i++)
{
var query = unitRepository.Get().AsNoTracking()
.Where(u => chunk.Contains(u.Id));
var filter = filters[i];
var mask = filter.ValueMask?.Trim();
foreach (var fieldFilter in filters)
if (string.IsNullOrEmpty(mask))
continue;
var sw = Stopwatch.StartNew();
var inputCount = currentIds.Count;
var matchingValueIds = await _unitFieldValueRepository
.FindValueIdsByMaskAsync(mask, ct);
if (matchingValueIds.Count == 0 && !filter.IsInverse)
{
var valueMask = fieldFilter.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask))
continue;
query = unitRepository.GetUnitByFieldAndValue(
query, fieldFilter.FieldId, fieldFilter.ValueMask!, fieldFilter.IsInverse);
sw.Stop();
_logger.LogDebug(
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}') | Вход: {InCount}, Выход: 0 (нет значений), Время: {Ms}ms",
i + 1, filter.FieldId, mask, inputCount, sw.ElapsedMilliseconds);
currentIds.Clear();
break;
}
var chunkResult = await query.Select(u => u.Id).ToListAsync(ct);
result.AddRange(chunkResult);
// Шаг 2: Найти/исключить юниты по ValueId
if (filter.IsInverse)
{
var unitsToExclude = await _unitRepository
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
currentIds.ExceptWith(unitsToExclude);
}
else
{
var unitsToKeep = await _unitRepository
.FindUnitIdsByValueIdsAsync(currentIds.ToList(), filter.FieldId, matchingValueIds, ct);
currentIds.IntersectWith(unitsToKeep);
}
sw.Stop();
_logger.LogDebug(
"UnitFieldMatcher: фильтр #{Index} (FieldId={FieldId}, Mask='{Mask}', Inverse={IsInverse}, Values={ValCount}) | Вход: {InCount}, Выход: {OutCount}, Время: {Ms}ms",
i + 1, filter.FieldId, mask, filter.IsInverse, matchingValueIds.Count,
inputCount, currentIds.Count, sw.ElapsedMilliseconds);
if (currentIds.Count == 0)
{
_logger.LogDebug("UnitFieldMatcher: прерывание на фильтре #{Index} (0 юнитов)", i + 1);
break;
}
}
logger.LogDebug("UnitFieldMatcher: выход {UnitCount} юнитов", result.Count);
return result;
_logger.LogDebug("UnitFieldMatcher: итоговый выход {UnitCount} юнитов", currentIds.Count);
return currentIds.ToList();
}
}

View File

@@ -4,6 +4,7 @@ using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.Unit;
namespace PARR.Core.Services.UnitFilterService.Matchers;
@@ -238,9 +239,9 @@ internal class UnitRelationshipMatcher : IUnitRelationshipMatcher
}
/// <summary>
/// Обрабатывает маску LIKE для корректной работы с SQL
/// Нормализует пользовательскую маску в формат, совместимый с PostgreSQL ILIKE.
/// </summary>
private static string NormalizeLikeMask(string valueMask)
internal static string NormalizeLikeMask(string valueMask)
{
if (string.IsNullOrWhiteSpace(valueMask))
return valueMask;
@@ -258,4 +259,62 @@ internal class UnitRelationshipMatcher : IUnitRelationshipMatcher
else
return valueMask;
}
/// <summary>
/// Проверяет соответствие значения маске в формате ILIKE.
/// Эмулирует поведение PostgreSQL ILIKE для использования в C#-коде.
/// Регистронезависима.
/// </summary>
internal static bool MatchesLikeMask(string value, string mask)
{
if (string.IsNullOrEmpty(mask))
return true;
bool startsWithWildcard = mask.StartsWith('%');
bool endsWithWildcard = mask.EndsWith('%');
var core = mask.Trim('%');
if (startsWithWildcard && endsWithWildcard)
return value.Contains(core, StringComparison.OrdinalIgnoreCase);
if (endsWithWildcard)
return value.StartsWith(core, StringComparison.OrdinalIgnoreCase);
if (startsWithWildcard)
return value.EndsWith(core, StringComparison.OrdinalIgnoreCase);
return value.Equals(core, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Проверяет, проходит ли один target-юнит один RelationshipFilter.
/// Единая точка истины для UnitRelationshipMatcher и GetRelatedUnitNamesAsync.
/// Учитывает IsInverse. Не учитывает IsFullMatch (это ответственность вызывающего кода).
/// </summary>
internal static bool TargetPassesFilter(
IReadOnlyList<UnitInValue> unitValues,
JobRelationshipFilter rf)
{
var normalizedMask = NormalizeLikeMask(rf.ValueMask?.Trim() ?? string.Empty);
if (string.IsNullOrEmpty(normalizedMask))
return true;
var matchingValues = unitValues
.Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null)
.ToList();
bool hasMatch;
if (!matchingValues.Any())
{
hasMatch = rf.IsInverse;
}
else
{
hasMatch = matchingValues.Any(uv => MatchesLikeMask(uv.Value!.Value!, normalizedMask));
if (rf.IsInverse)
hasMatch = !hasMatch;
}
return hasMatch;
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Core.Services.UnitFilterService.Models;
using PARR.Core.Services.UnitService.Interfaces;
@@ -84,8 +85,7 @@ internal class UnitFilterService : IUnitFilterService
public async Task<IEnumerable<UnitFilterResultDto>?> GetUnitsByJobFilterAsync(
Job job,
int? takeCount = null,
CancellationToken cancellationToken = default
)
CancellationToken cancellationToken = default)
{
if (job.Group == null)
throw new ArgumentNullException(nameof(job.Group), $"Job {job.Id} не содержит Group");
@@ -97,13 +97,9 @@ internal class UnitFilterService : IUnitFilterService
var totalStopwatch = Stopwatch.StartNew();
logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count);
// Собираем все контексты юнитов, прошедших фильтрацию
var allFilteredContexts = new List<UnitFilterMatchResult>();
// Преобразуем в список для индексации
var unitFiltersList = job.UnitFilters.ToList();
// Этап 1: Применение основных фильтров (Field, Relationship) на уровне SQL
for (int i = 0; i < unitFiltersList.Count; i++)
{
var filter = unitFiltersList[i];
@@ -113,14 +109,21 @@ internal class UnitFilterService : IUnitFilterService
{
logger.LogDebug("Применение фильтра #{Index} (Id={FilterId})", i + 1, filter.Id);
// 1. Найти ID юнитов по UnitFilter
// 1. Resolve
var resolveSw = Stopwatch.StartNew();
var initialUnitIds = await nameResolver.ResolveAsync(filter.UnitFilter, cancellationToken);
resolveSw.Stop();
if (!initialUnitIds.Any())
{
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов)", i + 1);
logger.LogDebug("Фильтр #{Index}: пропущен (0 юнитов после Resolve, {ResolveMs}ms)",
i + 1, resolveSw.ElapsedMilliseconds);
continue;
}
logger.LogDebug("Фильтр #{Index}: Resolve вернул {Count} юнитов за {Ms}ms",
i + 1, initialUnitIds.Count, resolveSw.ElapsedMilliseconds);
#if DEBUG
if (initialUnitIds.Contains(debugTargetUnitId))
{
@@ -128,14 +131,19 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 2. Применить FieldFilters
var fieldStopwatch = Stopwatch.StartNew();
//var fieldFilteredIds = await ApplyFieldFiltersOnDbAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
// 2. FieldFilters
var fieldSw = Stopwatch.StartNew();
var fieldFilteredIds = await unitFieldMatcher.MatchAsync(initialUnitIds, filter.FieldFilters, cancellationToken);
fieldSw.Stop();
logger.LogDebug("Фильтр #{Index}: FieldMatcher вернул {Count} юнитов за {Ms}ms",
i + 1, fieldFilteredIds.Count, fieldSw.ElapsedMilliseconds);
if (!fieldFilteredIds.Any())
{
logger.LogDebug("Фильтр #{Index}: 0 юнитов после FieldFilters", i + 1);
filterStopwatch.Stop();
logger.LogDebug("Фильтр #{Index}: завершён (0 юнитов). [Resolve: {R}ms, Field: {F}ms, Total: {T}ms]",
i + 1, resolveSw.ElapsedMilliseconds, fieldSw.ElapsedMilliseconds, filterStopwatch.ElapsedMilliseconds);
continue;
}
@@ -146,16 +154,24 @@ internal class UnitFilterService : IUnitFilterService
}
#endif
// 3. Применить RelationshipFilters
var relStopwatch = Stopwatch.StartNew();
// 3. RelationshipFilters
var relSw = Stopwatch.StartNew();
var relationshipFilteredContexts = await unitRelationshipMatcher.MatchAsync(
fieldFilteredIds, filter.RelationshipFilters, cancellationToken);
relSw.Stop();
if (!relationshipFilteredContexts.Any())
{
logger.LogDebug("Фильтр #{Index}: 0 юнитов после RelationshipFilters", i + 1);
continue;
}
filterStopwatch.Stop();
allFilteredContexts.AddRange(relationshipFilteredContexts);
logger.LogDebug(
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Resolve: {R}ms, Field: {F}ms, Rel: {Rel}ms, Total: {T}ms]",
i + 1,
relationshipFilteredContexts.Count,
allFilteredContexts.Count,
resolveSw.ElapsedMilliseconds,
fieldSw.ElapsedMilliseconds,
relSw.ElapsedMilliseconds,
filterStopwatch.ElapsedMilliseconds);
#if DEBUG
var targetContext = relationshipFilteredContexts.FirstOrDefault(c => c.UnitId == debugTargetUnitId);
@@ -165,25 +181,13 @@ internal class UnitFilterService : IUnitFilterService
debugTargetUnitId, i + 1, targetContext.ValidParentIds.Count, targetContext.ValidChildIds.Count);
}
#endif
// Добавляем отфильтрованные контексты в общий набор
allFilteredContexts.AddRange(relationshipFilteredContexts);
filterStopwatch.Stop();
logger.LogDebug(
"Фильтр #{Index}: добавлено {Count} юнитов. Всего: {Total}. [Field: {F}ms, Rel: {R}ms, Total: {T}ms]",
i + 1,
relationshipFilteredContexts.Count,
allFilteredContexts.Count,
fieldStopwatch.ElapsedMilliseconds,
relStopwatch.ElapsedMilliseconds,
filterStopwatch.ElapsedMilliseconds
);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при обработке фильтра {FilterId} для Job {JobId}",
filter.Id, job.Id);
filterStopwatch.Stop();
logger.LogError(ex, "Ошибка при обработке фильтра #{Index} (Id={FilterId}) для Job {JobId}. Время до ошибки: {Ms}ms",
i + 1, filter.Id, job.Id, filterStopwatch.ElapsedMilliseconds);
throw; // КРИТИЧНО: прерываем выполнение, чтобы не маскировать проблему
}
}
@@ -197,6 +201,7 @@ internal class UnitFilterService : IUnitFilterService
ValidChildIds = new HashSet<Guid>(g.SelectMany(c => c.ValidChildIds))
})
.ToList();
logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count);
// Этап 2: Применение Umbrella-фильтра
@@ -219,6 +224,7 @@ internal class UnitFilterService : IUnitFilterService
return result;
}
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
{
return await jobRepository
@@ -231,7 +237,8 @@ internal class UnitFilterService : IUnitFilterService
}
public async Task<List<string>> GetRelatedUnitNamesAsync(Guid jobId, Guid unitId, CancellationToken cancellationToken = default)
public async Task<List<string>> GetRelatedUnitNamesAsync(
Guid jobId, Guid unitId, CancellationToken cancellationToken = default)
{
logger.LogDebug("Начало GetRelatedUnitNamesAsync. JobId: {JobId}, UnitId: {UnitId}", jobId, unitId);
@@ -246,71 +253,47 @@ internal class UnitFilterService : IUnitFilterService
throw new ArgumentException($"Job {jobId} не найден.", nameof(jobId));
}
logger.LogDebug("Найден Job: {JobName}. Количество UnitFilters: {FilterCount}", job.Name, job.UnitFilters.Count());
// Единая точка загрузки связей
var allRelatedUnitIds = await unitInUnitRepository
.GetRelatedUnitIdsAsync(unitId, cancellationToken);
if (!allRelatedUnitIds.Any())
return new List<string>();
// Загружаем значения всех связанных юнитов одним запросом
var allUnitValues = await unitInValueRepository.GetByUnitIdsAsync(allRelatedUnitIds);
var valuesByUnit = allUnitValues
.GroupBy(uv => uv.UnitId)
.ToDictionary(g => g.Key, g => g.ToList());
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var filter in job.UnitFilters)
{
if (!filter.RelationshipFilters.Any()) continue;
logger.LogDebug("Обработка UnitFilter.Id {FilterId}. Количество RelationshipFilters: {RelFilterCount}", filter.Id, filter.RelationshipFilters.Count());
// Получить все связи для юнита
var parentLinks = await unitInUnitRepository.GetByChildIdAsync(unitId);
var childLinks = await unitInUnitRepository.GetByParentIdAsync(unitId);
// Собрать все UnitId, участвующие в связях
var allRelatedUnitIds = parentLinks
.Select(l => l.ParentUnitId)
.Concat(childLinks.Select(l => l.ChildUnitId))
.Distinct()
var relFilters = filter.RelationshipFilters
.Where(rf => !string.IsNullOrWhiteSpace(rf.ValueMask?.Trim()))
.ToList();
if (!allRelatedUnitIds.Any()) continue;
if (!relFilters.Any()) continue;
// Получить значения для всех связанных юнитов
var allUnitValues = await unitInValueRepository.GetByUnitIdsAsync(allRelatedUnitIds);
// Сгруппировать значения по UnitId
var valuesByUnit = allUnitValues
.GroupBy(uv => uv.UnitId)
.ToDictionary(g => g.Key, g => g.ToList());
// Найти UnitId, которые проходят все RelationshipFilters
var matchingUnitIds = new HashSet<Guid>();
// Проверяем каждый связанный юнит через единую точку проверки
var passedUnitIds = new HashSet<Guid>();
foreach (var relatedUnitId in allRelatedUnitIds)
{
bool passesAllFilters = filter.RelationshipFilters.All(rf =>
{
var values = valuesByUnit.GetValueOrDefault(relatedUnitId, new List<UnitInValue>());
var unitValues = valuesByUnit.GetValueOrDefault(relatedUnitId, new List<UnitInValue>());
var matchingValues = values
.Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null)
.ToList();
// Все фильтры должны пройти (AND между фильтрами в рамках одного UnitFilter)
bool passesAll = relFilters.All(rf =>
UnitRelationshipMatcher.TargetPassesFilter(unitValues, rf));
if (!matchingValues.Any())
{
return rf.IsInverse;
}
var hasMatch = matchingValues.Any(uv => uv.Value!.Value!.Contains(rf.ValueMask.Trim('%'), StringComparison.OrdinalIgnoreCase));
if (rf.IsInverse)
hasMatch = !hasMatch;
return hasMatch;
});
if (passesAllFilters)
matchingUnitIds.Add(relatedUnitId);
if (passesAll)
passedUnitIds.Add(relatedUnitId);
}
if (matchingUnitIds.Any())
if (passedUnitIds.Any())
{
// Используем кэширующий сервис вместо прямого запроса к БД
var cachedUnits = await unitService.GetWithCachingAsync(matchingUnitIds);
var cachedUnits = await unitService.GetWithCachingAsync(passedUnitIds);
var names = cachedUnits.Values
.Select(u => u.Name)
.Where(n => !string.IsNullOrEmpty(n));
@@ -318,6 +301,8 @@ internal class UnitFilterService : IUnitFilterService
}
}
return result.ToList();
return result
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -0,0 +1,326 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers;
using PARR.Domain.Entities.JobEntities;
namespace PARR.Core.Tests.Services.UnitFilterService.Matchers
{
public class UnitFieldMatcherTests
{
private readonly Mock<IUnitRepository> _unitRepoMock;
private readonly Mock<IUnitFieldValueRepository> _fieldValueRepoMock;
private readonly UnitFieldMatcher _sut;
public UnitFieldMatcherTests()
{
_unitRepoMock = new Mock<IUnitRepository>();
_fieldValueRepoMock = new Mock<IUnitFieldValueRepository>();
var logger = NullLoggerFactory.Instance.CreateLogger<UnitFieldMatcher>();
_sut = new UnitFieldMatcher(
_unitRepoMock.Object,
_fieldValueRepoMock.Object,
logger);
}
/// <summary>
/// Если фильтры не заданы, возвращается исходный набор юнитов без изменений.
/// </summary>
[Fact]
public async Task MatchAsync_NoFilters_ReturnsAllUnitIds()
{
// Arrange
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
// Act
var result = await _sut.MatchAsync(unitIds, Enumerable.Empty<JobFieldFilter>());
// Assert
Assert.Equal(unitIds.Count, result.Count);
Assert.Equal(unitIds, result);
_fieldValueRepoMock.Verify(
r => r.FindValueIdsByMaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
/// <summary>
/// Прямой фильтр оставляет только юниты, имеющие совпадающие значения.
/// Проверяется логика IntersectWith.
/// </summary>
[Fact]
public async Task MatchAsync_SingleDirectFilter_IntersectsWithMatchingUnits()
{
// Arrange
var unit1 = Guid.NewGuid();
var unit2 = Guid.NewGuid();
var unit3 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1, unit2, unit3 };
var fieldId = Guid.NewGuid();
var valueId = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId, ValueMask = "коммутатор", IsInverse = false }
};
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { valueId });
_unitRepoMock
.Setup(r => r.FindUnitIdsByValueIdsAsync(
It.IsAny<IReadOnlyList<Guid>>(),
fieldId,
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId)),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { unit1, unit3 });
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(unit1, result);
Assert.Contains(unit3, result);
Assert.DoesNotContain(unit2, result);
}
/// <summary>
/// Инверсный фильтр исключает юниты, имеющие совпадающие значения.
/// Проверяется логика ExceptWith.
/// </summary>
[Fact]
public async Task MatchAsync_SingleInverseFilter_ExcludesMatchingUnits()
{
// Arrange
var unit1 = Guid.NewGuid();
var unit2 = Guid.NewGuid();
var unit3 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1, unit2, unit3 };
var fieldId = Guid.NewGuid();
var valueId = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId, ValueMask = "1", IsInverse = true }
};
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { valueId });
_unitRepoMock
.Setup(r => r.FindUnitIdsByValueIdsAsync(
It.IsAny<IReadOnlyList<Guid>>(),
fieldId,
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId)),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { unit2 });
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(unit1, result);
Assert.Contains(unit3, result);
Assert.DoesNotContain(unit2, result);
}
/// <summary>
/// Несколько фильтров применяются последовательно (AND).
/// Каждый следующий фильтр сужает набор, полученный от предыдущего.
/// </summary>
[Fact]
public async Task MatchAsync_MultipleFilters_AppliesSequentially()
{
// Arrange
var unit1 = Guid.NewGuid();
var unit2 = Guid.NewGuid();
var unit3 = Guid.NewGuid();
var unit4 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1, unit2, unit3, unit4 };
var fieldId1 = Guid.NewGuid();
var fieldId2 = Guid.NewGuid();
var valueId1 = Guid.NewGuid();
var valueId2 = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId1, ValueMask = "СХД", IsInverse = false },
new() { FieldId = fieldId2, ValueMask = "коммутатор", IsInverse = false }
};
// Первый фильтр: значения найдены для unit1, unit2, unit3
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("СХД", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { valueId1 });
_unitRepoMock
.Setup(r => r.FindUnitIdsByValueIdsAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 4),
fieldId1,
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId1)),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { unit1, unit2, unit3 });
// Второй фильтр: из оставшихся 3 юнитов значение найдено для unit1 и unit3
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { valueId2 });
_unitRepoMock
.Setup(r => r.FindUnitIdsByValueIdsAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3),
fieldId2,
It.Is<IReadOnlyList<Guid>>(v => v.Contains(valueId2)),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { unit1, unit3 });
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(unit1, result);
Assert.Contains(unit3, result);
}
/// <summary>
/// Если прямой фильтр не нашёл ни одного значения, результат пуст.
/// Проверяется раннее прерывание.
/// </summary>
[Fact]
public async Task MatchAsync_NoMatchingValues_DirectFilter_ReturnsEmpty()
{
// Arrange
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
var fieldId = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId, ValueMask = "несуществующее", IsInverse = false }
};
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("несуществующее", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid>());
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Empty(result);
_unitRepoMock.Verify(
r => r.FindUnitIdsByValueIdsAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<Guid>(),
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
/// <summary>
/// Если инверсный фильтр не нашёл ни одного значения, все юниты проходят.
/// Исключать нечего, поэтому FindUnitIdsByValueIdsAsync не вызывается.
/// </summary>
[Fact]
public async Task MatchAsync_NoMatchingValues_InverseFilter_ReturnsAll()
{
// Arrange
var unit1 = Guid.NewGuid();
var unit2 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1, unit2 };
var fieldId = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId, ValueMask = "несуществующее", IsInverse = true }
};
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("несуществующее", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid>());
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(unit1, result);
Assert.Contains(unit2, result);
}
/// <summary>
/// Фильтр с пустой маской пропускается без вызова репозиториев.
/// </summary>
[Fact]
public async Task MatchAsync_EmptyMask_SkipsFilter()
{
// Arrange
var unitIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
var fieldId = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId, ValueMask = " ", IsInverse = false }
};
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Equal(unitIds.Count, result.Count);
_fieldValueRepoMock.Verify(
r => r.FindValueIdsByMaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
/// <summary>
/// Если после одного из фильтров набор стал пустым,
/// последующие фильтры не выполняются.
/// </summary>
[Fact]
public async Task MatchAsync_EarlyBreak_WhenNoUnitsLeft()
{
// Arrange
var unit1 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1 };
var fieldId1 = Guid.NewGuid();
var fieldId2 = Guid.NewGuid();
var valueId1 = Guid.NewGuid();
var filters = new List<JobFieldFilter>
{
new() { FieldId = fieldId1, ValueMask = "СХД", IsInverse = false },
new() { FieldId = fieldId2, ValueMask = "коммутатор", IsInverse = false }
};
// Первый фильтр возвращает пустой результат
_fieldValueRepoMock
.Setup(r => r.FindValueIdsByMaskAsync("СХД", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid> { valueId1 });
_unitRepoMock
.Setup(r => r.FindUnitIdsByValueIdsAsync(
It.IsAny<IReadOnlyList<Guid>>(),
fieldId1,
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Guid>());
// Act
var result = await _sut.MatchAsync(unitIds, filters);
// Assert
Assert.Empty(result);
// Второй фильтр не должен вызываться
_fieldValueRepoMock.Verify(
r => r.FindValueIdsByMaskAsync("коммутатор", It.IsAny<CancellationToken>()),
Times.Never);
}
}
}

View File

@@ -0,0 +1,591 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers;
using PARR.DAL.Context;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.Unit;
namespace PARR.Core.Tests.Services.UnitFilterService.Matchers
{
public class UnitRelationshipMatcherTests
{
private readonly Mock<IUnitInUnitRepository> _unitInUnitRepoMock;
private readonly Mock<IUnitInValueRepository> _unitInValueRepoMock;
private readonly UnitRelationshipMatcher _sut;
public UnitRelationshipMatcherTests()
{
_unitInUnitRepoMock = new Mock<IUnitInUnitRepository>();
_unitInValueRepoMock = new Mock<IUnitInValueRepository>();
var logger = NullLoggerFactory.Instance.CreateLogger<UnitRelationshipMatcher>();
_sut = new UnitRelationshipMatcher(
_unitInUnitRepoMock.Object,
_unitInValueRepoMock.Object,
logger);
}
/// <summary>
/// Создаёт юнит со значением поля и добавляет его в контекст.
/// </summary>
private static Unit BuildUnit(Guid id, string name, Guid fieldId, string fieldValue)
{
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
return new Unit
{
Id = id,
Name = name,
UnitValues = new List<UnitInValue>
{
new UnitInValue
{
UnitId = id,
FieldId = fieldId,
ValueId = fieldVal.Id,
Value = fieldVal
}
}
};
}
/// <summary>
/// Настраивает моки репозиториев для работы с InMemory-контекстом.
/// GetMatchingTargetIds эмулирует ILike через Contains с обрезкой символов '%'.
/// </summary>
private void SetupMocks(DataContext context)
{
_unitInUnitRepoMock
.Setup(r => r.Get())
.Returns(context.UnitInUnits.AsQueryable());
_unitInValueRepoMock
.Setup(r => r.GetMatchingTargetIds(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns((Guid fieldId, string mask) =>
{
var trimmed = mask.Trim('%');
return context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fieldId
&& uv.Value != null
&& uv.Value.Value != null
&& uv.Value.Value.Contains(trimmed, StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable();
});
}
/// <summary>
/// Если фильтры не заданы, все юниты возвращаются как контексты без изменений.
/// </summary>
[Fact]
public async Task MatchAsync_NoFilters_ReturnsAllUnitsAsContexts()
{
// Arrange
var unit1 = Guid.NewGuid();
var unit2 = Guid.NewGuid();
var unitIds = new List<Guid> { unit1, unit2 };
// Act
var result = await _sut.MatchAsync(unitIds, Enumerable.Empty<JobRelationshipFilter>());
// Assert
Assert.Equal(2, result.Count);
Assert.Contains(result, c => c.UnitId == unit1);
Assert.Contains(result, c => c.UnitId == unit2);
}
/// <summary>
/// Если входной набор пуст, возвращается пустой список.
/// </summary>
[Fact]
public async Task MatchAsync_EmptyUnitIds_ReturnsEmpty()
{
// Arrange
var filters = new List<JobRelationshipFilter>
{
new() { FieldId = Guid.NewGuid(), ValueMask = "%test%", IsParent = true }
};
// Act
var result = await _sut.MatchAsync(new List<Guid>(), filters);
// Assert
Assert.Empty(result);
}
/// <summary>
/// Прямой родительский фильтр (IsFullMatch=false):
/// юнит проходит, если ХОТЯ БЫ ОДИН из его родителей соответствует маске.
/// </summary>
[Fact]
public async Task MatchAsync_ParentFilter_DirectAnyMatch_OnlyUnitsWithMatchingParentPass()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var parentGood = Guid.NewGuid();
var parentBad = Guid.NewGuid();
var unitWithGoodParent = Guid.NewGuid();
var unitWithBadParent = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parentGood, "Parent_Good", fieldId, "VALID-TAG"),
BuildUnit(parentBad, "Parent_Bad", fieldId, "OTHER-TAG"),
BuildUnit(unitWithGoodParent, "Unit_Good", Guid.NewGuid(), "x"),
BuildUnit(unitWithBadParent, "Unit_Bad", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentGood, ChildUnitId = unitWithGoodParent, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentBad, ChildUnitId = unitWithBadParent, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%VALID%",
IsParent = true,
IsInverse = false,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitWithGoodParent, unitWithBadParent }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitWithGoodParent, resultIds);
Assert.DoesNotContain(unitWithBadParent, resultIds);
var ctx = result.First(c => c.UnitId == unitWithGoodParent);
Assert.Contains(parentGood, ctx.ValidParentIds);
}
/// <summary>
/// Инверсный родительский фильтр (IsInverse=true, IsFullMatch=false):
/// юнит проходит, если ХОТЯ БЫ ОДИН родитель НЕ соответствует маске.
/// </summary>
[Fact]
public async Task MatchAsync_ParentFilter_Inverse_UnitsWithOnlyForbiddenParentsExcluded()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var parentForbidden = Guid.NewGuid();
var parentClean = Guid.NewGuid();
var unitWithForbiddenOnly = Guid.NewGuid();
var unitWithCleanParent = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parentForbidden, "Parent_Forbidden", fieldId, "FORBIDDEN"),
BuildUnit(parentClean, "Parent_Clean", fieldId, "CLEAN"),
BuildUnit(unitWithForbiddenOnly, "Unit_Forbidden", Guid.NewGuid(), "x"),
BuildUnit(unitWithCleanParent, "Unit_Clean", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentForbidden, ChildUnitId = unitWithForbiddenOnly, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitWithCleanParent, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%FORBIDDEN%",
IsParent = true,
IsInverse = true,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitWithForbiddenOnly, unitWithCleanParent }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitWithCleanParent, resultIds);
Assert.DoesNotContain(unitWithForbiddenOnly, resultIds);
}
/// <summary>
/// IsFullMatch=true: юнит проходит, только если ВСЕ его родители соответствуют маске.
/// Юнит с одним "плохим" родителем исключается.
/// </summary>
[Fact]
public async Task MatchAsync_ParentFilter_IsFullMatch_AllParentsMustPass()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var parentGood1 = Guid.NewGuid();
var parentGood2 = Guid.NewGuid();
var parentBad = Guid.NewGuid();
var unitAllGood = Guid.NewGuid();
var unitMixed = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parentGood1, "Parent_Good1", fieldId, "Good"),
BuildUnit(parentGood2, "Parent_Good2", fieldId, "Good"),
BuildUnit(parentBad, "Parent_Bad", fieldId, "Bad"),
BuildUnit(unitAllGood, "Unit_AllGood", Guid.NewGuid(), "x"),
BuildUnit(unitMixed, "Unit_Mixed", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentGood1, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentGood2, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentGood1, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentBad, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%Good%",
IsParent = true,
IsInverse = false,
IsFullMatch = true
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitAllGood, unitMixed }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitAllGood, resultIds);
Assert.DoesNotContain(unitMixed, resultIds);
}
/// <summary>
/// Комбинация IsInverse=true и IsFullMatch=true:
/// юнит проходит, только если НИ ОДИН родитель не соответствует маске.
/// </summary>
[Fact]
public async Task MatchAsync_ParentFilter_InverseAndFullMatch_UnitWithAnyForbiddenParentExcluded()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var parentForbidden = Guid.NewGuid();
var parentClean = Guid.NewGuid();
var unitMixed = Guid.NewGuid();
var unitAllClean = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parentForbidden, "Parent_Forbidden", fieldId, "FORBIDDEN"),
BuildUnit(parentClean, "Parent_Clean", fieldId, "CLEAN"),
BuildUnit(unitMixed, "Unit_Mixed", Guid.NewGuid(), "x"),
BuildUnit(unitAllClean, "Unit_AllClean", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentForbidden, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitMixed, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentClean, ChildUnitId = unitAllClean, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%FORBIDDEN%",
IsParent = true,
IsInverse = true,
IsFullMatch = true
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitMixed, unitAllClean }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitAllClean, resultIds);
Assert.DoesNotContain(unitMixed, resultIds);
}
/// <summary>
/// Дочерний фильтр (IsParent=false):
/// юнит проходит, если хотя бы один из его детей соответствует маске.
/// </summary>
[Fact]
public async Task MatchAsync_ChildFilter_DirectMatch()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var childValid = Guid.NewGuid();
var childInvalid = Guid.NewGuid();
var parentWithValidChild = Guid.NewGuid();
var parentWithInvalidChild = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(childValid, "Child_Valid", fieldId, "VALID"),
BuildUnit(childInvalid, "Child_Invalid", fieldId, "OTHER"),
BuildUnit(parentWithValidChild, "Parent_Valid", Guid.NewGuid(), "x"),
BuildUnit(parentWithInvalidChild, "Parent_Invalid", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentWithValidChild, ChildUnitId = childValid, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentWithInvalidChild, ChildUnitId = childInvalid, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%VALID%",
IsParent = false,
IsInverse = false,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { parentWithValidChild, parentWithInvalidChild }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(parentWithValidChild, resultIds);
Assert.DoesNotContain(parentWithInvalidChild, resultIds);
var ctx = result.First(c => c.UnitId == parentWithValidChild);
Assert.Contains(childValid, ctx.ValidChildIds);
}
/// <summary>
/// Юнит без связей исключается, если есть фильтры с непустой маской.
/// </summary>
[Fact]
public async Task MatchAsync_UnitWithoutRelatedUnits_IsExcluded()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var unitWithParent = Guid.NewGuid();
var unitOrphan = Guid.NewGuid();
var parent = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parent, "Parent", fieldId, "VALID"),
BuildUnit(unitWithParent, "Unit_WithParent", Guid.NewGuid(), "x"),
BuildUnit(unitOrphan, "Unit_Orphan", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parent, ChildUnitId = unitWithParent, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = "%VALID%",
IsParent = true,
IsInverse = false,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitWithParent, unitOrphan }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitWithParent, resultIds);
Assert.DoesNotContain(unitOrphan, resultIds);
}
/// <summary>
/// Фильтр с пустой маской пропускается: юниты не исключаются.
/// </summary>
[Fact]
public async Task MatchAsync_EmptyMask_FilterSkipped()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var fieldId = Guid.NewGuid();
var unit1 = Guid.NewGuid();
var parent = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(parent, "Parent", fieldId, "Any"),
BuildUnit(unit1, "Unit", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parent, ChildUnitId = unit1, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = fieldId,
ValueMask = " ",
IsParent = true,
IsInverse = false,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(new List<Guid> { unit1 }, filters);
// Assert
Assert.Single(result);
Assert.Equal(unit1, result.First().UnitId);
}
/// <summary>
/// Комбинация родительского и дочернего фильтров:
/// юнит должен пройти оба направления.
/// </summary>
[Fact]
public async Task MatchAsync_CombinedParentAndChildFilters_BothDirectionsApplied()
{
// Arrange
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
using var context = new DataContext(dbOptions);
var parentFieldId = Guid.NewGuid();
var childFieldId = Guid.NewGuid();
var goodParent = Guid.NewGuid();
var badParent = Guid.NewGuid();
var goodChild = Guid.NewGuid();
var badChild = Guid.NewGuid();
var unitAllGood = Guid.NewGuid();
var unitBadParent = Guid.NewGuid();
var unitBadChild = Guid.NewGuid();
context.Units.AddRange(
BuildUnit(goodParent, "GoodParent", parentFieldId, "GOOD-PARENT"),
BuildUnit(badParent, "BadParent", parentFieldId, "BAD-PARENT"),
BuildUnit(goodChild, "GoodChild", childFieldId, "GOOD-CHILD"),
BuildUnit(badChild, "BadChild", childFieldId, "BAD-CHILD"),
BuildUnit(unitAllGood, "Unit_AllGood", Guid.NewGuid(), "x"),
BuildUnit(unitBadParent, "Unit_BadParent", Guid.NewGuid(), "x"),
BuildUnit(unitBadChild, "Unit_BadChild", Guid.NewGuid(), "x")
);
context.UnitInUnits.AddRange(
// unitAllGood: хороший родитель + хороший ребёнок
new UnitInUnit { ParentUnitId = goodParent, ChildUnitId = unitAllGood, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = unitAllGood, ChildUnitId = goodChild, DateCreated = DateTimeOffset.UtcNow },
// unitBadParent: плохой родитель + хороший ребёнок
new UnitInUnit { ParentUnitId = badParent, ChildUnitId = unitBadParent, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = unitBadParent, ChildUnitId = goodChild, DateCreated = DateTimeOffset.UtcNow },
// unitBadChild: хороший родитель + плохой ребёнок
new UnitInUnit { ParentUnitId = goodParent, ChildUnitId = unitBadChild, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = unitBadChild, ChildUnitId = badChild, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
SetupMocks(context);
var filters = new List<JobRelationshipFilter>
{
new()
{
FieldId = parentFieldId,
ValueMask = "%GOOD-PARENT%",
IsParent = true,
IsInverse = false,
IsFullMatch = false
},
new()
{
FieldId = childFieldId,
ValueMask = "%GOOD-CHILD%",
IsParent = false,
IsInverse = false,
IsFullMatch = false
}
};
// Act
var result = await _sut.MatchAsync(
new List<Guid> { unitAllGood, unitBadParent, unitBadChild }, filters);
// Assert
var resultIds = result.Select(c => c.UnitId).ToList();
Assert.Contains(unitAllGood, resultIds);
Assert.DoesNotContain(unitBadParent, resultIds);
Assert.DoesNotContain(unitBadChild, resultIds);
}
}
}

View File

@@ -21,14 +21,6 @@ public class UnitFilterServiceTests
{
#region Helpers
/// <summary>
/// Создаёт тестовую сущность Unit с заданным значением поля.
/// Используется для подготовки данных в тестах фильтрации.
/// </summary>
/// <param name="id">Уникальный идентификатор юнита</param>
/// <param name="name">Имя юнита для отладки</param>
/// <param name="fieldId">Идентификатор поля, к которому привязывается значение</param>
/// <param name="fieldValue">Значение поля</param>
private static Unit BuildTestUnit(Guid id, string name, Guid fieldId, string fieldValue)
{
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
@@ -49,10 +41,6 @@ public class UnitFilterServiceTests
};
}
/// <summary>
/// Создаёт тестовый объект JobGroupType с кодом Umbrella.
/// Используется для тестирования логики групповых работ типа "зонтик".
/// </summary>
private static JobGroupType BuildTestJobGroupType() => new()
{
Id = Guid.NewGuid(),
@@ -61,10 +49,6 @@ public class UnitFilterServiceTests
Description = "Test"
};
/// <summary>
/// Создаёт тестовый объект JobGroup с заполненными обязательными полями.
/// Используется для подготовки навигационных свойств в тестах.
/// </summary>
private static JobGroup BuildTestJobGroup() => new()
{
Id = Guid.NewGuid(),
@@ -79,20 +63,12 @@ public class UnitFilterServiceTests
GroupType = BuildTestJobGroupType()
};
/// <summary>
/// Создаёт тестовый объект JobAutoControl с отключённым автоконтролем.
/// Используется для заполнения обязательного свойства в сущности Job.
/// </summary>
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()
{
JobId = jobId,
IsEnable = false
};
/// <summary>
/// Контейнер для моков репозиториев.
/// Упрощает передачу зависимостей в метод создания сервиса.
/// </summary>
private class RepositoryMocks
{
public Mock<IUnitRepository> Unit { get; set; }
@@ -101,7 +77,6 @@ public class UnitFilterServiceTests
public Mock<IUnitFieldRepository> UnitField { get; set; }
public Mock<IJobRepository> Job { get; set; }
public Mock<IRedisCacheService> Cache { get; set; }
// Новые зависимости после рефакторинга
public Mock<IUnitService> UnitService { get; set; }
public Mock<IUnitFieldMatcher> FieldMatcher { get; set; }
public Mock<IUnitRelationshipMatcher> RelationshipMatcher { get; set; }
@@ -110,11 +85,6 @@ public class UnitFilterServiceTests
public Mock<IUnitNameResolver> NameResolver { get; set; }
}
/// <summary>
/// Настраивает базовые моки репозиториев с использованием InMemory-контекста.
/// Возвращает объект с подготовленными моками для повторного использования в тестах.
/// </summary>
/// <param name="context">Экземпляр DataContext с тестовыми данными</param>
private static RepositoryMocks ArrangeRepositoryMocks(DataContext context)
{
var unitRepoMock = new Mock<IUnitRepository>();
@@ -126,16 +96,6 @@ public class UnitFilterServiceTests
unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable());
var unitInValueRepoMock = new Mock<IUnitInValueRepository>();
// Единый подход: используем контекст для поддержки IAsyncQueryProvider
// Это гарантирует корректную работу .ToListAsync() внутри сервиса
unitInValueRepoMock.Setup(r => r.GetMatchingTargetIds(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null)
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
var unitFieldRepoMock = new Mock<IUnitFieldRepository>();
unitFieldRepoMock.Setup(r => r.Get()).Returns(new List<UnitField>().AsQueryable());
@@ -154,46 +114,60 @@ public class UnitFilterServiceTests
var resultLoaderMock = new Mock<IUnitFilterResultLoader>();
var nameResolverMock = new Mock<IUnitNameResolver>();
// Настройка NameResolver: возвращаем все ID юнитов из контекста (эмуляция кэш-промаха + БД)
nameResolverMock.Setup(r => r.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string mask, CancellationToken ct) =>
context.Units.Select(u => u.Id).ToList());
// Настройка FieldMatcher: эмуляция SQL-фильтрации через InMemory-контекст
fieldMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobFieldFilter>>(), It.IsAny<CancellationToken>()))
// По умолчанию FieldMatcher пропускает все юниты без изменений.
// В конкретных тестах переопределяется для эмуляции фильтрации.
fieldMatcherMock.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobFieldFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
ids.ToList());
// Настройка RelationshipMatcher: возвращаем контексты без изменений
relationshipMatcherMock.Setup(r => r.MatchAsync(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IEnumerable<JobRelationshipFilter>>(), It.IsAny<CancellationToken>()))
// По умолчанию RelationshipMatcher возвращает контексты без изменений.
// В конкретных тестах переопределяется для эмуляции фильтрации связей.
relationshipMatcherMock.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Select(id => new UnitFilterMatchResult { UnitId = id }).ToList());
ids.Select(id => new UnitFilterMatchResult
{
UnitId = id,
ValidParentIds = new HashSet<Guid>(),
ValidChildIds = new HashSet<Guid>()
}).ToList());
// Настройка UmbrellaFilter: пропускаем без изменений
umbrellaFilterMock.Setup(r => r.Apply(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<Job>()))
.Returns((List<UnitFilterMatchResult> ctx, Job j) => ctx);
// Настройка ResultLoader: формируем DTO из контекста
resultLoaderMock.Setup(r => r.LoadAsync(It.IsAny<List<UnitFilterMatchResult>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((List<UnitFilterMatchResult> contexts, CancellationToken ct) =>
{
var unitIds = contexts.Select(c => c.UnitId).ToHashSet();
var units = context.Units.Where(u => unitIds.Contains(u.Id)).ToList();
return units.Select(u => new UnitFilterResultDto
return units.Select(u =>
{
Id = u.Id,
Name = u.Name,
Values = u.UnitValues?.Select(v => new UnitValueDto
var ctx = contexts.First(c => c.UnitId == u.Id);
return new UnitFilterResultDto
{
FieldId = v.FieldId,
Value = v.Value?.Value
}).ToList() ?? new List<UnitValueDto>(),
Parents = contexts.First(c => c.UnitId == u.Id).ValidParentIds
.Select(pid => new RelatedUnitDto { UnitId = pid })
.ToList(),
Children = contexts.First(c => c.UnitId == u.Id).ValidChildIds
.Select(cid => new RelatedUnitDto { UnitId = cid })
.ToList()
Id = u.Id,
Name = u.Name,
Values = u.UnitValues?.Select(v => new UnitValueDto
{
FieldId = v.FieldId,
Value = v.Value?.Value
}).ToList() ?? new List<UnitValueDto>(),
Parents = (ctx.ValidParentIds ?? new HashSet<Guid>())
.Select(pid => new RelatedUnitDto { UnitId = pid })
.ToList(),
Children = (ctx.ValidChildIds ?? new HashSet<Guid>())
.Select(cid => new RelatedUnitDto { UnitId = cid })
.ToList()
};
}).ToList();
});
@@ -214,12 +188,6 @@ public class UnitFilterServiceTests
};
}
/// <summary>
/// Создаёт экземпляр тестируемого сервиса (SUT — System Under Test).
/// Инкапсулирует логику конструктора для упрощения тестов.
/// </summary>
/// <param name="mocks">Подготовленные моки репозиториев</param>
/// <param name="logger">Экземпляр логгера для сервиса</param>
private static Core.Services.UnitFilterService.UnitFilterService CreateSut(
RepositoryMocks mocks,
ILogger<Core.Services.UnitFilterService.UnitFilterService> logger)
@@ -245,8 +213,9 @@ public class UnitFilterServiceTests
/// <summary>
/// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter)
/// или по связям (RelationshipFilter), корректно исключаются из результата.
/// Проверяет, что юниты, не прошедшие фильтрацию по полю или по связям,
/// корректно исключаются из результата.
/// Логика фильтрации эмулируется через моки FieldMatcher и RelationshipMatcher.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
@@ -260,30 +229,24 @@ public class UnitFilterServiceTests
var jobId = Guid.NewGuid();
var fieldId = Guid.NewGuid();
var validParentId = Guid.NewGuid();
var wrongParentId = Guid.NewGuid();
var unitPassesAll = Guid.NewGuid();
var unitFailsFieldFilter = Guid.NewGuid();
var unitFailsRelationshipFilter = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(unitPassesAll, "Unit_PassesAll", fieldId, "Accepted Value"),
BuildTestUnit(unitFailsFieldFilter, "Unit_FailsField", fieldId, "Rejected Value"),
BuildTestUnit(unitFailsRelationshipFilter, "Unit_FailsRel", fieldId, "Accepted Value"),
BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent"),
BuildTestUnit(wrongParentId, "Parent_Wrong", fieldId, "Wrong Parent")
BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = wrongParentId, ChildUnitId = unitFailsRelationshipFilter, DateCreated = DateTimeOffset.UtcNow }
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow }
);
await context.SaveChangesAsync();
// Формируем тестовый объект Job с фильтрами
var job = new Job
{
Id = jobId,
@@ -318,20 +281,29 @@ public class UnitFilterServiceTests
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок фильтрации по полю: в реальном коде используется EF.Functions.ILike,
// в тесте заменяем на прямое сравнение строк для предсказуемости
mocks.Unit.Setup(r => r.GetUnitByFieldAndValue(It.IsAny<IQueryable<Unit>>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<bool>()))
.Returns((IQueryable<Unit> q, Guid fId, string mask, bool inv) =>
q.Where(u => u.UnitValues != null && u.UnitValues.Any(v => v.FieldId == fId && v.Value != null && v.Value.Value == "Accepted Value")));
// Эмуляция FieldMatcher: пропускает только юниты со значением "Accepted Value"
mocks.FieldMatcher.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobFieldFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobFieldFilter> filters, CancellationToken ct) =>
ids.Where(id => id == unitPassesAll || id == unitFailsRelationshipFilter).ToList());
// ИСПРАВЛЕНО: используем запрос к контексту вместо массива.
// Массивный AsQueryable() не поддерживает IAsyncQueryProvider, что вызывает крах при вызове .ToListAsync() внутри сервиса.
// Запрос к InMemory DbSet гарантирует корректную асинхронную материализацию.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny<string>()))
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
// Эмуляция RelationshipMatcher: пропускает только unitPassesAll
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Where(id => id == unitPassesAll)
.Select(id => new UnitFilterMatchResult
{
UnitId = id,
ValidParentIds = new HashSet<Guid> { validParentId },
ValidChildIds = new HashSet<Guid>()
}).ToList());
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
@@ -354,8 +326,8 @@ public class UnitFilterServiceTests
}
/// <summary>
/// Проверяет, что юниты исключаются, если их единственный родительский юнит
/// содержит запрещённое значение тега (сценарий с IsInverse = true).
/// Проверяет, что юниты исключаются, если их родитель содержит запрещённое значение (IsInverse = true).
/// Логика фильтрации связей эмулируется через мок RelationshipMatcher.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
@@ -373,7 +345,6 @@ public class UnitFilterServiceTests
var unitWithValidParent = Guid.NewGuid();
var unitWithInvalidParent = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(validParentId, "Parent_Valid", tagFieldId, "ОТВ.ЭК"),
BuildTestUnit(invalidParentId, "Parent_Invalid", tagFieldId, "ПАРР-РРПТК-ОТВ.ЭК"),
@@ -381,7 +352,6 @@ public class UnitFilterServiceTests
BuildTestUnit(unitWithInvalidParent, "Unit_Invalid", Guid.NewGuid(), "Val")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitWithValidParent, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = invalidParentId, ChildUnitId = unitWithInvalidParent, DateCreated = DateTimeOffset.UtcNow }
@@ -389,8 +359,6 @@ public class UnitFilterServiceTests
await context.SaveChangesAsync();
// Формируем тестовый объект Job с настройкой IsInverse = true
// IsInverse = true означает: исключить родителей, которые СОВПАДАЮТ с маской
var job = new Job
{
Id = jobId,
@@ -430,20 +398,21 @@ public class UnitFilterServiceTests
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей с запрещённым тегом.
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
// IsInverse = true в сервисе инвертирует результат, поэтому эти родители будут исключены.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("ПАРР-РРПТК-ОТВ.ЭК", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
// Эмуляция RelationshipMatcher: исключает unitWithInvalidParent
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Where(id => id == unitWithValidParent)
.Select(id => new UnitFilterMatchResult
{
UnitId = id,
ValidParentIds = new HashSet<Guid> { validParentId },
ValidChildIds = new HashSet<Guid>()
}).ToList());
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
@@ -459,8 +428,8 @@ public class UnitFilterServiceTests
}
/// <summary>
/// Проверяет логику IsFullMatch = true: юнит исключается,
/// если хотя бы один из его родительских юнитов не соответствует фильтру.
/// Проверяет логику IsFullMatch = true: юнит исключается,
/// если хотя бы один из его родителей не соответствует фильтру.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
@@ -478,7 +447,6 @@ public class UnitFilterServiceTests
var unitMixedId = Guid.NewGuid();
var unitCleanId = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(parentGoodId, "Parent_Good", checkFieldId, "Good Parent"),
BuildTestUnit(parentBadId, "Parent_Bad", checkFieldId, "Bad Parent"),
@@ -486,7 +454,6 @@ public class UnitFilterServiceTests
BuildTestUnit(unitCleanId, "Unit_Clean", Guid.NewGuid(), "Clean")
);
// Подготовка тестовых данных: связи между юнитами
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentBadId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
@@ -495,8 +462,6 @@ public class UnitFilterServiceTests
await context.SaveChangesAsync();
// Формируем тестовый объект Job с настройкой IsFullMatch = true
// IsFullMatch = true означает: ВСЕ родители должны соответствовать фильтру
var job = new Job
{
Id = jobId,
@@ -536,19 +501,21 @@ public class UnitFilterServiceTests
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей, соответствующих маске "Good".
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(checkFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("Good", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
// Эмуляция RelationshipMatcher: исключает unitMixedId (один из родителей не проходит)
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Where(id => id == unitCleanId)
.Select(id => new UnitFilterMatchResult
{
UnitId = id,
ValidParentIds = new HashSet<Guid> { parentGoodId },
ValidChildIds = new HashSet<Guid>()
}).ToList());
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
@@ -563,10 +530,9 @@ public class UnitFilterServiceTests
Assert.DoesNotContain(unitMixedId, resultIds);
}
/// <summary>
/// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true.
/// Логика: юнит проходит, только если НИ ОДИН из его родителей не содержит запрещённое значение.
/// Юнит проходит, только если ни один из его родителей не содержит запрещённое значение.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded()
@@ -579,12 +545,11 @@ public class UnitFilterServiceTests
var jobId = Guid.NewGuid();
var tagFieldId = Guid.NewGuid();
var parentForbiddenId = Guid.NewGuid(); // Родитель с запрещённым тегом
var parentCleanId = Guid.NewGuid(); // Родитель без запрещённого тега
var unitMixedId = Guid.NewGuid(); // Юнит с одним "плохим" и одним "хорошим" родителем
var unitAllCleanId = Guid.NewGuid(); // Юнит только с "хорошими" родителями
var parentForbiddenId = Guid.NewGuid();
var parentCleanId = Guid.NewGuid();
var unitMixedId = Guid.NewGuid();
var unitAllCleanId = Guid.NewGuid();
// Подготовка тестовых данных: юниты
context.Units.AddRange(
BuildTestUnit(parentForbiddenId, "Parent_Forbidden", tagFieldId, "FORBIDDEN-TAG"),
BuildTestUnit(parentCleanId, "Parent_Clean", tagFieldId, "CLEAN-TAG"),
@@ -592,9 +557,6 @@ public class UnitFilterServiceTests
BuildTestUnit(unitAllCleanId, "Unit_AllClean", Guid.NewGuid(), "AllClean")
);
// Подготовка тестовых данных: связи
// unitMixed имеет обоих родителей — один с запрещённым тегом, один без
// unitAllClean имеет только "чистого" родителя
context.UnitInUnits.AddRange(
new UnitInUnit { ParentUnitId = parentForbiddenId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow },
@@ -603,10 +565,6 @@ public class UnitFilterServiceTests
await context.SaveChangesAsync();
// Формируем тестовый объект Job с комбинацией флагов:
// IsParent = true: проверяем родительские связи
// IsInverse = true: исключаем родителей, которые СОВПАДАЮТ с маской
// IsFullMatch = true: ВСЕ родители должны пройти фильтр (ни один не должен совпасть с маской)
var job = new Job
{
Id = jobId,
@@ -646,21 +604,21 @@ public class UnitFilterServiceTests
}
};
// Настройка моков репозиториев
var mocks = ArrangeRepositoryMocks(context);
// Переопределяем мок: возвращаем только родителей, содержащих "FORBIDDEN".
// В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты.
// Из-за IsInverse=true эти родители будут исключены из "валидных".
// Из-за IsFullMatch=true юнит пройдёт, только если ВСЕ его родители валидны.
mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny<string>()))
.Returns((Guid fId, string mask) =>
context.UnitInValues
.AsNoTracking()
.Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("FORBIDDEN", StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.AsQueryable());
// Эмуляция RelationshipMatcher: исключает unitMixedId (есть родитель с запрещённым тегом)
mocks.RelationshipMatcher.Setup(r => r.MatchAsync(
It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<IEnumerable<JobRelationshipFilter>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, IEnumerable<JobRelationshipFilter> filters, CancellationToken ct) =>
ids.Where(id => id == unitAllCleanId)
.Select(id => new UnitFilterMatchResult
{
UnitId = id,
ValidParentIds = new HashSet<Guid> { parentCleanId },
ValidChildIds = new HashSet<Guid>()
}).ToList());
var logger = NullLoggerFactory.Instance.CreateLogger<Core.Services.UnitFilterService.UnitFilterService>();
var service = CreateSut(mocks, logger);
@@ -671,15 +629,7 @@ public class UnitFilterServiceTests
// === Assert ===
var resultIds = result.Select(u => u.Id).ToList();
// unitAllCleanId должен остаться: у него один родитель, и он не содержит запрещённый тег
Assert.Contains(unitAllCleanId, resultIds);
// unitMixedId должен быть исключён: у него есть родитель с запрещённым тегом.
// Логика:
// 1. GetMatchingTargetIds возвращает {parentForbiddenId}
// 2. IsInverse=true → валидные родители = все.Кроме({parentForbiddenId}) = {parentCleanId}
// 3. IsFullMatch=true → проверяем: все родители {parentForbiddenId, parentCleanId} входят в {parentCleanId}? Нет.
// 4. Юнит исключается.
Assert.DoesNotContain(unitMixedId, resultIds);
}
}

View File

@@ -8,6 +8,7 @@ using PARR.Domain.Entities.JobGroupEntities;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Entities.Schedule;
using PARR.Domain.Entities.TaskEntities;
using PARR.Domain.Entities.TemplateEntities;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
using PARR.Domain.Settings;
@@ -18,34 +19,13 @@ namespace PARR.DAL.Context
{
public DataContext(DbContextOptions<DataContext> options) : base(options) { }
//public DbSet<Host> Hosts { get; set; }
//public DbSet<WorkGroup> WorkGroups { get; set; }
//public DbSet<AppInWorkInWorkGroup> AppInWorkInWorkGroups { get; set; }
//public DbSet<EkStatus> EkStatuses { get; set; }
//public DbSet<ResponseArea> ResponseAreas { get; set; }
//public DbSet<Application> Applications { get; set; }
//public DbSet<ApplicationType> ApplicationTypes { get; set; }
//public DbSet<ApplicationInHost> ApplicationsInHosts { get; set; }
public DbSet<Template> Templates { get; set; }
public DbSet<TemplateHistory> TemplateHistories { get; set; }
public DbSet<TemplateStatusType> TemplateStatusTypes { get; set; }
public DbSet<Domain.Entities.TaskStatus> TaskStatuses { get; set; }
public DbSet<RobotStatus> RobotStatuses { get; set; }
public DbSet<Process> Processes { get; set; }
public DbSet<Subprocess> Subprocesses { get; set; }
public DbSet<Tnk> Tnks { get; set; }
//public DbSet<ApplicationsInWork> ApplicationsInWorks { get; set; }
public DbSet<Setting> Setting { get; set; }
public DbSet<RobotHistoryLevel> RobotHistoryLevels { get; set; }
public DbSet<Robot> Robots { get; set; }
public DbSet<RobotConfiguration> RobotConfigurations { get; set; }
public DbSet<RobotHistory> RobotHistories { get; set; }
#region Schedule
@@ -59,6 +39,8 @@ namespace PARR.DAL.Context
public DbSet<ScheduleExcludeType> ScheduleExcludeTypes { get; set; }
public DbSet<ScheduleResponseAreaTimeOffset> ScheduleResponseAreaTimeOffsets { get; set; }
public DbSet<DistributionPeriod> DistributionPeriods { get; set; }
#endregion
public DbSet<AgentHistory> AgentHistories { get; set; }
@@ -73,8 +55,6 @@ namespace PARR.DAL.Context
public DbSet<WeekendDay> WeekendDays { get; set; }
public DbSet<DistributionPeriod> DistributionPeriods { get; set; }
public DbSet<ParrComponent> ParrComponents { get; set; }
#region Units
@@ -142,11 +122,25 @@ namespace PARR.DAL.Context
#region Robot
public DbSet<RobotHistory> RobotHistories { get; set; }
public DbSet<RobotHistoryLevel> RobotHistoryLevels { get; set; }
public DbSet<Robot> Robots { get; set; }
public DbSet<Domain.Entities.RobotEntities.TaskStatus> TaskStatuses { get; set; }
public DbSet<RobotStatus> RobotStatuses { get; set; }
public DbSet<RobotSnapshot> RobotSnapshots { get; set; }
public DbSet<RobotConfigurationSnapshot> RobotConfigurationSnapshots { get; set; }
#endregion
#region Template
public DbSet<Template> Templates { get; set; }
public DbSet<TemplateHistory> TemplateHistories { get; set; }
public DbSet<TemplateStatusType> TemplateStatusTypes { get; set; }
public DbSet<TemplateRenamePending> TemplateRenamePendings { get; set; }
#endregion
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -203,7 +197,7 @@ namespace PARR.DAL.Context
#endregion
#region TaskStatus
modelBuilder.Entity<Domain.Entities.TaskStatus>(f =>
modelBuilder.Entity<Domain.Entities.RobotEntities.TaskStatus>(f =>
{
f.HasData(

View File

@@ -8,6 +8,7 @@ using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Core.Repositories.Interfaces.RobotRepositories;
using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Core.Repositories.Interfaces.TaskRepositories;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.DAL.Configurations.DbSettings;
using PARR.DAL.Context;
@@ -18,6 +19,7 @@ using PARR.DAL.Repositories.JobRepositories;
using PARR.DAL.Repositories.RobotRepositories;
using PARR.DAL.Repositories.Schedule;
using PARR.DAL.Repositories.TaskRepositories;
using PARR.DAL.Repositories.TemplateRepositories;
using PARR.DAL.Repositories.Unit;
using PARR.Domain.Settings;
@@ -39,7 +41,10 @@ namespace PARR.DAL
services.AddDbContext<DataContext>(opt =>
opt
.EnableSensitiveDataLogging()
.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))
.UseNpgsql(
configuration.GetConnectionString("DefaultConnection")
//, npgsqlOptions => npgsqlOptions.CommandTimeout(300) // Время в секундах (5 минут)
)
);
@@ -139,6 +144,12 @@ namespace PARR.DAL
#endregion
#region Templates
services.AddScoped<ITemplateRenamePendingRepository, TemplateRenamePendingRepository>();
#endregion
//services.AddTransient<INextRunModifierService, NextRunModifierService>();
#region NextRun Services

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblRobotsUpdateScheme : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "TaskStatuses",
newName: "TaskStatuses",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "RobotStatuses",
newName: "RobotStatuses",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "Robots",
newName: "Robots",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "RobotHistoryLevels",
newName: "RobotHistoryLevels",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "RobotHistories",
newName: "RobotHistories",
newSchema: "robot");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameTable(
name: "TaskStatuses",
schema: "robot",
newName: "TaskStatuses");
migrationBuilder.RenameTable(
name: "RobotStatuses",
schema: "robot",
newName: "RobotStatuses");
migrationBuilder.RenameTable(
name: "Robots",
schema: "robot",
newName: "Robots");
migrationBuilder.RenameTable(
name: "RobotHistoryLevels",
schema: "robot",
newName: "RobotHistoryLevels");
migrationBuilder.RenameTable(
name: "RobotHistories",
schema: "robot",
newName: "RobotHistories");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblRobotsRename : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RobotHistories_RobotConfigurations_RobotConfigurationId",
schema: "robot",
table: "RobotHistories");
migrationBuilder.DropForeignKey(
name: "FK_RobotHistories_RobotHistoryLevels_HistoryLevel",
schema: "robot",
table: "RobotHistories");
migrationBuilder.DropForeignKey(
name: "FK_RobotHistories_TaskStatuses_TaskStatusCode",
schema: "robot",
table: "RobotHistories");
migrationBuilder.DropPrimaryKey(
name: "PK_RobotHistoryLevels",
schema: "robot",
table: "RobotHistoryLevels");
migrationBuilder.DropPrimaryKey(
name: "PK_RobotHistories",
schema: "robot",
table: "RobotHistories");
migrationBuilder.RenameTable(
name: "RobotHistoryLevels",
schema: "robot",
newName: "HistoryLevels",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "RobotHistories",
schema: "robot",
newName: "Histories",
newSchema: "robot");
migrationBuilder.RenameIndex(
name: "IX_RobotHistories_TaskStatusCode",
schema: "robot",
table: "Histories",
newName: "IX_Histories_TaskStatusCode");
migrationBuilder.RenameIndex(
name: "IX_RobotHistories_RobotConfigurationId_DateCreated",
schema: "robot",
table: "Histories",
newName: "IX_Histories_RobotConfigurationId_DateCreated");
migrationBuilder.RenameIndex(
name: "IX_RobotHistories_HistoryLevel_DateCreated",
schema: "robot",
table: "Histories",
newName: "IX_Histories_HistoryLevel_DateCreated");
migrationBuilder.RenameIndex(
name: "IX_RobotHistories_DateCreated",
schema: "robot",
table: "Histories",
newName: "IX_Histories_DateCreated");
migrationBuilder.AddPrimaryKey(
name: "PK_HistoryLevels",
schema: "robot",
table: "HistoryLevels",
column: "Level");
migrationBuilder.AddPrimaryKey(
name: "PK_Histories",
schema: "robot",
table: "Histories",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Histories_HistoryLevels_HistoryLevel",
schema: "robot",
table: "Histories",
column: "HistoryLevel",
principalSchema: "robot",
principalTable: "HistoryLevels",
principalColumn: "Level",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Histories_RobotConfigurations_RobotConfigurationId",
schema: "robot",
table: "Histories",
column: "RobotConfigurationId",
principalTable: "RobotConfigurations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Histories_TaskStatuses_TaskStatusCode",
schema: "robot",
table: "Histories",
column: "TaskStatusCode",
principalSchema: "robot",
principalTable: "TaskStatuses",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Histories_HistoryLevels_HistoryLevel",
schema: "robot",
table: "Histories");
migrationBuilder.DropForeignKey(
name: "FK_Histories_RobotConfigurations_RobotConfigurationId",
schema: "robot",
table: "Histories");
migrationBuilder.DropForeignKey(
name: "FK_Histories_TaskStatuses_TaskStatusCode",
schema: "robot",
table: "Histories");
migrationBuilder.DropPrimaryKey(
name: "PK_HistoryLevels",
schema: "robot",
table: "HistoryLevels");
migrationBuilder.DropPrimaryKey(
name: "PK_Histories",
schema: "robot",
table: "Histories");
migrationBuilder.RenameTable(
name: "HistoryLevels",
schema: "robot",
newName: "RobotHistoryLevels",
newSchema: "robot");
migrationBuilder.RenameTable(
name: "Histories",
schema: "robot",
newName: "RobotHistories",
newSchema: "robot");
migrationBuilder.RenameIndex(
name: "IX_Histories_TaskStatusCode",
schema: "robot",
table: "RobotHistories",
newName: "IX_RobotHistories_TaskStatusCode");
migrationBuilder.RenameIndex(
name: "IX_Histories_RobotConfigurationId_DateCreated",
schema: "robot",
table: "RobotHistories",
newName: "IX_RobotHistories_RobotConfigurationId_DateCreated");
migrationBuilder.RenameIndex(
name: "IX_Histories_HistoryLevel_DateCreated",
schema: "robot",
table: "RobotHistories",
newName: "IX_RobotHistories_HistoryLevel_DateCreated");
migrationBuilder.RenameIndex(
name: "IX_Histories_DateCreated",
schema: "robot",
table: "RobotHistories",
newName: "IX_RobotHistories_DateCreated");
migrationBuilder.AddPrimaryKey(
name: "PK_RobotHistoryLevels",
schema: "robot",
table: "RobotHistoryLevels",
column: "Level");
migrationBuilder.AddPrimaryKey(
name: "PK_RobotHistories",
schema: "robot",
table: "RobotHistories",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_RobotHistories_RobotConfigurations_RobotConfigurationId",
schema: "robot",
table: "RobotHistories",
column: "RobotConfigurationId",
principalTable: "RobotConfigurations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RobotHistories_RobotHistoryLevels_HistoryLevel",
schema: "robot",
table: "RobotHistories",
column: "HistoryLevel",
principalSchema: "robot",
principalTable: "RobotHistoryLevels",
principalColumn: "Level",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RobotHistories_TaskStatuses_TaskStatusCode",
schema: "robot",
table: "RobotHistories",
column: "TaskStatusCode",
principalSchema: "robot",
principalTable: "TaskStatuses",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblTemplateRenamePendings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "template");
migrationBuilder.CreateTable(
name: "TemplateRenamePendings",
schema: "template",
columns: table => new
{
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
DateCreated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
OldName = table.Column<string>(type: "text", nullable: false, comment: "Старое имя шаблона")
},
constraints: table =>
{
table.PrimaryKey("PK_TemplateRenamePendings", x => x.TemplateId);
table.ForeignKey(
name: "FK_TemplateRenamePendings_Templates_TemplateId",
column: x => x.TemplateId,
principalTable: "Templates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
},
comment: "Шаблоны находящиеся в процессе переименования");
migrationBuilder.CreateIndex(
name: "IX_TemplateRenamePendings_OldName",
schema: "template",
table: "TemplateRenamePendings",
column: "OldName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TemplateRenamePendings",
schema: "template");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblRobotConfigurationsAddIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_RobotConfigurations_RobotCode",
table: "RobotConfigurations");
migrationBuilder.CreateIndex(
name: "IX_RobotConfigurations_RobotCode_RobotStatusCode",
table: "RobotConfigurations",
columns: new[] { "RobotCode", "RobotStatusCode" });
migrationBuilder.CreateIndex(
name: "IX_RobotConfigurations_RobotCode_TaskStatusCode",
table: "RobotConfigurations",
columns: new[] { "RobotCode", "TaskStatusCode" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_RobotConfigurations_RobotCode_RobotStatusCode",
table: "RobotConfigurations");
migrationBuilder.DropIndex(
name: "IX_RobotConfigurations_RobotCode_TaskStatusCode",
table: "RobotConfigurations");
migrationBuilder.CreateIndex(
name: "IX_RobotConfigurations_RobotCode",
table: "RobotConfigurations",
column: "RobotCode");
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblTemplateRenamePendingAddDateModifiedRemUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_TemplateRenamePendings_OldName",
schema: "template",
table: "TemplateRenamePendings");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DateModified",
schema: "template",
table: "TemplateRenamePendings",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TemplateRenamePendings_OldName",
schema: "template",
table: "TemplateRenamePendings",
column: "OldName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_TemplateRenamePendings_OldName",
schema: "template",
table: "TemplateRenamePendings");
migrationBuilder.DropColumn(
name: "DateModified",
schema: "template",
table: "TemplateRenamePendings");
migrationBuilder.CreateIndex(
name: "IX_TemplateRenamePendings_OldName",
schema: "template",
table: "TemplateRenamePendings",
column: "OldName",
unique: true);
}
}
}

View File

@@ -921,44 +921,6 @@ namespace PARR.DAL.Migrations
b.ToTable("Processes");
});
modelBuilder.Entity("PARR.Domain.Entities.Robot", b =>
{
b.Property<int>("Code")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Code"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Code");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Robots");
b.HasData(
new
{
Code = 1,
Description = "Робот по созданию/изменению шаблона наряда ЕСПП",
Name = "TemplateOrder"
},
new
{
Code = 2,
Description = "Робот по созданию/изменению расписания шаблона наряда в ЕСПП",
Name = "ScheduleOrder"
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotConfiguration", b =>
{
b.Property<Guid>("Id")
@@ -988,12 +950,14 @@ namespace PARR.DAL.Migrations
b.HasKey("Id");
b.HasIndex("RobotCode");
b.HasIndex("RobotStatusCode");
b.HasIndex("TaskStatusCode");
b.HasIndex("RobotCode", "RobotStatusCode");
b.HasIndex("RobotCode", "TaskStatusCode");
b.HasIndex("TemplateId", "RobotCode")
.IsUnique();
@@ -1004,6 +968,44 @@ namespace PARR.DAL.Migrations
b.ToTable("RobotConfigurations");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.Robot", b =>
{
b.Property<int>("Code")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Code"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Code");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Robots", "robot");
b.HasData(
new
{
Code = 1,
Description = "Робот по созданию/изменению шаблона наряда ЕСПП",
Name = "TemplateOrder"
},
new
{
Code = 2,
Description = "Робот по созданию/изменению расписания шаблона наряда в ЕСПП",
Name = "ScheduleOrder"
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotConfigurationSnapshot", b =>
{
b.Property<Guid>("Id")
@@ -1040,44 +1042,7 @@ namespace PARR.DAL.Migrations
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<string>("Ip")
.IsRequired()
.HasMaxLength(45)
.HasColumnType("character varying(45)")
.HasComment("Текущий IP-адрес сервера");
b.Property<int>("MaxRobots")
.HasColumnType("integer")
.HasComment("Максимально разрешенное количество роботов на сервере");
b.Property<int>("ScheduleRobotsCount")
.HasColumnType("integer")
.HasComment("Количество запущенных роботов по расписаниям");
b.Property<int>("TemplateRobotsCount")
.HasColumnType("integer")
.HasComment("Количество запущенных роботов по шаблонам");
b.HasKey("Id");
b.HasIndex("Ip", "DateCreated");
b.ToTable("Snapshots", "robot", t =>
{
t.HasComment("Снимки роботов");
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotHistory", b =>
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotHistory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -1119,10 +1084,10 @@ namespace PARR.DAL.Migrations
b.HasIndex("RobotConfigurationId", "DateCreated");
b.ToTable("RobotHistories");
b.ToTable("Histories", "robot");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotHistoryLevel", b =>
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotHistoryLevel", b =>
{
b.Property<int>("Level")
.ValueGeneratedOnAdd()
@@ -1140,7 +1105,7 @@ namespace PARR.DAL.Migrations
b.HasKey("Level");
b.ToTable("RobotHistoryLevels");
b.ToTable("HistoryLevels", "robot");
b.HasData(
new
@@ -1169,7 +1134,44 @@ namespace PARR.DAL.Migrations
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotStatus", b =>
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<string>("Ip")
.IsRequired()
.HasMaxLength(45)
.HasColumnType("character varying(45)")
.HasComment("Текущий IP-адрес сервера");
b.Property<int>("MaxRobots")
.HasColumnType("integer")
.HasComment("Максимально разрешенное количество роботов на сервере");
b.Property<int>("ScheduleRobotsCount")
.HasColumnType("integer")
.HasComment("Количество запущенных роботов по расписаниям");
b.Property<int>("TemplateRobotsCount")
.HasColumnType("integer")
.HasComment("Количество запущенных роботов по шаблонам");
b.HasKey("Id");
b.HasIndex("Ip", "DateCreated");
b.ToTable("Snapshots", "robot", t =>
{
t.HasComment("Снимки роботов");
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotStatus", b =>
{
b.Property<int>("Code")
.ValueGeneratedOnAdd()
@@ -1187,7 +1189,7 @@ namespace PARR.DAL.Migrations
b.HasKey("Code");
b.ToTable("RobotStatuses");
b.ToTable("RobotStatuses", "robot");
b.HasData(
new
@@ -1216,6 +1218,47 @@ namespace PARR.DAL.Migrations
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.TaskStatus", b =>
{
b.Property<int>("Code")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Code"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Code");
b.ToTable("TaskStatuses", "robot");
b.HasData(
new
{
Code = 10,
Description = "Требуется создание объекта в ЕСПП",
Name = "Creating"
},
new
{
Code = 20,
Description = "Требуется обновление объекта в ЕСПП",
Name = "Updating"
},
new
{
Code = 30,
Description = "Нормальное состояние объекта в ЕСПП и ПАРР. Объект в ПАРР соответствует объекту в ЕСПП",
Name = "Ok"
});
});
modelBuilder.Entity("PARR.Domain.Entities.Role", b =>
{
b.Property<Guid>("Id")
@@ -2743,47 +2786,6 @@ namespace PARR.DAL.Migrations
});
});
modelBuilder.Entity("PARR.Domain.Entities.TaskStatus", b =>
{
b.Property<int>("Code")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Code"));
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Code");
b.ToTable("TaskStatuses");
b.HasData(
new
{
Code = 10,
Description = "Требуется создание объекта в ЕСПП",
Name = "Creating"
},
new
{
Code = 20,
Description = "Требуется обновление объекта в ЕСПП",
Name = "Updating"
},
new
{
Code = 30,
Description = "Нормальное состояние объекта в ЕСПП и ПАРР. Объект в ПАРР соответствует объекту в ЕСПП",
Name = "Ok"
});
});
modelBuilder.Entity("PARR.Domain.Entities.Template", b =>
{
b.Property<Guid>("Id")
@@ -2850,6 +2852,32 @@ namespace PARR.DAL.Migrations
b.ToTable("Templates");
});
modelBuilder.Entity("PARR.Domain.Entities.TemplateEntities.TemplateRenamePending", b =>
{
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("OldName")
.IsRequired()
.HasColumnType("text")
.HasComment("Старое имя шаблона");
b.HasKey("TemplateId");
b.HasIndex("OldName");
b.ToTable("TemplateRenamePendings", "template", t =>
{
t.HasComment("Шаблоны находящиеся в процессе переименования");
});
});
modelBuilder.Entity("PARR.Domain.Entities.TemplateHistory", b =>
{
b.Property<Guid>("Id")
@@ -3516,19 +3544,19 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.RobotConfiguration", b =>
{
b.HasOne("PARR.Domain.Entities.Robot", "Robot")
b.HasOne("PARR.Domain.Entities.RobotEntities.Robot", "Robot")
.WithMany("RobotConfigurations")
.HasForeignKey("RobotCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.RobotStatus", "RobotStatus")
b.HasOne("PARR.Domain.Entities.RobotEntities.RobotStatus", "RobotStatus")
.WithMany("RobotConfigurations")
.HasForeignKey("RobotStatusCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.TaskStatus", "TaskStatus")
b.HasOne("PARR.Domain.Entities.RobotEntities.TaskStatus", "TaskStatus")
.WithMany("RobotConfigurations")
.HasForeignKey("TaskStatusCode")
.OnDelete(DeleteBehavior.Cascade)
@@ -3551,19 +3579,19 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotConfigurationSnapshot", b =>
{
b.HasOne("PARR.Domain.Entities.Robot", "Robot")
b.HasOne("PARR.Domain.Entities.RobotEntities.Robot", "Robot")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("RobotCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.RobotStatus", "RobotStatus")
b.HasOne("PARR.Domain.Entities.RobotEntities.RobotStatus", "RobotStatus")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("RobotStatusCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.TaskStatus", "TaskStatus")
b.HasOne("PARR.Domain.Entities.RobotEntities.TaskStatus", "TaskStatus")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("TaskStatusCode")
.OnDelete(DeleteBehavior.Cascade)
@@ -3576,9 +3604,9 @@ namespace PARR.DAL.Migrations
b.Navigation("TaskStatus");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotHistory", b =>
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotHistory", b =>
{
b.HasOne("PARR.Domain.Entities.RobotHistoryLevel", "RobotHistoryLevel")
b.HasOne("PARR.Domain.Entities.RobotEntities.RobotHistoryLevel", "RobotHistoryLevel")
.WithMany("RobotHistories")
.HasForeignKey("HistoryLevel")
.OnDelete(DeleteBehavior.Cascade)
@@ -3590,7 +3618,7 @@ namespace PARR.DAL.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.TaskStatus", "StatusTask")
b.HasOne("PARR.Domain.Entities.RobotEntities.TaskStatus", "StatusTask")
.WithMany("RobotHistories")
.HasForeignKey("TaskStatusCode")
.OnDelete(DeleteBehavior.Cascade)
@@ -3728,6 +3756,17 @@ namespace PARR.DAL.Migrations
b.Navigation("Unit");
});
modelBuilder.Entity("PARR.Domain.Entities.TemplateEntities.TemplateRenamePending", b =>
{
b.HasOne("PARR.Domain.Entities.Template", "Template")
.WithOne("TemplateRenamePending")
.HasForeignKey("PARR.Domain.Entities.TemplateEntities.TemplateRenamePending", "TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("PARR.Domain.Entities.TemplateHistory", b =>
{
b.HasOne("PARR.Domain.Entities.Template", "Template")
@@ -3927,30 +3966,39 @@ namespace PARR.DAL.Migrations
b.Navigation("Subprocesses");
});
modelBuilder.Entity("PARR.Domain.Entities.Robot", b =>
{
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotConfiguration", b =>
{
b.Navigation("RobotHistories");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotHistoryLevel", b =>
{
b.Navigation("RobotHistories");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotStatus", b =>
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.Robot", b =>
{
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotHistoryLevel", b =>
{
b.Navigation("RobotHistories");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotStatus", b =>
{
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.TaskStatus", b =>
{
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations");
b.Navigation("RobotHistories");
});
modelBuilder.Entity("PARR.Domain.Entities.Role", b =>
{
b.Navigation("Users");
@@ -4008,15 +4056,6 @@ namespace PARR.DAL.Migrations
b.Navigation("Tasks");
});
modelBuilder.Entity("PARR.Domain.Entities.TaskStatus", b =>
{
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations");
b.Navigation("RobotHistories");
});
modelBuilder.Entity("PARR.Domain.Entities.Template", b =>
{
b.Navigation("AgentHistories");
@@ -4027,6 +4066,8 @@ namespace PARR.DAL.Migrations
b.Navigation("TemplateHistories");
b.Navigation("TemplateRenamePending");
b.Navigation("UnitsInTemplate");
});

View File

@@ -15,23 +15,13 @@ namespace PARR.DAL.Repositories.Base
{
internal abstract class BaseRepository<T> : IBaseRepository<T> where T : class, IBaseEntity
{
//private readonly ILogger<BaseRepository<T>> logger;
//protected abstract DbSet<T> EntitySet { get; }
//protected abstract DataContext EntitiContext { get; }
//public BaseRepository(ILogger<BaseRepository<T>> logger)
//{
// this.logger = logger;
//}
protected readonly ILogger logger;
protected readonly ILogger _logger;
protected readonly DbSet<T> EntitySet;
protected readonly DataContext EntityContext;
protected BaseRepository(ILogger logger, DataContext dataContext)
{
this.logger = logger;
this._logger = logger;
this.EntityContext = dataContext;
this.EntitySet = dataContext.Set<T>();
}
@@ -39,7 +29,7 @@ namespace PARR.DAL.Repositories.Base
public virtual async Task<bool> AddRangeAsync(List<T> objs)
{
logger.LogDebug("Начинаю добавление диапазона объектов типа {EntityType}, количество: {Count}",
_logger.LogDebug("Начинаю добавление диапазона объектов типа {EntityType}, количество: {Count}",
typeof(T).Name, objs.Count);
objs.ForEach(item => item.DateCreated = DateTimeOffset.UtcNow);
@@ -47,26 +37,26 @@ namespace PARR.DAL.Repositories.Base
try
{
await EntitySet.AddRangeAsync(objs);
logger.LogDebug("Успешно добавлено {Count} объектов типа {EntityType}",
_logger.LogDebug("Успешно добавлено {Count} объектов типа {EntityType}",
objs.Count, typeof(T).Name);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при добавлении диапазона объектов типа {EntityType}", typeof(T).Name);
_logger.LogError(ex, "Ошибка при добавлении диапазона объектов типа {EntityType}", typeof(T).Name);
return false;
}
}
public async Task<bool> CommitAsync(IHistoryInitiator? initiator = null)
{
logger.LogDebug("Начинаю сохранение изменений в БД для объектов типа {EntityType}", typeof(T).Name);
_logger.LogDebug("Начинаю сохранение изменений в БД для объектов типа {EntityType}", typeof(T).Name);
#region Изменения
var modifiedEntrities = EntityContext.ChangeTracker.Entries()
.Where(t => t.State == EntityState.Modified/* || t.State == EntityState.Deleted*/);
logger.LogDebug("Найдено {Count} измененных сущностей для обработки истории", modifiedEntrities.Count());
_logger.LogDebug("Найдено {Count} измененных сущностей для обработки истории", modifiedEntrities.Count());
foreach (var obj in modifiedEntrities)
{
@@ -83,13 +73,13 @@ namespace PARR.DAL.Repositories.Base
try
{
var changedCount = await EntityContext.SaveChangesAsync();
logger.LogDebug("Успешно сохранено {ChangedCount} изменений в БД для объектов типа {EntityType}",
_logger.LogDebug("Успешно сохранено {ChangedCount} изменений в БД для объектов типа {EntityType}",
changedCount, typeof(T).Name);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при сохранении изменений в БД для объектов типа {EntityType}", typeof(T).Name);
_logger.LogError(ex, "Ошибка при сохранении изменений в БД для объектов типа {EntityType}", typeof(T).Name);
return false;
}
}
@@ -104,14 +94,14 @@ namespace PARR.DAL.Repositories.Base
if (initiator == null)
return;
logger.LogDebug("Устанавливаю инициатора для изменений");
_logger.LogDebug("Устанавливаю инициатора для изменений");
// Задаем инициатора только для новых и измененных записей
var entrities = EntityContext.ChangeTracker.Entries()
.Where(t => t.State == EntityState.Modified || t.State == EntityState.Added);
var entityCount = entrities.Count();
logger.LogDebug("Найдено {Count} сущностей для установки инициатора", entityCount);
_logger.LogDebug("Найдено {Count} сущностей для установки инициатора", entityCount);
// смотрим есть ли у объекта интерфейс IHistoryInitiator, если есть, задаём значения
foreach (var obj in entrities)
@@ -123,7 +113,7 @@ namespace PARR.DAL.Repositories.Base
(obj.Entity as IHistoryInitiator)!.InitiatorParrComponentId = initiator?.InitiatorParrComponentId ?? null;
(obj.Entity as IHistoryInitiator)!.InitiatorComment = initiator?.InitiatorComment ?? null;
logger.LogDebug("Установлен инициатор для сущности типа {EntityType}", obj.Entity.GetType().Name);
_logger.LogDebug("Установлен инициатор для сущности типа {EntityType}", obj.Entity.GetType().Name);
}
}
}
@@ -150,12 +140,12 @@ namespace PARR.DAL.Repositories.Base
if (!isManual)
{
logger.LogDebug("Обновляю DateModified для сущности типа {EntityType}", obj.Entity.GetType().Name);
_logger.LogDebug("Обновляю DateModified для сущности типа {EntityType}", obj.Entity.GetType().Name);
entity.DateModified = DateTimeOffset.UtcNow;
}
else
{
logger.LogDebug("Пропуск обновления DateModified (ManualControl) для {EntityType}", entityType.Name);
_logger.LogDebug("Пропуск обновления DateModified (ManualControl) для {EntityType}", entityType.Name);
}
}
}
@@ -166,7 +156,7 @@ namespace PARR.DAL.Repositories.Base
/// <param name="obj"></param>
private void TableHistoryResolver(EntityEntry obj)
{
logger.LogDebug("Проверяю необходимость создания истории для сущности типа {EntityType}", obj.Entity.GetType().Name);
_logger.LogDebug("Проверяю необходимость создания истории для сущности типа {EntityType}", obj.Entity.GetType().Name);
var myHistoryInterface = obj.Entity.GetType().GetInterfaces()
.Where(t => t.IsGenericType)
@@ -176,7 +166,7 @@ namespace PARR.DAL.Repositories.Base
// у этого объекта нет интерфейса IMyHistory<>. Не ведем историю
if (myHistoryInterface == null)
{
logger.LogDebug("Сущность типа {EntityType} не требует ведения истории", obj.Entity.GetType().Name);
_logger.LogDebug("Сущность типа {EntityType} не требует ведения истории", obj.Entity.GetType().Name);
return;
}
@@ -186,13 +176,13 @@ namespace PARR.DAL.Repositories.Base
var historyType = myHistoryInterface.GetGenericArguments().First();
var historyProps = historyType.GetProperties(/*BindingFlags.DeclaredOnly | */ /*BindingFlags.Public*/).ToList();
logger.LogDebug("Создаю историю для сущности типа {EntityType}, тип истории: {HistoryType}",
_logger.LogDebug("Создаю историю для сущности типа {EntityType}, тип истории: {HistoryType}",
obj.Entity.GetType().Name, historyType.Name);
var historyInstance = Activator.CreateInstance(historyType);
if (historyInstance == null)
{
logger.LogError("Не смог создать инстанс для ведения истории {HistoryType}", historyType.Name);
_logger.LogError("Не смог создать инстанс для ведения истории {HistoryType}", historyType.Name);
return;
}
@@ -208,11 +198,11 @@ namespace PARR.DAL.Repositories.Base
try
{
EntityContext.Add(historyInstance);
logger.LogDebug("История добавлена для сущности типа {EntityType}", obj.Entity.GetType().Name);
_logger.LogDebug("История добавлена для сущности типа {EntityType}", obj.Entity.GetType().Name);
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при добавлении объекта в историю {HistoryType}", historyType.Name);
_logger.LogError(ex, "Ошибка при добавлении объекта в историю {HistoryType}", historyType.Name);
}
}
@@ -225,7 +215,7 @@ namespace PARR.DAL.Repositories.Base
/// <param name="propsList"></param>
private void FillHistoryProps(EntityEntry originalObj, ref object historyInstance, List<PropertyInfo> propsList)
{
logger.LogDebug("Заполняю историю для сущности типа {EntityType}", originalObj.Entity.GetType().Name);
_logger.LogDebug("Заполняю историю для сущности типа {EntityType}", originalObj.Entity.GetType().Name);
foreach (var prop in propsList)
{
@@ -250,7 +240,7 @@ namespace PARR.DAL.Repositories.Base
histProp.SetValue(historyInstance, origValues);
}
logger.LogDebug("Завершено заполнение истории для сущности типа {EntityType}", originalObj.Entity.GetType().Name);
_logger.LogDebug("Завершено заполнение истории для сущности типа {EntityType}", originalObj.Entity.GetType().Name);
}
@@ -266,14 +256,14 @@ namespace PARR.DAL.Repositories.Base
var histProp = instanceObj.GetType().GetProperty(propName);
if (histProp == null)
{
logger.LogError("При изменении объекта для БД, не найдено свойство {PropertyName}", propName);
_logger.LogError("При изменении объекта для БД, не найдено свойство {PropertyName}", propName);
return;
}
// сравним типы
if (histProp.PropertyType != typeof(TValue))
{
logger.LogError("При изменении объекта для БД, не совпадают типы у свойства {PropertyName}, {PropertyType}!={ValueType}",
_logger.LogError("При изменении объекта для БД, не совпадают типы у свойства {PropertyName}, {PropertyType}!={ValueType}",
propName, histProp.PropertyType.Name, typeof(TValue).Name);
return;
}
@@ -340,7 +330,7 @@ namespace PARR.DAL.Repositories.Base
public virtual async Task<bool> CreateAsync(T obj)
{
logger.LogDebug("Начинаю создание объекта типа {EntityType}", typeof(T).Name);
_logger.LogDebug("Начинаю создание объекта типа {EntityType}", typeof(T).Name);
if (obj.DateCreated == DateTimeOffset.MinValue)
obj.DateCreated = DateTimeOffset.UtcNow;
@@ -348,73 +338,73 @@ namespace PARR.DAL.Repositories.Base
try
{
await EntitySet.AddAsync(obj);
logger.LogDebug("Объект типа {EntityType} добавлен в контекст", typeof(T).Name);
_logger.LogDebug("Объект типа {EntityType} добавлен в контекст", typeof(T).Name);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при добавлении объекта типа {EntityType} в БД", typeof(T).Name);
_logger.LogError(ex, "Ошибка при добавлении объекта типа {EntityType} в БД", typeof(T).Name);
return false;
}
}
public virtual bool Delete(T obj)
{
logger.LogDebug("Начинаю удаление объекта типа {EntityType}", obj.GetType().Name);
_logger.LogDebug("Начинаю удаление объекта типа {EntityType}", obj.GetType().Name);
try
{
EntitySet.Remove(obj);
logger.LogDebug("Объект типа {EntityType} удален из контекста", obj.GetType().Name);
_logger.LogDebug("Объект типа {EntityType} удален из контекста", obj.GetType().Name);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при удалении объекта типа {EntityType} из БД", obj.GetType().Name);
_logger.LogError(ex, "Ошибка при удалении объекта типа {EntityType} из БД", obj.GetType().Name);
return false;
}
}
public virtual async Task<bool> DeleteAsync(Guid id)
{
logger.LogDebug("Начинаю удаление объекта типа {EntityType} по ID: {Id}", typeof(T).Name, id);
_logger.LogDebug("Начинаю удаление объекта типа {EntityType} по ID: {Id}", typeof(T).Name, id);
try
{
var exist = await GetAsync(id);
if (exist == null)
{
logger.LogError("Ошибка при удалении из БД. Не найдена запись в БД типа {EntityType} с id: {Id}",
_logger.LogError("Ошибка при удалении из БД. Не найдена запись в БД типа {EntityType} с id: {Id}",
typeof(T).Name, id);
return false;
}
EntitySet.Remove(exist);
logger.LogDebug("Объект типа {EntityType} с ID {Id} удален из контекста", typeof(T).Name, id);
_logger.LogDebug("Объект типа {EntityType} с ID {Id} удален из контекста", typeof(T).Name, id);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при удалении объекта типа {EntityType} из БД по ID: {Id}", typeof(T).Name, id);
_logger.LogError(ex, "Ошибка при удалении объекта типа {EntityType} из БД по ID: {Id}", typeof(T).Name, id);
return false;
}
}
public virtual IQueryable<T> Get()
{
logger.LogDebug("Получаю набор объектов типа {EntityType}", typeof(T).Name);
_logger.LogDebug("Получаю набор объектов типа {EntityType}", typeof(T).Name);
return EntitySet;
}
public virtual async Task<T?> GetAsync(Guid id)
{
logger.LogDebug("Получаю объект типа {EntityType} по ID: {Id}", typeof(T).Name, id);
_logger.LogDebug("Получаю объект типа {EntityType} по ID: {Id}", typeof(T).Name, id);
return await EntitySet.FirstOrDefaultAsync(t => t.Id == id);
}
public virtual IQueryable<T> GetPage(IQueryable<T> query, PaginationFilter paginationFilter)
{
logger.LogDebug("Получаю страницу объектов типа {EntityType}, страница: {PageNumber}, размер: {PageSize}",
_logger.LogDebug("Получаю страницу объектов типа {EntityType}, страница: {PageNumber}, размер: {PageSize}",
typeof(T).Name, paginationFilter.PageNumber, paginationFilter.PageSize);
int skip = (paginationFilter.PageNumber - 1) * paginationFilter.PageSize;

View File

@@ -1,9 +1,11 @@
using Microsoft.EntityFrameworkCore;
using InfluxDB.Client.Api.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.DAL.Context;
using PARR.DAL.Repositories.Base;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums;
using PARR.Domain.Settings;
@@ -52,7 +54,7 @@ namespace PARR.DAL.Repositories
? ((TaskStatusEnum)taskStatusValue).ToString()
: $"Unknown ({taskStatusValue})";
logger.LogInformation("Нельзя установить статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}, так как текущий статус {currentStatus}",
_logger.LogInformation("Нельзя установить статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}, так как текущий статус {currentStatus}",
updatingStatus, configuration.Id, configuration.TemplateId, taskStatusName);
return false;
}
@@ -63,13 +65,13 @@ namespace PARR.DAL.Repositories
// есть ли связь у config с templetes, может инклуда нет, мало ли
if (configuration.Template == null)
{
logger.LogWarning("При изменении статуса задания на обновление шаблона, не смог проверить наличае ScheduleEsppId, так как нет Include с Templates. Пропустил эту проверку. configurationId: {configurationId}", configuration.Id);
_logger.LogWarning("При изменении статуса задания на обновление шаблона, не смог проверить наличае ScheduleEsppId, так как нет Include с Templates. Пропустил эту проверку. configurationId: {configurationId}", configuration.Id);
}
else
{
if (configuration.Template.ScheduleEsppId == null)
{
logger.LogInformation("Нельзя установить статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}, так как у шаблона отсутсвтует ScheduleEsppId=null",
_logger.LogInformation("Нельзя установить статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}, так как у шаблона отсутсвтует ScheduleEsppId=null",
updatingStatus, configuration.Id, configuration.TemplateId);
return false;
}
@@ -78,7 +80,7 @@ namespace PARR.DAL.Repositories
// Статус ОК, можно ставить Updating
ChangeTaskStatus(updatingStatus, configuration);
logger.LogInformation("Установлен статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}", updatingStatus, configuration.Id, configuration.TemplateId);
_logger.LogInformation("Установлен статус {newStatus} для конфигурации {configurationId}, templateId: {templateId}", updatingStatus, configuration.Id, configuration.TemplateId);
return true;
}
@@ -94,8 +96,8 @@ namespace PARR.DAL.Repositories
configuration.AttemptsNumber++;
configuration.LastRobotStatusUpdated = DateTimeOffset.UtcNow;
break;
//case RobotStatusEnum.Error:
// break;
case RobotStatusEnum.Error:
break;
case RobotStatusEnum.Complete:
configuration.LastRobotStatusUpdated = DateTimeOffset.UtcNow;
break;
@@ -108,6 +110,14 @@ namespace PARR.DAL.Repositories
}
}
public void SetErrorRobotStatusAndMaxAttempts(RobotConfiguration configuration)
{
ChangeRobotStatus(RobotStatusEnum.Error, configuration);
configuration.AttemptsNumber = settingsFromDb.RobotAttemptsNumber;
configuration.LastRobotStatusUpdated = DateTimeOffset.UtcNow;
}
public async Task<bool> SetInProgressStatusAsync(Guid id)
{
@@ -144,7 +154,7 @@ namespace PARR.DAL.Repositories
if (config == null)
{
logger.LogError($"У шаблона нет конфигурации роботов. TemplateId: {template.Id}");
_logger.LogError($"У шаблона нет конфигурации роботов. TemplateId: {template.Id}");
throw new Exception($"У шаблона нет конфигурации роботов. TemplateId: {template.Id}");
}
@@ -160,25 +170,82 @@ namespace PARR.DAL.Repositories
var endDate = DateTimeOffset.UtcNow.Add(-robotWaitTime);
var configObjs = await EntitySet.Where(t =>
var expiredConfigs = await EntitySet.Where(t =>
t.RobotStatusCode == (int)RobotStatusEnum.InProgress
&& t.AttemptsNumber >= robotAttemptsNumber
&& t.LastRobotStatusUpdated <= endDate
).ToListAsync();
if (!configObjs.Any())
if (!expiredConfigs.Any())
return;
configObjs.ForEach(item =>
foreach (var item in expiredConfigs)
{
ChangeRobotStatus(RobotStatusEnum.Error, item);
logger.LogInformation($"Устанавливаю RobotStatus: {RobotStatusEnum.Error} для RobotConfigurationId {item.Id}");
});
_logger.LogInformation("Устанавливаю статус RobotStatus: {RobotStatus} для RobotConfigurationId: {RobotConfigurationId}", RobotStatusEnum.Error, item.Id);
}
#region Ищем, есть ли связанные шаблоны, которые должны переименоваться, им тоже нужно установить статус ошибки, но только для Шаблонов
// Проактивная обработка связанных шаблонов переименования Old->New
// Если старый шаблон умен, мы должны сразу убить (!!!замочить!!!) и новый (целевой), чтобы он не висел вечно в ожидании.
var expiredTemplateIds = expiredConfigs
.Where(t => t.RobotCode == (int)RobotsEnum.TemplateOrder)
.Select(t => t.TemplateId)
.Distinct()
.ToList();
if (expiredTemplateIds.Any())
{
// Находим OldName для этих шаблонов из таблицы переименований.
var oldNamesToFail = await EntityContext.Templates
.Where(t => expiredTemplateIds.Contains(t.Id) && t.TemplateRenamePending != null)
.Select(t => t.TemplateRenamePending!.OldName)
.Distinct()
.ToListAsync();
// Находим целевые (новые задачи), имена которых совпадают с найденными OldName
if (oldNamesToFail.Any())
{
var targetConfigs = await EntitySet
.Where(t =>
t.RobotCode == (int)RobotsEnum.TemplateOrder
&& t.RobotStatusCode != (int)RobotStatusEnum.Error // Не трогаем те, что уже в ошибке
&& oldNamesToFail.Contains(t.Template!.Name)
).ToListAsync();
foreach (var item in targetConfigs)
{
SetErrorRobotStatusAndMaxAttempts(item);
// Пишем в лог роботу
var history = new RobotHistory
{
Id = Guid.NewGuid(),
HistoryLevel = (int)RobotStatusEnum.Error,
TaskStatusCode = item.TaskStatusCode,
RobotConfigurationId = item.Id,
RobotIp = null,
RobotId = ParrComponentsEnum.Api.ToString(),
RobotMessage = "[RobotConfigurationRepository] Установлен статус ошибки, так как не переименован связанный шаблон"
};
// Синхронный Add работает быстрее и безопаснее внутри цикла
EntityContext.RobotHistories.Add(history);
_logger.LogInformation("Проактивно установлен статус {Status} для целевого задания RobotConfigurationID: {Id} из-за ошибки старого шаблона.", RobotStatusEnum.Error, item.Id);
}
}
}
#endregion
var result = await CommitAsync();
if (!result)
logger.LogError($"Ошибка при сохранении изменений RobotStatus для RobotConfigurationId: item.Id, RobotStatus: {RobotStatusEnum.Error}");
_logger.LogError("Ошибка при сохранении изменений RobotStatus для просроченных заданий. Откат транзакции.");
//else
// logger.LogInformation("Успешно обработано и переведено в статус Ошибки просроченных заданий: {Count} шт.", configObjs.Count + linksCount);
}

View File

@@ -1,6 +1,6 @@
using PARR.Core.Repositories.Interfaces;
using PARR.DAL.Context;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.DAL.Repositories
{

View File

@@ -2,7 +2,7 @@
using PARR.Core.Repositories.Interfaces;
using PARR.DAL.Context;
using PARR.DAL.Repositories.Base;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.DAL.Repositories
{

View File

@@ -1,6 +1,6 @@
using PARR.Core.Repositories.Interfaces;
using PARR.DAL.Context;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.DAL.Repositories
{

View File

@@ -1,6 +1,6 @@
using PARR.Core.Repositories.Interfaces;
using PARR.DAL.Context;
using PARR.Domain.Entities;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.DAL.Repositories
{

View File

@@ -12,7 +12,7 @@ namespace PARR.DAL.Repositories
this.dataContext = dataContext;
}
public IQueryable<Domain.Entities.TaskStatus> Get()
public IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get()
{
return dataContext.TaskStatuses;
}

View File

@@ -12,7 +12,7 @@ namespace PARR.DAL.Repositories
this.dataContext = dataContext;
}
public IQueryable<Domain.Entities.TaskStatus> Get()
public IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get()
{
return dataContext.TaskStatuses;
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.DAL.Context;
using PARR.Domain.Entities.TemplateEntities;
namespace PARR.DAL.Repositories.TemplateRepositories
{
internal class TemplateRenamePendingRepository : ITemplateRenamePendingRepository
{
private readonly DataContext _dataContext;
private readonly ILogger<TemplateRenamePendingRepository> _logger;
public TemplateRenamePendingRepository(
DataContext dataContext,
ILogger<TemplateRenamePendingRepository> logger
)
{
_dataContext = dataContext;
_logger = logger;
}
public IQueryable<TemplateRenamePending> Get()
{
return _dataContext.TemplateRenamePendings;
}
public void Remove(TemplateRenamePending obj)
{
_dataContext.TemplateRenamePendings.Remove(obj);
}
public async Task<bool> CreateAsync(TemplateRenamePending obj)
{
try
{
await _dataContext.TemplateRenamePendings.AddAsync(obj);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при добавлении объекта типа TemplateRenamePending в БД");
return false;
}
}
}
}

View File

@@ -16,7 +16,7 @@ namespace PARR.DAL.Repositories
public async Task<Template?> GetTemplateByNameAsync(string name)
{
logger.LogDebug("Поиск шаблона по имени: {TemplateName}", name);
_logger.LogDebug("Поиск шаблона по имени: {TemplateName}", name);
var template = await GetWithIncludes()
.Include(t => t.RobotConfigurations)
@@ -24,11 +24,11 @@ namespace PARR.DAL.Repositories
if (template != null)
{
logger.LogDebug("Шаблон найден: {TemplateId}, имя: {TemplateName}", template.Id, template.Name);
_logger.LogDebug("Шаблон найден: {TemplateId}, имя: {TemplateName}", template.Id, template.Name);
}
else
{
logger.LogDebug("Шаблон с именем {TemplateName} не найден", name);
_logger.LogDebug("Шаблон с именем {TemplateName} не найден", name);
}
return template;
@@ -36,7 +36,7 @@ namespace PARR.DAL.Repositories
public IQueryable<Template> GetWithIncludes()
{
logger.LogDebug("Получаю шаблоны с include связями");
_logger.LogDebug("Получаю шаблоны с include связями");
return Get()
.Include(h => h.Unit)
@@ -60,7 +60,7 @@ namespace PARR.DAL.Repositories
public override Task<bool> CreateAsync(Template obj)
{
logger.LogDebug("Создание шаблона: {TemplateName}", obj.Name);
_logger.LogDebug("Создание шаблона: {TemplateName}", obj.Name);
// добавление роботов для шаблона
obj.RobotConfigurations = new List<RobotConfiguration>
@@ -89,9 +89,10 @@ namespace PARR.DAL.Repositories
AttemptsNumber = 0,
LastRobotStatusUpdated = null
}
};
logger.LogDebug("Добавлены роботы для шаблона {TemplateName}", obj.Name);
_logger.LogDebug("Добавлены роботы для шаблона {TemplateName}", obj.Name);
return base.CreateAsync(obj);
}
@@ -99,83 +100,85 @@ namespace PARR.DAL.Repositories
public async Task<Guid?> ReserveUnusedTemplateAsync(Guid newUnitId, HistoryInitiator initiator)
{
logger.LogDebug("Резервирую неиспользуемый шаблон с проверкой конфигураций роботов для UnitId: {UnitId}", newUnitId);
_logger.LogDebug("Резервирую неиспользуемый шаблон для UnitId: {UnitId}", newUnitId);
var sql = @"
UPDATE ""Templates""
SET ""StatusTypeId"" = @NewStatus,
""DateModified"" = @DateModified,
""InitiatorIp"" = @InitiatorIp,
""InitiatorParrComponentId"" = @InitiatorComponent,
""InitiatorComment"" = @InitiatorComment
WHERE ""Id"" = (
SELECT t.""Id""
FROM ""Templates"" t
WHERE t.""StatusTypeId"" = @OldStatus
AND t.""UnitId"" != @NewUnitId
AND EXISTS (
SELECT 1
FROM ""RobotConfigurations"" rc
WHERE rc.""TemplateId"" = t.""Id""
AND rc.""RobotCode"" = @RobotCode1
AND rc.""TaskStatusCode"" = @TaskStatus
AND rc.""RobotStatusCode"" = @RobotStatus
)
AND EXISTS (
SELECT 1
FROM ""RobotConfigurations"" rc
WHERE rc.""TemplateId"" = t.""Id""
AND rc.""RobotCode"" = @RobotCode2
AND rc.""TaskStatusCode"" = @TaskStatus
AND rc.""RobotStatusCode"" = @RobotStatus
)
ORDER BY t.""DateCreated"" ASC
LIMIT 1
)
RETURNING ""Id"";";
var parameters = new[]
{
new NpgsqlParameter("@NewStatus", (int)TemplateStatusTypeEnum.Updating),
new NpgsqlParameter("@DateModified", DateTimeOffset.UtcNow),
new NpgsqlParameter("@InitiatorIp", initiator.InitiatorIp ?? (object)DBNull.Value),
new NpgsqlParameter("@InitiatorComponent",
initiator.InitiatorParrComponentId.HasValue
? (object)(int)initiator.InitiatorParrComponentId.Value
: DBNull.Value),
new NpgsqlParameter("@InitiatorComment", initiator.InitiatorComment ?? (object)DBNull.Value),
new NpgsqlParameter("@OldStatus", (int)TemplateStatusTypeEnum.Unused),
new NpgsqlParameter("@NewUnitId", newUnitId),
// Параметры для проверки конфигураций роботов
new NpgsqlParameter("@RobotCode1", (int)RobotsEnum.TemplateOrder),
new NpgsqlParameter("@RobotCode2", (int)RobotsEnum.ScheduleOrder),
new NpgsqlParameter("@TaskStatus", (int)TaskStatusEnum.Ok),
new NpgsqlParameter("@RobotStatus", (int)RobotStatusEnum.Complete)
};
// Явная транзакция гарантирует атомарность UPDATE + подзапроса
await using var transaction = await EntityContext.Database.BeginTransactionAsync();
try
{
var sql = @"
UPDATE ""Templates""
SET ""StatusTypeId"" = @NewStatus,
""DateModified"" = @DateModified,
""InitiatorIp"" = @InitiatorIp,
""InitiatorParrComponentId"" = @InitiatorComponent,
""InitiatorComment"" = @InitiatorComment
WHERE ""Id"" = (
SELECT t.""Id""
FROM ""Templates"" t
WHERE t.""StatusTypeId"" = @OldStatus
AND t.""UnitId"" != @NewUnitId
AND EXISTS (
SELECT 1 FROM ""RobotConfigurations"" rc
WHERE rc.""TemplateId"" = t.""Id""
AND rc.""RobotCode"" = @RobotCode1
AND rc.""TaskStatusCode"" = @TaskStatus
AND rc.""RobotStatusCode"" = @RobotStatus
)
AND EXISTS (
SELECT 1 FROM ""RobotConfigurations"" rc
WHERE rc.""TemplateId"" = t.""Id""
AND rc.""RobotCode"" = @RobotCode2
AND rc.""TaskStatusCode"" = @TaskStatus
AND rc.""RobotStatusCode"" = @RobotStatus
)
ORDER BY t.""DateModified"" ASC NULLS FIRST
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING ""Id"";";
var parameters = new[]
{
new NpgsqlParameter("@NewStatus", (int)TemplateStatusTypeEnum.Updating),
new NpgsqlParameter("@DateModified", DateTimeOffset.UtcNow),
new NpgsqlParameter("@InitiatorIp", initiator.InitiatorIp ?? (object)DBNull.Value),
new NpgsqlParameter("@InitiatorComponent",
initiator.InitiatorParrComponentId.HasValue
? (object)(int)initiator.InitiatorParrComponentId.Value
: DBNull.Value),
new NpgsqlParameter("@InitiatorComment", initiator.InitiatorComment ?? (object)DBNull.Value),
new NpgsqlParameter("@OldStatus", (int)TemplateStatusTypeEnum.Unused),
new NpgsqlParameter("@NewUnitId", newUnitId),
new NpgsqlParameter("@RobotCode1", (int)RobotsEnum.TemplateOrder),
new NpgsqlParameter("@RobotCode2", (int)RobotsEnum.ScheduleOrder),
new NpgsqlParameter("@TaskStatus", (int)TaskStatusEnum.Ok),
new NpgsqlParameter("@RobotStatus", (int)RobotStatusEnum.Complete)
};
var result = await EntityContext.Database
.SqlQueryRaw<Guid>(sql, parameters)
.ToListAsync();
await transaction.CommitAsync();
var reservedTemplateId = result.FirstOrDefault();
if (reservedTemplateId != Guid.Empty)
{
logger.LogInformation("Успешно зарезервирован шаблон с ID: {TemplateId} для UnitId: {UnitId}",
_logger.LogInformation("Успешно зарезервирован шаблон с ID: {TemplateId} для UnitId: {UnitId}",
reservedTemplateId, newUnitId);
return reservedTemplateId;
}
else
{
logger.LogDebug("Не удалось зарезервировать шаблон для UnitId: {UnitId} (не найдено подходящих конфигураций роботов)", newUnitId);
return null;
}
_logger.LogDebug("Не удалось зарезервировать шаблон для UnitId: {UnitId}", newUnitId);
return null;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка при резервировании шаблона для UnitId: {UnitId}", newUnitId);
await transaction.RollbackAsync();
_logger.LogError(ex, "Ошибка при резервировании шаблона для UnitId: {UnitId}", newUnitId);
throw;
}
}

View File

@@ -11,16 +11,18 @@ namespace PARR.DAL.Repositories.Unit
{
public UnitFieldValueRepository(DataContext dataContext, ILogger<UnitFieldValueRepository> logger) : base(logger, dataContext) { }
public async Task<UnitFieldValue?> GetByValueNameAsync(string? value)
public async Task<List<Guid>> FindValueIdsByMaskAsync(
string mask,
CancellationToken ct = default)
{
var query = EntitySet
.Include(v => v.FieldValues);
var query = EntitySet.AsNoTracking();
if (string.IsNullOrWhiteSpace(value))
return await query.FirstOrDefaultAsync(uf => uf.Value == null);
var valueIds = await query
.Where(v => EF.Functions.ILike(v.Value!, mask))
.Select(v => v.Id)
.ToListAsync(ct);
return await query.FirstOrDefaultAsync(uf => uf.Value!.ToLower().Trim() == value.ToLower().Trim());
return valueIds;
}
}
}

View File

@@ -27,38 +27,14 @@ namespace PARR.DAL.Repositories.Unit
}
public Task<List<UnitInUnit>> GetByParentIdAsync(Guid parentId)
public async Task<List<Guid>> GetRelatedUnitIdsAsync(Guid unitId, CancellationToken ct = default)
{
return dataContext.UnitInUnits
.Where(u => u.ParentUnitId == parentId)
.ToListAsync();
}
public Task<List<UnitInUnit>> GetByChildIdAsync(Guid childId)
{
return dataContext.UnitInUnits
.Where(u => u.ChildUnitId == childId)
.ToListAsync();
}
public async Task<List<UnitInUnit>> GetParentLinksByChildIdsAsync(IEnumerable<Guid> childUnitIds)
{
var set = childUnitIds.ToHashSet();
return await dataContext.UnitInUnits
return await Get()
.AsNoTracking()
.Where(uinu => set.Contains(uinu.ChildUnitId))
.ToListAsync();
}
public async Task<List<UnitInUnit>> GetChildLinksByParentIdsAsync(IEnumerable<Guid> parentUnitIds)
{
var set = parentUnitIds.ToHashSet();
return await dataContext.UnitInUnits
.AsNoTracking()
.Where(uinu => set.Contains(uinu.ParentUnitId))
.ToListAsync();
.Where(link => link.ChildUnitId == unitId || link.ParentUnitId == unitId)
.Select(link => link.ChildUnitId == unitId ? link.ParentUnitId : link.ChildUnitId)
.Distinct()
.ToListAsync(ct);
}
}
}

View File

@@ -11,7 +11,6 @@ namespace PARR.DAL.Repositories.Unit
{
public UnitRepository(DataContext dataContext, ILogger<UnitRepository> logger) : base(logger, dataContext) { }
public IQueryable<Domain.Entities.Unit.Unit> GetWithIncludes()
{
return Get()
@@ -22,40 +21,30 @@ namespace PARR.DAL.Repositories.Unit
}
public IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false)
{
//TODO: вынесено из UnitFilterService
if (isInverse)
{
query = query.Where(u => !u.UnitValues.Any(v =>
v.FieldId == fieldId &&
EF.Functions.ILike(v.Value.Value, valueMask)));
}
else
{
query = query.Where(u => u.UnitValues.Any(v =>
v.FieldId == fieldId &&
EF.Functions.ILike(v.Value.Value, valueMask)));
}
return query;
}
public IQueryable<Guid> GetInitialUnitIds(string dbValueMask)
{
//TODO: вынесено из UnitFilterService
//var initialUnitIds = await unitService.Get().AsNoTracking()
//.Where(unit => EF.Functions.ILike(unit.Name, dbValueMask))
//.Select(u => u.Id)
//.ToListAsync(cancellationToken);
return Get().AsNoTracking()
.Where(unit => EF.Functions.ILike(unit.Name, dbValueMask))
.Select(u => u.Id);
}
public async Task<List<Guid>> FindUnitIdsByValueIdsAsync(
IReadOnlyList<Guid> unitIds,
Guid fieldId,
IReadOnlyList<Guid> valueIds,
CancellationToken ct = default)
{
if (valueIds.Count == 0)
return new List<Guid>();
return await EntitySet.AsNoTracking()
.Where(u => unitIds.Contains(u.Id))
.Where(u => u.UnitValues.Any(uv =>
uv.FieldId == fieldId &&
valueIds.Contains(uv.ValueId)))
.Select(u => u.Id)
.ToListAsync(ct);
}
}
}

View File

@@ -84,7 +84,7 @@ namespace PARR.DAL.Repositories
var exist = await GetAsync(id);
if (exist == null)
{
logger.LogError($"Ошибка при удалении из БД. Не найдена запись в БД с id: {id}");
_logger.LogError($"Ошибка при удалении из БД. Не найдена запись в БД с id: {id}");
return false;
}

View File

@@ -32,10 +32,19 @@
/// </summary>
public const string Task = "task";
/// <summary>
/// Робот
/// </summary>
public const string Robot = "robot";
/// <summary>
/// Шаблоны
/// </summary>
public const string Template = "template";
/// <summary>
/// Схема public
/// </summary>
public const string Public = "public";
}
}

View File

@@ -0,0 +1,18 @@
using PARR.Domain.DTOs.Shared;
namespace PARR.Domain.DTOs.RobotStatusDetails
{
public record RobotStatusDetailsResult
{
public RobotResult Robot { get; init; } = null!;
public RobotStatusResult Status { get; init; } = null!;
public List<RobotStatusGroupDetailsResult> Details { get; init; } = null!;
}
public record RobotStatusGroupDetailsResult
{
public JobGroupShortResult JobGroup { get; init; } = null!;
public int TemplatesCount { get; init; }
}
}

View File

@@ -0,0 +1,18 @@
using PARR.Domain.DTOs.Shared;
namespace PARR.Domain.DTOs.RobotTaskDetails
{
public record RobotTaskDetailsResult
{
public RobotResult Robot { get; init; } = null!;
public RobotTaskStatusResult Task { get; init; } = null!;
public List<RobotTaskGroupDetailsResult> Details { get; init; } = null!;
}
public record RobotTaskGroupDetailsResult
{
public JobGroupShortResult JobGroup { get; init; } = null!;
public int TemplatesCount { get; init; }
}
}

View File

@@ -0,0 +1,13 @@
using PARR.Domain.Enums;
namespace PARR.Domain.DTOs.RobotTaskRobotStatus
{
/// <summary>
/// Изменить статус задания робота
/// </summary>
/// <param name="TaskId"></param>
/// <param name="RobotStatusCode"></param>
/// <param name="RobotId">Идентификатор робота</param>
/// <param name="RobotIp">IP адрес робота</param>
public record ChangeRobotStatus(Guid TaskId, RobotStatusEnum RobotStatusCode, string? RobotId, string? RobotIp);
}

View File

@@ -0,0 +1,23 @@
using PARR.Domain.DTOs.Shared;
namespace PARR.Domain.DTOs.RobotTaskRobotStatus
{
public record RobotConfigurationResult
{
public Guid Id { get; init; }
public DateTimeOffset DateCreated { get; init; }
public Guid TemplateId { get; init; }
public RobotResult? Robot { get; init; }
public RobotTaskStatusResult? TaskStatus { get; init; }
public RobotStatusResult? RobotStatus { get; init; }
public int AttemptsNumber { get; init; }
public DateTimeOffset? LastRobotStatusUpdated { get; init; }
}
}

View File

@@ -0,0 +1,15 @@
namespace PARR.Domain.DTOs.Shared
{
public record JobGroupShortResult
{
public Guid Id { get; set; }
public required string GroupName { get; set; }
}
public record JobGroupResult : JobGroupShortResult
{
}
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Domain.DTOs.Shared
{
public record RobotResult
{
public int Code { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
}
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Domain.DTOs.Shared
{
public record RobotStatusResult
{
public int Code { get; init; }
public string? Name { get; init; }
public string? Description { get; init; }
}
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Domain.DTOs.Shared
{
public record RobotTaskStatusResult
{
public int Code { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PARR.Domain.Entities.Base;
using PARR.Domain.Entities.RobotEntities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -9,6 +10,8 @@ namespace PARR.Domain.Entities
[Index(nameof(TemplateId), nameof(RobotCode), IsUnique = true)]
[Index(nameof(TemplateId), nameof(TaskStatusCode))]
[Index(nameof(TemplateId), nameof(RobotStatusCode))]
[Index(nameof(RobotCode), nameof(TaskStatusCode))]
[Index(nameof(RobotCode), nameof(RobotStatusCode))]
public class RobotConfiguration : IBaseEntity
{
[Key]
@@ -53,7 +56,7 @@ namespace PARR.Domain.Entities
public Robot? Robot { get; set; }
[ForeignKey(nameof(TaskStatusCode))]
public TaskStatus? TaskStatus { get; set; }
public RobotEntities.TaskStatus? TaskStatus { get; set; }
[ForeignKey(nameof(RobotStatusCode))]
public RobotStatus? RobotStatus { get; set; }

View File

@@ -1,11 +1,11 @@
using Microsoft.EntityFrameworkCore;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Constants;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities
namespace PARR.Domain.Entities.RobotEntities
{
[Table("Robots")]
[Table("Robots", Schema = DatabaseSchemas.Robot)]
[Index(nameof(Name), IsUnique = true)]
public class Robot
{

View File

@@ -1,10 +1,12 @@
using Microsoft.EntityFrameworkCore;
using PARR.Domain.Constants;
using PARR.Domain.Entities.Base;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities
namespace PARR.Domain.Entities.RobotEntities
{
[Table("RobotHistories")]
//[Table("RobotHistories", Schema = DatabaseSchemas.Robot)]
[Table("Histories", Schema = DatabaseSchemas.Robot)]
[Index(nameof(HistoryLevel), nameof(DateCreated), IsDescending = new[] { false, true })]
[Index(nameof(RobotConfigurationId), nameof(DateCreated))]
[Index(nameof(DateCreated))]

View File

@@ -1,12 +1,14 @@
using System.ComponentModel.DataAnnotations;
using PARR.Domain.Constants;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities
namespace PARR.Domain.Entities.RobotEntities
{
/// <summary>
/// Уровень логов работы робота
/// </summary>
[Table("RobotHistoryLevels")]
//[Table("RobotHistoryLevels", Schema = DatabaseSchemas.Robot)]
[Table("HistoryLevels", Schema = DatabaseSchemas.Robot)]
public class RobotHistoryLevel
{
[Key]
@@ -18,8 +20,5 @@ namespace PARR.Domain.Entities
public ICollection<RobotHistory> RobotHistories { get; set; } = new HashSet<RobotHistory>();
}
}

View File

@@ -1,13 +1,13 @@
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Constants;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities
namespace PARR.Domain.Entities.RobotEntities
{
/// <summary>
/// Статус работы робота
/// </summary>
[Table("RobotStatuses")]
[Table("RobotStatuses", Schema = DatabaseSchemas.Robot)]
public class RobotStatus
{
[Key]

View File

@@ -1,13 +1,13 @@
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Constants;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities
namespace PARR.Domain.Entities.RobotEntities
{
/// <summary>
/// Статус шаблона. Что нужно сделать роботу в ЕСПП
/// </summary>
[Table("TaskStatuses")]
[Table("TaskStatuses", Schema = DatabaseSchemas.Robot)]
public class TaskStatus
{
[Key]

View File

@@ -3,6 +3,7 @@ using PARR.Domain.Entities.Base;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.Base.History.Base;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.TemplateEntities;
using PARR.Domain.Enums;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -94,5 +95,7 @@ namespace PARR.Domain.Entities
public ICollection<UnitsInTemplate> UnitsInTemplate { get; set; } = new HashSet<UnitsInTemplate>();
public TemplateStatusType? StatusType { get; set; }
public TemplateRenamePending? TemplateRenamePending { get; set; }
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using PARR.Domain.Constants;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities.TemplateEntities
{
/// <summary>
/// Шаблоны находящиеся в процессе переименования
/// </summary>
[Table("TemplateRenamePendings", Schema = DatabaseSchemas.Template)]
[Comment("Шаблоны находящиеся в процессе переименования")]
//[Index(nameof(OldName), IsUnique = true)]
[Index(nameof(OldName))]
public class TemplateRenamePending
{
[Key]
public Guid TemplateId { get; set; }
public DateTimeOffset DateCreated { get; set; }
public DateTimeOffset? DateModified { get; set; }
/// <summary>
/// Старое имя шаблона
/// </summary>
[Comment("Старое имя шаблона")]
public required string OldName { get; set; }
[ForeignKey(nameof(TemplateId))]
public Template? Template { get; set; }
}
}

View File

@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Services.Shortcodes;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
@@ -14,16 +15,16 @@ namespace PARR.EsppSync
{
internal class SyncService<EsppObject> : ISyncService<EsppObject> where EsppObject : class, IEsppObject
{
private readonly ILogger<SyncService<EsppObject>> logger;
private readonly IServiceProvider serviceProvider;
private readonly ILogger<SyncService<EsppObject>> _logger;
private readonly IServiceProvider _serviceProvider;
public SyncService(
ILogger<SyncService<EsppObject>> logger,
IServiceProvider serviceProvider
)
{
this.logger = logger;
this.serviceProvider = serviceProvider;
_logger = logger;
_serviceProvider = serviceProvider;
}
@@ -35,11 +36,11 @@ namespace PARR.EsppSync
AfterParseStringToEsppObjectAsync<EsppObject>? afterParseStringToEsppObjectAsync = null
)
{
logger.LogDebug("Получил строку. Начинаю работать. Строка: {String}", str);
_logger.LogDebug("Получил строку. Начинаю работать. Строка: {String}", str);
if (string.IsNullOrEmpty(str))
{
logger.LogWarning("Получил пустую строку, ничего не делаю.");
_logger.LogWarning("Получил пустую строку, ничего не делаю.");
return;
}
@@ -47,7 +48,7 @@ namespace PARR.EsppSync
if (esppObject == null)
{
logger.LogWarning("После парсинга строки, esppObject = null. Дальше ничего не буду делать.");
_logger.LogWarning("После парсинга строки, esppObject = null. Дальше ничего не буду делать.");
return;
}
@@ -56,47 +57,64 @@ namespace PARR.EsppSync
await afterParseStringToEsppObjectAsync.Invoke(esppObject);
using (var scope = serviceProvider.CreateScope())
using (var scope = _serviceProvider.CreateScope())
{
var templateService = GetServiceInScope<ITemplateRepository>(scope);
var robotConfigurationService = GetServiceInScope<IRobotConfigurationRepository>(scope);
var shortcodesService = GetServiceInScope<IShortcodesService>(scope);
var templateService = scope.ServiceProvider.GetRequiredService<ITemplateRepository>();
var robotConfigurationService = scope.ServiceProvider.GetRequiredService<IRobotConfigurationRepository>();
var shortcodesService = scope.ServiceProvider.GetRequiredService<IShortcodesService>();
var templateRenamePendingRepository = scope.ServiceProvider.GetRequiredService<ITemplateRenamePendingRepository>();
try
{
#region Проверка, идет ли переименование
// Если есть у этого шаблона запись в TemplateRenamePending, значит сейчас идет переименование, пропускаем синхронизацию этого шаблона
// Если имя этого шаблона находится в таблице TemplateRenamePending, значит идет переименование связанного шаблона, пропускаем синхронизацию этого шаблона
var isPartOfRename = await templateRenamePendingRepository.Get()
.AnyAsync(t => t.Template.Name == esppObject.TemplateName || t.OldName == esppObject.TemplateName);
if (isPartOfRename)
{
_logger.LogInformation("Пропускаем синхронизацию '{TemplateName}', так как шаблон ({EsppObject}) участвует в процессе переименования.", esppObject.TemplateName, nameof(EsppObject));
// Выходим сразу
return;
}
#endregion
// Загружаем Template и TemplateForShortcodes в одном запросе
var query = templateService.Get()
//.AsNoTracking()
.Include(h => h.Unit)
.ThenInclude(t => t!.UnitValues)
.ThenInclude(t => t.Value)
.Include(h => h.Unit)
.ThenInclude(t => t!.UnitValues)
.ThenInclude(t => t.Field)
.Include(t => t.RobotConfigurations)
.Include(t => t.Job)
.ThenInclude(j => j.Group)
.ThenInclude(g => g.GroupType)
.Include(t => t.Job)
.ThenInclude(t => t.Group)
.ThenInclude(t => t.ScheduleExcludeType)
.Include(t => t.Job)
.ThenInclude(t => t.Group)
.ThenInclude(t => t.ScheduleExcludeTypeCalendar)
.Include(t => t.Job)
.ThenInclude(j => j.Tnk)
.ThenInclude(s => s!.Subprocess)
.ThenInclude(p => p!.Process)
.Include(t => t.UnitsInTemplate);
.Include(h => h.Unit)
.ThenInclude(t => t!.UnitValues)
.ThenInclude(t => t.Value)
.Include(h => h.Unit)
.ThenInclude(t => t!.UnitValues)
.ThenInclude(t => t.Field)
.Include(t => t.RobotConfigurations)
.Include(t => t.Job)
.ThenInclude(j => j.Group)
.ThenInclude(g => g.GroupType)
.Include(t => t.Job)
.ThenInclude(t => t.Group)
.ThenInclude(t => t.ScheduleExcludeType)
.Include(t => t.Job)
.ThenInclude(t => t.Group)
.ThenInclude(t => t.ScheduleExcludeTypeCalendar)
.Include(t => t.Job)
.ThenInclude(j => j.Tnk)
.ThenInclude(s => s!.Subprocess)
.ThenInclude(p => p!.Process)
.Include(t => t.UnitsInTemplate);
var template = await query.FirstOrDefaultAsync(t => t.Name == esppObject.TemplateName);
if (template == null)
{
logger.LogWarning("Найден объект в ЕСПП с именем шаблона '{TemplateName}' незарегистрированный в ПАРР. Строка: {String}", esppObject.TemplateName, str);
_logger.LogWarning("Найден объект в ЕСПП с именем шаблона '{TemplateName}' незарегистрированный в ПАРР. Строка: {String}", esppObject.TemplateName, str);
return;
}
var dbObjectInEsppObject = converterDbToEsppObject.Invoke(template);
await ApplyShortcodesAsync(dbObjectInEsppObject, template, shortcodesService);
@@ -107,7 +125,7 @@ namespace PARR.EsppSync
if (dbObjectInEsppObject.IsActive == false)
{
logger.LogDebug("Объект деактивирован в ПАРР. Сравниваем только обязательные поля. {TemplateName}", esppObject.TemplateName);
_logger.LogDebug("Объект деактивирован в ПАРР. Сравниваем только обязательные поля. {TemplateName}", esppObject.TemplateName);
var lightDbObj = new EsppLightObject(dbObjectInEsppObject);
var lightEsppObject = new EsppLightObject(esppObject);
@@ -115,7 +133,7 @@ namespace PARR.EsppSync
}
else
{
logger.LogDebug("Объект активирован в ПАРР. Сравниваем все поля. {TemplateName}", esppObject.TemplateName);
_logger.LogDebug("Объект активирован в ПАРР. Сравниваем все поля. {TemplateName}", esppObject.TemplateName);
isChanged = IsChanged(esppObject, dbObjectInEsppObject, esppObject.TemplateName);
// выполняем кастомную дополнительную проверку (только если isChanged==false, чтоб лишний раз не гонять)
@@ -127,25 +145,25 @@ namespace PARR.EsppSync
var isCustomComparision = await customComparisionAsync.Invoke(esppObject, dbObjectInEsppObject, template.Id);
if (isCustomComparision)
{
logger.LogDebug("Дополнительная проверка прошла.");
_logger.LogDebug("Дополнительная проверка прошла.");
}
else
{
// если дополнительная проверка не прошла, то говорим что есть изменения
isChanged = true;
logger.LogDebug("Дополнительная проверка не прошла, ставим статус isChanged: {isChanged}", isChanged);
_logger.LogDebug("Дополнительная проверка не прошла, ставим статус isChanged: {isChanged}", isChanged);
}
}
else
{
logger.LogDebug("Дополнительная проверка отсутствует");
_logger.LogDebug("Дополнительная проверка отсутствует");
}
}
}
if (isChanged)
{
logger.LogDebug("Есть изменения, требуется обновление. {TemplateName}", esppObject.TemplateName);
_logger.LogDebug("Есть изменения, требуется обновление. {TemplateName}", esppObject.TemplateName);
var config = robotConfigurationService.GetFromTemplateByRobotCode(esppObject.Robot, template);
@@ -160,25 +178,25 @@ namespace PARR.EsppSync
if (isChangedStatus)
{
if (!await templateService.CommitAsync(GetInitiator()))
logger.LogError("Не удалось изменить запись Template {TemplateName}, Robot: {Robot}", template.Name, esppObject.Robot);
_logger.LogError("Не удалось изменить запись Template {TemplateName}, Robot: {Robot}", template.Name, esppObject.Robot);
else
logger.LogInformation("Установлен принудительный статус {TaskStatus}, Template {templateName}, Robot: {Robot}", TaskStatusEnum.Updating, template.Name, esppObject.Robot);
_logger.LogInformation("Установлен принудительный статус {TaskStatus}, Template {templateName}, Robot: {Robot}", TaskStatusEnum.Updating, template.Name, esppObject.Robot);
}
else
{
logger.LogInformation("Нельзя установить статус Updating для шаблона {templateName}, так как текущий статус это запрещает.", template.Name);
_logger.LogInformation("Нельзя установить статус Updating для шаблона {templateName}, так как текущий статус это запрещает.", template.Name);
}
}
else
{
// Если пред статус был Update, то ничего не делаем, так его и оставляем, не сбрасывам кол-во попыток и ошибок
logger.LogInformation("Есть изменения в Template {TemplateName}, но предыдущий статус TaskStatusCode: {TaskStatusCode}. Не меняем статус, будем разбираться вручную.", template.Name, (TaskStatusEnum)config.TaskStatusCode);
_logger.LogInformation("Есть изменения в Template {TemplateName}, но предыдущий статус TaskStatusCode: {TaskStatusCode}. Не меняем статус, будем разбираться вручную.", template.Name, (TaskStatusEnum)config.TaskStatusCode);
}
}//надо ли проверять если не изменился, но был статус Updating не понятно. Доверяем роботу пока, что после окончания работ он точно сообщит
else
{
logger.LogDebug("Нет изменений, обновление не требуется. {TemplateName}", esppObject.TemplateName);
_logger.LogDebug("Нет изменений, обновление не требуется. {TemplateName}", esppObject.TemplateName);
//если все поля совпали
//проверяем, какой был статус предыдущий статус в БД, если он был не Ок, то ставим ему ОК
@@ -187,15 +205,15 @@ namespace PARR.EsppSync
{
robotConfigurationService.ChangeTaskStatus(TaskStatusEnum.Ok, robotConfig);
if (!await templateService.CommitAsync(GetInitiator()))
logger.LogError("Не удалось изменить запись Template {TemplateName}, Robot: {Robot}", template.Name, esppObject.Robot);
_logger.LogError("Не удалось изменить запись Template {TemplateName}, Robot: {Robot}", template.Name, esppObject.Robot);
else
logger.LogInformation("Установлен принудительный статус {TaskStatus}, Template {TemplateName}, Robot: {Robot}", TaskStatusEnum.Ok, template.Name, esppObject.Robot);
_logger.LogInformation("Установлен принудительный статус {TaskStatus}, Template {TemplateName}, Robot: {Robot}", TaskStatusEnum.Ok, template.Name, esppObject.Robot);
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка синхронизации объекта АСУ ЕСПП {TemplateName}", esppObject.TemplateName);
_logger.LogError(ex, "Ошибка синхронизации объекта АСУ ЕСПП {TemplateName}, {EsppObject}", esppObject.TemplateName, nameof(EsppObject));
}
}
}
@@ -243,36 +261,6 @@ namespace PARR.EsppSync
}
///// <summary>
///// Установить статус - Обновить
///// </summary>
///// <param name="template"></param>
///// <param name="robotConfigurationService"></param>
///// <param name="robot"></param>
//private void SetUpdateStatus(ref Template template, IRobotConfigurationService robotConfigurationService, RobotsEnum robot)
//{
// var robotConfig = robotConfigurationService.GetFromTemplateByRobotCode(robot, template);
// robotConfigurationService.ChangeTaskStatus(TaskStatusEnum.Updating, robotConfig);
//}
/// <summary>
/// Получить сервис из scope
/// </summary>
/// <typeparam name="Service"></typeparam>
/// <param name="scope"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
private Service GetServiceInScope<Service>(IServiceScope scope)
{
var service = scope.ServiceProvider.GetService<Service>();
if (service == null)
throw new Exception($"Не найден сервис: {nameof(Service)}");
return service;
}
/// <summary>
/// Сравнение объектов
/// </summary>
@@ -282,31 +270,35 @@ namespace PARR.EsppSync
/// <returns></returns>
private bool IsChanged(object esppObj, object dbObj, string templateName)
{
foreach (var prop in dbObj.GetType().GetProperties())
var dbType = dbObj.GetType();
var esppType = esppObj.GetType();
foreach (var dbProp in dbType.GetProperties())
{
// Пропускаем свойства, помеченные атрибутом SkipComparison
if (Attribute.IsDefined(prop, typeof(SkipComparisonAttribute)))
if (Attribute.IsDefined(dbProp, typeof(SkipComparisonAttribute)))
{
logger.LogDebug("Пропущено сравнение поля {PropertyName} (помечено [SkipComparison]). Шаблон: {TemplateName}", prop.Name, templateName);
_logger.LogDebug("Пропущено сравнение поля {PropertyName} (помечено [SkipComparison]). Шаблон: {TemplateName}", dbProp.Name, templateName);
continue;
}
if (prop == null)
continue;
// Значение из объекта БД
//var dbValue = dbObj.GetType().GetProperty(prop.Name)?.GetValue(dbObj, null);
var dbValue = dbProp.GetValue(dbObj);
var dbValue = dbObj.GetType().GetProperty(prop.Name)?.GetValue(dbObj, null);
var esppValue = esppObj.GetType().GetProperty(prop.Name)?.GetValue(esppObj, null);
// Ищем аналогичное свойство в объекте из ЕСПП по имени
//var esppValue = esppObj.GetType().GetProperty(dbProp.Name)?.GetValue(esppObj, null);
var esppProp = esppType.GetProperty(dbProp.Name);
var esppValue = esppProp?.GetValue(esppObj);
if (dbValue == null || esppValue == null)
continue;
//Replace("\r","").Replace("\n","") - в подробном описании могут быть переносы строк, в Rabbit прилетает без переносов. Убираем переносы для стравнения
var dbValueStr = EsppSyncHelpers.Normalize(dbValue!.ToString());
var esppValueStr = EsppSyncHelpers.Normalize(esppValue!.ToString());
// Replace("\r","").Replace("\n","") - в подробном описании могут быть переносы строк, в Rabbit прилетает без переносов. Убираем переносы для стравнения
// Если значение null, хелпер Normalize вернет string.Empty, что предотвратит NRE и ложные срабатывания.
var dbValueStr = EsppSyncHelpers.Normalize(dbValue?.ToString());
var esppValueStr = EsppSyncHelpers.Normalize(esppValue?.ToString());
if (dbValueStr != esppValueStr)
{
logger.LogInformation("Не совпадают поля ({PropertyName}). dbValueStr: {DbValueStr}, esppValueStr: {EsppValueStr}. Имя шаблона: {TemplateName}", prop.Name, dbValueStr, esppValueStr, templateName);
_logger.LogInformation("Не совпадают поля ({PropertyName}). dbValueStr: {DbValueStr}, esppValueStr: {EsppValueStr}. Имя шаблона: {TemplateName}", dbProp.Name, dbValueStr, esppValueStr, templateName);
return true;
}

View File

@@ -3,10 +3,13 @@ using Microsoft.Extensions.Logging;
using PARR.Core.Extensions;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.NextRunServices;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Entities;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.TemplateEntities;
using PARR.Domain.Enums;
namespace PARR.TemplateUpdater.Services
@@ -20,6 +23,7 @@ namespace PARR.TemplateUpdater.Services
private readonly IRobotConfigurationRepository robotConfigurationService;
private readonly INextRunService nextRunService;
private readonly IUnitInValueRepository unitInValueService;
private readonly ITemplateRenamePendingRepository _templateRenamePendingRepository;
public TemplateUpdaterService(
ILogger<TemplateUpdaterService> logger,
@@ -28,7 +32,8 @@ namespace PARR.TemplateUpdater.Services
IUnitRepository unitService,
IRobotConfigurationRepository robotConfigurationService,
INextRunService nextRunService,
IUnitInValueRepository unitInValueService
IUnitInValueRepository unitInValueService,
ITemplateRenamePendingRepository templateRenamePendingRepository
)
{
this.logger = logger;
@@ -38,6 +43,7 @@ namespace PARR.TemplateUpdater.Services
this.robotConfigurationService = robotConfigurationService;
this.nextRunService = nextRunService;
this.unitInValueService = unitInValueService;
_templateRenamePendingRepository = templateRenamePendingRepository;
}
@@ -53,7 +59,8 @@ namespace PARR.TemplateUpdater.Services
var template = await templateService.Get()
.Include(t => t.RobotConfigurations)
.Include(t => t.UnitsInTemplate)
.AsSplitQuery()
//.AsSplitQuery()
.AsSingleQuery()
.FirstOrDefaultAsync(t => t.Id == query.TemplateId);
if (template == null)
{
@@ -64,9 +71,14 @@ namespace PARR.TemplateUpdater.Services
var templateIsChanged = false;
var scheduleIsChanged = false;
if (template.Name != query.Name.Trim())
var trimmedNewName = query.Name.Trim();
if (template.Name != trimmedNewName)
{
template.Name = query.Name.Trim();
var prepareOldNameResult = await PrepareOldTemplateNameAsync(template.Name, trimmedNewName, template);
if (!prepareOldNameResult)
return;
template.Name = trimmedNewName;
templateIsChanged = true;
scheduleIsChanged = true;
}
@@ -276,5 +288,98 @@ namespace PARR.TemplateUpdater.Services
return true;
}
/// <summary>
/// Добавление записи в таблицу ожидания переименования
/// </summary>
/// <param name="oldName"></param>
/// <param name="newName"></param>
/// <param name="template"></param>
/// <returns></returns>
private async Task<bool> PrepareOldTemplateNameAsync(string oldName, string newName, Template template)
{
var existRenamePending = await _templateRenamePendingRepository.Get()
.FirstOrDefaultAsync(t => t.TemplateId == template.Id);
if (existRenamePending != null)
{
logger.LogInformation(
"Найдено существующее задание на переименование шаблона {TemplateId}. " +
"Обновляю OldName с '{OriginalOldName}' на '{NewOldName}'.",
template.Id, existRenamePending.OldName, oldName);
// Подменяем имя шаблона
existRenamePending.OldName = oldName;
existRenamePending.DateModified = DateTimeOffset.UtcNow;
return true;
}
else
{
// Создаем запись
var pendingRename = new TemplateRenamePending
{
TemplateId = template.Id,
DateCreated = DateTimeOffset.UtcNow,
OldName = oldName
};
logger.LogInformation(
"Добавлен шаблон в таблицу ожидания переименования. TemplateId: {TemplateId}, OldName: '{OldName}', NewName: '{NewName}'.",
template.Id, oldName, newName);
var addResult = await _templateRenamePendingRepository.CreateAsync(pendingRename);
if (!addResult)
return false;
return true;
}
#region Old
//// Проверяем, не запущено ли уже переименование для этого шаблона
//var alreadyPending = await _templateRenamePendingRepository.Get()
// .AsNoTracking()
// .FirstOrDefaultAsync(t => t.TemplateId == template.Id);
//if (alreadyPending != null)
//{
// logger.LogError("При попытке переименования шаблона {TemplateId}, из '{OldName}' в '{NewName}', " +
// "произошла ошибка, этот шаблон уже находится в процессе переименования (старое имя {PendingName})", template.Id, oldName, newName, alreadyPending.OldName);
// return false;
//}
//// Уникально ли имя в таблице ожидания переименования
//var existPendingOldName = await _templateRenamePendingRepository.Get()
// .AsNoTracking()
// .FirstOrDefaultAsync(t => t.OldName == oldName);
//if (existPendingOldName != null)
//{
// logger.LogError("При добавлении старого имени в таблицу ожидания для шаблона {TemplateId} обнаружен конфликт: " +
// "имя '{ExistOldName}' уже зарезервировано другим процессом для шаблона {ExistTemplateId}",
// template.Id, existPendingOldName.OldName, existPendingOldName.TemplateId);
// return false;
//}
//// Все нормально, добавляем запись в таблицу
//var pendingRename = new TemplateRenamePending
//{
// TemplateId = template.Id,
// DateCreated = DateTimeOffset.UtcNow,
// OldName = oldName,
// Template = template
//};
//var addResult = await _templateRenamePendingRepository.CreateAsync(pendingRename);
//if (!addResult)
// return false;
//return true;
#endregion
}
}
}

View File

@@ -1,7 +1,8 @@
using Microsoft.EntityFrameworkCore;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.JobGroupRepositories;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Enums;
using PARR.EsppApi;
using PARR.EsppApi.Constants;
@@ -181,7 +182,7 @@ namespace PARR.Test
{
//Guid.Parse("cfb0d2dd-192a-4ed5-a29c-25f0abf61895"),//11010
//Guid.Parse("79976eaf-be7b-42a0-a4e3-a89d7f68a18e"),//17
Guid.Parse("6ff1de05-80c3-4b38-846b-0c793fd7fc8c")//318
Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a")//318
};
if (!jobIds.Any())
@@ -235,22 +236,33 @@ namespace PARR.Test
var templateMatcher = scope.ServiceProvider.GetRequiredService<ITemplateMatcher>();
var groupRepository = scope.ServiceProvider.GetRequiredService<IJobGroupRepository>();
var groups = await groupRepository.Get().AsNoTracking()
.Where(t => t.GroupType != null && t.GroupType.Code == JobGroupTypesEnum.Group)
.Select(t => t.Id).ToHashSetAsync();
await templateMatcher.SyncTemplatesForJob(Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a"),
new HistoryInitiator
{
InitiatorIp = "10.99.246.156",
InitiatorParrComponentId = ParrComponentsEnum.Master,
InitiatorComment = "Тестовый проект. Синхронизация Группированых работ"
foreach (var groupId in groups)
{
await templateMatcher.SyncTemplatesForJobGroup(groupId,
new HistoryInitiator
{
InitiatorIp = "10.99.246.156",
InitiatorParrComponentId = ParrComponentsEnum.Master,
InitiatorComment = "Тестовый проект. Синхронизация Группированых работ"
}
);
}
);
}
//var groups = await groupRepository.Get().AsNoTracking()
// .Where(t => t.GroupType != null && t.GroupType.Code == JobGroupTypesEnum.Group)
// .Select(t => t.Id).ToHashSetAsync();
//foreach (var groupId in groups)
//{
// await templateMatcher.SyncTemplatesForJobGroup(groupId,
// new HistoryInitiator
// {
// InitiatorIp = "10.99.246.156",
// InitiatorParrComponentId = ParrComponentsEnum.Master,
// InitiatorComment = "Тестовый проект. Синхронизация Группированых работ"
// }
// );
//}
return;
}

View File

@@ -5,7 +5,7 @@
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information"
}
},