Compare commits
58 Commits
min-relati
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7d041a8b | ||
|
|
236ddee5ef | ||
|
|
fdfd0f9363 | ||
|
|
393f043286 | ||
|
|
5c3b273346 | ||
|
|
0fcbe870b9 | ||
|
|
f9f234d119 | ||
|
|
394fc059ba | ||
|
|
1b02bf63bf | ||
|
|
09d8244b03 | ||
|
|
4e81ee72f4 | ||
|
|
04d9843cfb | ||
|
|
9f5bae3f32 | ||
|
|
a611a27c6e | ||
|
|
d6f4368917 | ||
|
|
e99ca22251 | ||
|
|
11eff75686 | ||
|
|
3818a470ce | ||
|
|
b475d1e10c | ||
|
|
f624b7f4a7 | ||
|
|
2a3afb9a5d | ||
|
|
f3a3a0cf8c | ||
|
|
2befb6b0c3 | ||
|
|
0629579cf0 | ||
|
|
83c6a5f605 | ||
|
|
e02a51f5ed | ||
|
|
fb20395e53 | ||
|
|
94bea5c46d | ||
|
|
349cf55862 | ||
|
|
4cd6134ad4 | ||
|
|
f10a50edae | ||
|
|
0ab5b49371 | ||
|
|
acdb6ec893 | ||
|
|
7d5fb23daf | ||
|
|
e36e08aa73 | ||
|
|
343468cf71 | ||
|
|
56cdca0502 | ||
|
|
8798529b4d | ||
|
|
556d895c7c | ||
|
|
58275d73f4 | ||
|
|
eacda75649 | ||
|
|
90e9f80505 | ||
|
|
b3879062a6 | ||
|
|
58b4d98b16 | ||
|
|
d1889460de | ||
|
|
5f384947a6 | ||
|
|
1cc2beb9ca | ||
|
|
0b0c0b04ad | ||
|
|
5094945c8e | ||
|
|
98245e73e6 | ||
|
|
cdcb4fd9bc | ||
|
|
bbb14ee4ea | ||
|
|
5edbcff35b | ||
|
|
6c93e1971f | ||
|
|
68696a2fdc | ||
|
|
751b693e72 | ||
|
|
6ca6bfbc2e | ||
|
|
178a991d8b |
@@ -196,7 +196,6 @@ namespace PARR.AIHITMainSyncer.Services
|
||||
}
|
||||
|
||||
// Поиск конфигурации поля для тега
|
||||
// Рекомендация: если метод вызывается в цикле, передавайте найденное поле внешним слоем
|
||||
var tagUnitField = fieldsFromDB.FirstOrDefault(f => f.Code == "tag");
|
||||
bool isTagProperty = tagUnitField != null && multiValueProperty.Key == tagUnitField.AihitName;
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ internal class RelationshipsSyncService : IRelationshipsSyncService
|
||||
List<(string ParentName, string ChildName)> sourcePairs)
|
||||
{
|
||||
var relevantParentNames = sourcePairs.Select(p => p.ParentName).Distinct().ToList();
|
||||
|
||||
|
||||
var existingForParents = await _unitRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Include(u => u.ChildUnits).ThenInclude(r => r.ChildUnit)
|
||||
@@ -157,19 +157,19 @@ internal class RelationshipsSyncService : IRelationshipsSyncService
|
||||
Relationship = relationship
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
var sourceSet = new HashSet<(string, string)>(sourcePairs);
|
||||
var existingSet = new HashSet<(string, string)>(
|
||||
existingForParents.Select(x => (x.ParentName, x.ChildName)));
|
||||
|
||||
|
||||
var toRemove = existingForParents
|
||||
.Where(r => !sourceSet.Contains((r.ParentName, r.ChildName)))
|
||||
.ToList();
|
||||
|
||||
|
||||
var toAdd = sourcePairs
|
||||
.Where(p => !existingSet.Contains(p))
|
||||
.ToList();
|
||||
|
||||
|
||||
_logger.LogInformation("Дельта: удаление {ToRemove}, добавление {ToAdd}", toRemove.Count, toAdd.Count);
|
||||
return (toRemove, toAdd);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace PARR.API.Contracts.V1
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
|
||||
|
||||
namespace PARR.API.Contracts.V1
|
||||
{
|
||||
// https://tproger.ru/translations/luchshie-praktiki-razrabotki-rest-api-20-sovetov/
|
||||
|
||||
@@ -210,11 +212,21 @@
|
||||
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/";
|
||||
@@ -224,6 +236,7 @@
|
||||
{
|
||||
public const string Get = BaseStat + "/templates/";
|
||||
public const string GetForPeriod = BaseStat + "/templates/period";
|
||||
public const string GetTemplatesWithoutScheduleAndTaskCount = BaseStat + "/templates/without-schedule";
|
||||
}
|
||||
|
||||
public static class StatStatusTypeTemplates
|
||||
@@ -324,6 +337,16 @@
|
||||
public const string GetWorkloadTemplateReport = BaseStat + "/workload/templates/{reportType}/{filter}/{state}/{date}";
|
||||
}
|
||||
|
||||
public static class StatRobotMetrics
|
||||
{
|
||||
public const string GetRobotStatusMetrics = BaseStat + "/robot-metrics/robot-status/{robotCode}/{period}";
|
||||
|
||||
public const string GetTaskStatusMetrics = BaseStat + "/robot-metrics/task-status/{robotCode}/{period}";
|
||||
|
||||
public const string GetFilteredMetrics = BaseStat + "/robot-metrics/filtered";
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Наряды
|
||||
@@ -592,6 +615,17 @@
|
||||
|
||||
#endregion
|
||||
|
||||
#region HelpDocument
|
||||
|
||||
public static class HelpDocument
|
||||
{
|
||||
public const string GetDocument = Base + "/help-documents/get-document";
|
||||
|
||||
public const string GitLabWebhook = Base + "/help-documents/modified";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace PARR.API.Contracts.V1.Requests
|
||||
{
|
||||
public record JobGroupRequest
|
||||
public record JobGroupRequest : UnitFiltersBaseRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
public Guid? ScheduleExcludeTypeCalendarId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Маска ЭК
|
||||
/// </summary>
|
||||
public required string EkMask { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Автораспределение включено
|
||||
/// </summary>
|
||||
public bool IsAutoDistributionEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
namespace PARR.API.Contracts.V1.Requests
|
||||
{
|
||||
public record JobRequest
|
||||
public record JobRequest : UnitFiltersBaseRequest
|
||||
{
|
||||
|
||||
public required Guid TnkId { get; init; }
|
||||
|
||||
public required Guid GroupId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
//public int? MinValueRelationships { get; init; }
|
||||
|
||||
//public int? MaxValueRelationships { get; init; }
|
||||
|
||||
//public bool? IsParentRelationships { get; init; }
|
||||
|
||||
public JobRelationships? Relationships { get; init; }
|
||||
|
||||
public required string TemplateNameMask { get; init; }
|
||||
@@ -25,19 +18,9 @@
|
||||
|
||||
public required string ResponseAreaMask { get; init; }
|
||||
|
||||
//#region AutoControl
|
||||
|
||||
//public bool IsEnableAutoControl { get; set; }
|
||||
|
||||
//public bool InitUsedTemplateState { get; set; }
|
||||
|
||||
//public bool InitUsedScheduleState { get; set; }
|
||||
|
||||
//#endregion
|
||||
|
||||
public JobAutoControlRequest? AutoControl { get; init; }
|
||||
|
||||
public required List<UnitFilterRequest> UnitFilters { get; init; }
|
||||
//public required List<UnitFilterRequest> UnitFilters { get; init; }
|
||||
}
|
||||
|
||||
public record JobRelationships
|
||||
@@ -49,43 +32,38 @@
|
||||
public bool IsParentRelationships { get; init; }
|
||||
}
|
||||
|
||||
public record UnitFilterRequest
|
||||
{
|
||||
///// <summary>
|
||||
///// Id = null в методе Create, в Update обязателен
|
||||
///// </summary>
|
||||
//public Guid? Id { get; set; }
|
||||
//public record UnitFilterRequest
|
||||
//{
|
||||
// public required string UnitFilterMask { get; init; }
|
||||
|
||||
public required string UnitFilterMask { get; init; }
|
||||
// public List<FieldFilterRequest>? FieldFilters { get; init; }
|
||||
|
||||
public List<FieldFilterRequest>? FieldFilters { get; init; }
|
||||
|
||||
public List<RelationshipFilterRequest>? RelationshipFilters { get; init; }
|
||||
}
|
||||
// public List<RelationshipFilterRequest>? RelationshipFilters { get; init; }
|
||||
//}
|
||||
|
||||
|
||||
public record FieldFilterRequest
|
||||
{
|
||||
//public record FieldFilterRequest
|
||||
//{
|
||||
|
||||
public Guid FieldId { get; init; }
|
||||
// public Guid FieldId { get; init; }
|
||||
|
||||
public string? ValueMask { get; init; }
|
||||
// public string? ValueMask { get; init; }
|
||||
|
||||
public bool IsInverse { get; init; } = false;
|
||||
// public bool IsInverse { get; init; } = false;
|
||||
|
||||
}
|
||||
//}
|
||||
|
||||
|
||||
public record RelationshipFilterRequest
|
||||
{
|
||||
public Guid FieldId { get; init; }
|
||||
//public record RelationshipFilterRequest
|
||||
//{
|
||||
// public Guid FieldId { get; init; }
|
||||
|
||||
public bool? IsParent { get; init; }
|
||||
// public bool? IsParent { get; init; }
|
||||
|
||||
public string? ValueMask { get; init; }
|
||||
// public string? ValueMask { get; init; }
|
||||
|
||||
public bool? IsFullMatch { get; init; }
|
||||
// public bool? IsFullMatch { get; init; }
|
||||
|
||||
public bool? IsInverse { get; init; }
|
||||
}
|
||||
// public bool? IsInverse { get; init; }
|
||||
//}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using PARR.Domain.Enums;
|
||||
|
||||
namespace PARR.API.Contracts.V1.Requests.Queries
|
||||
{
|
||||
public record RobotFilteredMetricsQuery
|
||||
{
|
||||
public RobotsEnum? RobotCode { get; init; }
|
||||
|
||||
public RobotStatusEnum? RobotStatusCode { get; init; }
|
||||
|
||||
public TaskStatusEnum? TaskStatusCode { get; init; }
|
||||
|
||||
public DateTimeOffset? DateFrom { get; init; }
|
||||
|
||||
public DateTimeOffset? DateTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Шаг группировки в минутах (например, 2, 30, 60, 1440)
|
||||
/// </summary>
|
||||
public int IntervalMinutes { get; set; } = 30;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace PARR.API.Contracts.V1.Requests
|
||||
{
|
||||
public class TnkRequest
|
||||
public record TnkRequest
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public string? ShortName { get; set; }
|
||||
public int? EsppId { get; set; }
|
||||
public Guid SubprocessId { get; set; }
|
||||
public required string Name { get; init; }
|
||||
public string? ShortName { get; init; }
|
||||
public int? EsppId { get; init; }
|
||||
public int? DurationMinutes { get; init; }
|
||||
public Guid SubprocessId { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
42
PARR.API/Contracts/V1/Requests/UnitFiltersBaseRequest.cs
Normal file
42
PARR.API/Contracts/V1/Requests/UnitFiltersBaseRequest.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace PARR.API.Contracts.V1.Requests
|
||||
{
|
||||
/// <summary>
|
||||
/// Unit фильтры для Job и JobGroup
|
||||
/// </summary>
|
||||
public record UnitFiltersBaseRequest
|
||||
{
|
||||
public required List<UnitFilterRequest> UnitFilters { get; init; }
|
||||
}
|
||||
|
||||
public record UnitFilterRequest
|
||||
{
|
||||
public required string UnitFilterMask { get; init; }
|
||||
|
||||
public List<FieldFilterRequest>? FieldFilters { get; init; }
|
||||
|
||||
public List<RelationshipFilterRequest>? RelationshipFilters { get; init; }
|
||||
}
|
||||
|
||||
public record FieldFilterRequest
|
||||
{
|
||||
public Guid FieldId { get; init; }
|
||||
|
||||
public string? ValueMask { get; init; }
|
||||
|
||||
public bool IsInverse { get; init; } = false;
|
||||
|
||||
}
|
||||
|
||||
public record RelationshipFilterRequest
|
||||
{
|
||||
public Guid FieldId { get; init; }
|
||||
|
||||
public bool? IsParent { get; init; }
|
||||
|
||||
public string? ValueMask { get; init; }
|
||||
|
||||
public bool? IsFullMatch { get; init; }
|
||||
|
||||
public bool? IsInverse { get; init; }
|
||||
}
|
||||
}
|
||||
33
PARR.API/Contracts/V1/Responses/HelpDocumentResponse.cs
Normal file
33
PARR.API/Contracts/V1/Responses/HelpDocumentResponse.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace PARR.API.Contracts.V1.Responses
|
||||
{
|
||||
public record HelpDocumentResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Уникальный ID (например: "ui/billing/invoice-create/vat-calc")
|
||||
/// </summary>
|
||||
public string HelpId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок из метаданных файла (title)
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// // Чистый Markdown-текст (все, что идет после метаданных)
|
||||
/// </summary>
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// ИД для перехода назад
|
||||
/// </summary>
|
||||
public string? PrevId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ИД для перехода вперед
|
||||
/// </summary>
|
||||
public string? NextId { get; init; }
|
||||
|
||||
public string? EditUrl { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
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 required string ShortDescription { get; set; }
|
||||
|
||||
public required string FullDescription { get; set; }
|
||||
@@ -49,9 +50,19 @@
|
||||
// public int? AgentTimeOutSec { get; set; }
|
||||
|
||||
// public string? AgentScript { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Юнит фильтры
|
||||
/// </summary>
|
||||
public List<UnitFilterResponse>? UnitFilters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Маска ЭК
|
||||
/// </summary>
|
||||
public required string EkMask { get; set; }
|
||||
}
|
||||
|
||||
public class JobGroupResponse : JobGroupWithDistributionConfigResponse //: JobGroupBaseResponse
|
||||
public class JobGroupResponse : JobGroupWithDistributionConfigResponse
|
||||
{
|
||||
//public List<JobResponse>? Jobs { get; set; }
|
||||
public int JobsCount { get; set; }
|
||||
@@ -71,9 +82,9 @@
|
||||
public ScheduleExcludeTypeCalendarResponse? ScheduleExcludeTypeCalendar { get; set; }
|
||||
|
||||
///// <summary>
|
||||
///// Настройки автораспределения
|
||||
///// Юнит фильтры
|
||||
///// </summary>
|
||||
//public JobGroupDistributionConfigResponse? DistributionConfig { get; set; }
|
||||
//public List<UnitFilterResponse>? UnitFilters { get; set; }
|
||||
}
|
||||
|
||||
public class JobGroupAutoControlResponse
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
|
||||
public int? TemplatesCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Юнит фильтры Работы
|
||||
/// </summary>
|
||||
public List<UnitFilterResponse>? UnitFilters { get; set; }
|
||||
|
||||
public JobAutoControlResponse? AutoControl { get; set; }
|
||||
@@ -46,44 +49,6 @@
|
||||
}
|
||||
|
||||
|
||||
public class UnitFilterResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
|
||||
public required string UnitFilterMask { get; set; }
|
||||
|
||||
public List<FieldFilterResponse>? FieldFilters { get; set; }
|
||||
|
||||
public List<RelationshipFilterResponse>? RelationshipFilters { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class FieldFilterResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
|
||||
public required FieldResponse Field { get; set; }
|
||||
|
||||
public string? ValueMask { get; set; }
|
||||
|
||||
public bool IsInverse { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class RelationshipFilterResponse
|
||||
{
|
||||
public required FieldResponse Field { get; set; }
|
||||
|
||||
public bool? IsParent { get; set; }
|
||||
|
||||
public string? ValueMask { get; set; }
|
||||
|
||||
public bool? IsFullMatch { get; set; }
|
||||
|
||||
public bool? IsInverse { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class TemplateStats
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PARR.API.Contracts.V1.Responses.Statistics
|
||||
{
|
||||
public record StatFilteredChartPoint
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public int Count { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PARR.API.Contracts.V1.Responses.Statistics
|
||||
{
|
||||
public record StatRobotStatusChartPoint
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public int Wait { get; init; }
|
||||
public int InProgress { get; init; }
|
||||
public int Error { get; init; }
|
||||
public int Complete { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PARR.API.Contracts.V1.Responses.Statistics
|
||||
{
|
||||
public record StatTaskStatusChartPoint
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public int Creating { get; init; }
|
||||
public int Updating { get; init; }
|
||||
public int Ok { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace PARR.API.Contracts.V1.Responses.Statistics
|
||||
{
|
||||
public record StatTemplatesWithoutScheduleResponse(int Count);
|
||||
|
||||
}
|
||||
@@ -33,6 +33,14 @@
|
||||
/// </summary>
|
||||
public string? ResponseArea { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ЭК согласно настроек маски группы работ, подставляется в шаблон ЕСПП
|
||||
/// </summary>
|
||||
public required string EsppEk { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Юнит привязанный к шаблону
|
||||
/// </summary>
|
||||
public UnitWithAttributesResponse? Unit { get; set; }
|
||||
|
||||
public TemplateStatusTypeResponse? StatusType { get; set; }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
public int? EsppId { get; set; }
|
||||
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
public Guid SubprocessId { get; set; }
|
||||
}
|
||||
|
||||
|
||||
32
PARR.API/Contracts/V1/Responses/UnitFilterBaseResponse.cs
Normal file
32
PARR.API/Contracts/V1/Responses/UnitFilterBaseResponse.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace PARR.API.Contracts.V1.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Базовый респонс для юнит фильтров
|
||||
/// </summary>
|
||||
public class UnitFilterResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string UnitFilterMask { get; set; }
|
||||
public List<FieldFilterResponse>? FieldFilters { get; set; }
|
||||
public List<RelationshipFilterResponse>? RelationshipFilters { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class FieldFilterResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required FieldResponse Field { get; set; }
|
||||
public string? ValueMask { get; set; }
|
||||
public bool IsInverse { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class RelationshipFilterResponse
|
||||
{
|
||||
public required FieldResponse Field { get; set; }
|
||||
public bool? IsParent { get; set; }
|
||||
public string? ValueMask { get; set; }
|
||||
public bool? IsFullMatch { get; set; }
|
||||
public bool? IsInverse { get; set; }
|
||||
}
|
||||
}
|
||||
173
PARR.API/Controllers/V1/HelpDocumentController.cs
Normal file
173
PARR.API/Controllers/V1/HelpDocumentController.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PARR.API.Contracts.V1;
|
||||
using PARR.API.Contracts.V1.Responses;
|
||||
using PARR.API.Contracts.V1.Responses.Base;
|
||||
using PARR.API.Controllers.V1.Base;
|
||||
using PARR.Core.Services.HelpDocument.Interfaces;
|
||||
using PARR.Domain.Common.Roles;
|
||||
using PARR.Domain.Exceptions;
|
||||
using PARR.Domain.Settings;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PARR.API.Controllers.V1
|
||||
{
|
||||
/// <summary>
|
||||
/// Контроллер по работе со справочной документацией
|
||||
/// </summary>
|
||||
public class HelpDocumentController : BaseApiController
|
||||
{
|
||||
private readonly IHelpDocumentService _helpDocumentService;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly HelpDocumentSettings _helpDocumentSettings;
|
||||
private readonly ILogger<HelpDocumentController> _logger;
|
||||
|
||||
public HelpDocumentController(
|
||||
IHelpDocumentService helpDocumentService,
|
||||
IMapper mapper,
|
||||
HelpDocumentSettings helpDocumentSettings,
|
||||
ILogger<HelpDocumentController> logger
|
||||
)
|
||||
{
|
||||
_helpDocumentService = helpDocumentService;
|
||||
_mapper = mapper;
|
||||
_helpDocumentSettings = helpDocumentSettings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Получить документ справки
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
/// <param name="helpId">Путь к справке (например: ui/billing/invoice-create/vat-calc)</param>
|
||||
[Authorize(Roles = ParrRoles.Administrator.Role)]
|
||||
[HttpGet(ApiRoutes.HelpDocument.GetDocument)]
|
||||
public async Task<IActionResult> GetHelpDocument([FromQuery] string helpId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(helpId))
|
||||
throw new ArgumentNullException(nameof(helpId), "Значение не может быть пустым");
|
||||
|
||||
var documnet = await _helpDocumentService.GetHelpDocumentAsync(helpId);
|
||||
|
||||
if (documnet == null)
|
||||
return NotFound();
|
||||
|
||||
var response = _mapper.Map<HelpDocumentResponse>(documnet);
|
||||
|
||||
return Ok(new Response<HelpDocumentResponse>(response, true));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Вебхук от GitLab для автоматического сброса кэша при пуше изменений
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpPost(ApiRoutes.HelpDocument.GitLabWebhook)]
|
||||
public async Task<IActionResult> GitLabWebhook([FromBody] JsonNode payload)
|
||||
{
|
||||
_logger.LogInformation("Получен Webhook запрос от GitLab.");
|
||||
|
||||
#region Проверка безопасности
|
||||
|
||||
// GitLab передает токен в заголовке X-Gitlab-Token
|
||||
if (!Request.Headers.TryGetValue("X-Gitlab-Token", out var receivedToken))
|
||||
{
|
||||
_logger.LogWarning("Запрос вебхука отклонен. Отсутствует заголовок X-Gitlab-Token.");
|
||||
throw new UnauthorizedException("Отсутствует авторизационный токен вебхука.");
|
||||
}
|
||||
|
||||
if (receivedToken.ToString() != _helpDocumentSettings.WebhookToken)
|
||||
{
|
||||
_logger.LogWarning("Запрос вебхука отклонен. Передан невалидный Secret Token.");
|
||||
throw new UnauthorizedException("Неверный токен вебхука.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
_logger.LogDebug("Проверка Secret Token пройдена успешно. Начинается обработка изменений.");
|
||||
|
||||
try
|
||||
{
|
||||
// Проверяем, что это событие push
|
||||
var objectKind = payload["object_kind"]?.ToString();
|
||||
if (objectKind != "push")
|
||||
{
|
||||
_logger.LogDebug("Игнорируем вебхук: тип события '{ObjectKind}' не является 'push'.", objectKind);
|
||||
return Ok(new { message = "Ignored non-push event" });
|
||||
}
|
||||
|
||||
// Извлекаем массив коммитов
|
||||
var commits = payload["commits"]?.AsArray();
|
||||
if (commits == null || commits.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("В вебхуке отсутствуют коммиты.");
|
||||
return Ok(new { message = "No commits found" });
|
||||
}
|
||||
|
||||
var filesToInvalidate = new HashSet<string>();
|
||||
|
||||
// Пробегаемся по коммитам и собираем измененные/добавленные файлы
|
||||
foreach (var commit in commits)
|
||||
{
|
||||
var modifiedFiles = commit?["modified"]?.AsArray();
|
||||
if (modifiedFiles != null)
|
||||
{
|
||||
foreach (var file in modifiedFiles)
|
||||
{
|
||||
var filePath = file?.ToString();
|
||||
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filesToInvalidate.Add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var addedFiles = commit?["added"]?.AsArray();
|
||||
if (addedFiles != null)
|
||||
{
|
||||
foreach (var file in addedFiles)
|
||||
{
|
||||
var filePath = file?.ToString();
|
||||
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filesToInvalidate.Add(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToInvalidate.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Среди измененных файлов в коммитах нет документов .md. Кэш не изменен.");
|
||||
return Ok(new { message = "No .md files modified" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Вебхук зафиксировал изменения в {Count} файлах справок. Начинается сброс кэша...", filesToInvalidate.Count);
|
||||
|
||||
// Инвалидируем кэш для каждого измененного файла
|
||||
foreach (var file in filesToInvalidate)
|
||||
{
|
||||
// Превращаем путь "ui/billing/vat-calc.md" обратно в helpId "ui/billing/vat-calc"
|
||||
var helpId = file.Substring(0, file.Length - 3);
|
||||
|
||||
_logger.LogDebug("Сброс кэша по вебхуку для HelpId: {HelpId}", helpId);
|
||||
await _helpDocumentService.ClearCacheAsync(helpId);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Сброс кэша по вебхуку GitLab успешно завершен.");
|
||||
return Ok(new { clearedFilesCount = filesToInvalidate.Count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Ошибка при обработке Webhook от GitLab");
|
||||
// Возвращаем 200 или 500 в зависимости от политики.
|
||||
// Обычно возвращают BadRequest/InternalServerError, чтобы в GitLab в истории вебхуков была видна ошибка отладки.
|
||||
throw new ExternalServiceException("Внутренняя ошибка обработки вебхука");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ namespace PARR.API.Controllers.V1
|
||||
var paginationFilter = _mapper.Map<PaginationFilter>(paginationQuery);
|
||||
|
||||
IQueryable<Job> query = _jobRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Include(t => t.AutoControl);
|
||||
|
||||
|
||||
@@ -152,9 +153,22 @@ namespace PARR.API.Controllers.V1
|
||||
{
|
||||
var job = await _jobRepository.Get()
|
||||
.Include(t => t.Tnk)
|
||||
.Include(t => t.Group).ThenInclude(t => t.GroupType)
|
||||
.Include(t => t.Group).ThenInclude(t => t.AutoControl)
|
||||
.Include(t => t.Group).ThenInclude(t => t.GroupingUnitField)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.GroupType)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.AutoControl)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.GroupingUnitField)
|
||||
//--- Фильтры в группе
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t=>t.UnitField)
|
||||
//---
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
@@ -162,6 +176,7 @@ namespace PARR.API.Controllers.V1
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.AutoControl)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (job == null)
|
||||
@@ -169,12 +184,9 @@ namespace PARR.API.Controllers.V1
|
||||
|
||||
var response = _mapper.Map<JobResponse>(job);
|
||||
|
||||
response.TemplatesCount = await GetCountTemplatesAsync(id); //await templateService.Get().CountAsync(t => t.JobId == id);
|
||||
response.TemplatesCount = await GetCountTemplatesAsync(id);
|
||||
response.MatchingStatus = await GetMatchingStatusAsync(id);
|
||||
|
||||
//var statistics = await GetStatisticsAsync(response.Id);
|
||||
//BindStatistics(response, statistics);
|
||||
|
||||
return Ok(new Response<JobResponse>(response, true));
|
||||
}
|
||||
|
||||
@@ -243,7 +255,18 @@ namespace PARR.API.Controllers.V1
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.AutoControl)
|
||||
//--- Фильтры в группе
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
//---
|
||||
.FirstOrDefaultAsync(t => t.Id == job.Id);
|
||||
|
||||
var locationUri = _uriService.GetUri(ApiRoutes.Job.Get, ApiRoutes.Job.getParam, createdJob!.Id);
|
||||
@@ -361,10 +384,21 @@ namespace PARR.API.Controllers.V1
|
||||
.Include(t => t.Group).ThenInclude(t => t.GroupingUnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t=>t.UnitField)
|
||||
.Include(t => t.AutoControl)
|
||||
//--- Фильтры в группе
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.Group)
|
||||
.ThenInclude(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
//---
|
||||
.FirstAsync(t => t.Id == orig.Id);
|
||||
|
||||
var response = _mapper.Map<JobResponse>(updatedJob);
|
||||
@@ -411,124 +445,6 @@ namespace PARR.API.Controllers.V1
|
||||
|
||||
orig.UnitFilters.Add(newUnitFilter);
|
||||
}
|
||||
|
||||
#region old
|
||||
/* Писал полноценный апдейт, но Миша сказал что нахрен это - просто все удаляем, а потом создаём заново... Ох уж этот Миша...
|
||||
//Сразу удаляем UnitFilter которых нет
|
||||
var toDelete = orig.UnitFilters.Where(t => !mappedRequest.UnitFilters.Any(e => e.Id == t.Id));
|
||||
foreach (var item in toDelete)
|
||||
orig.UnitFilters.Remove(item);
|
||||
|
||||
foreach (var mappedUnitFilter in mappedRequest.UnitFilters)
|
||||
{
|
||||
var origUnitFilter = orig.UnitFilters.FirstOrDefault(t => t.Id == mappedUnitFilter.Id);
|
||||
|
||||
if (origUnitFilter == null)
|
||||
{
|
||||
var newUnitFilter = new JobUnitFilter
|
||||
{
|
||||
UnitFilter = mappedUnitFilter.UnitFilter,
|
||||
DateCreated = DateTimeOffset.UtcNow,
|
||||
JobId = orig.Id
|
||||
};
|
||||
|
||||
foreach (var newRequestFieldFilter in mappedUnitFilter.FieldFilters)
|
||||
{
|
||||
var newFieldFilter = CreateFieldFilter(newUnitFilter.Id, newRequestFieldFilter.FieldId, newRequestFieldFilter.ValueMask);
|
||||
|
||||
newUnitFilter.FieldFilters.Add(newFieldFilter);
|
||||
}
|
||||
|
||||
foreach (var newRequestRelationshipFilter in mappedUnitFilter.RelationshipFilters)
|
||||
{
|
||||
var newRelationshipFilter = CreateRelationshipFilter(newRequestRelationshipFilter.FieldId,
|
||||
newRequestRelationshipFilter.IsParent,
|
||||
newRequestRelationshipFilter.IsFullMatch,
|
||||
newRequestRelationshipFilter.IsInverse,
|
||||
newRequestRelationshipFilter.ValueMask);
|
||||
newUnitFilter.RelationshipFilters.Add(newRelationshipFilter);
|
||||
}
|
||||
|
||||
orig.UnitFilters.Add(newUnitFilter);
|
||||
}
|
||||
else
|
||||
{
|
||||
#region Непосредственно UnitFilter
|
||||
if (origUnitFilter.UnitFilter != mappedUnitFilter.UnitFilter)
|
||||
{
|
||||
origUnitFilter.UnitFilter = mappedUnitFilter.UnitFilter;
|
||||
origUnitFilter.DateModified = DateTimeOffset.UtcNow;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region FieldFilters
|
||||
//Сразу удаляем неактуальные
|
||||
var fieldFiltersToDelete = origUnitFilter.FieldFilters.Where(t => !mappedUnitFilter.FieldFilters.Any(m => m.FieldId == t.FieldId));
|
||||
foreach (var item in fieldFiltersToDelete)
|
||||
origUnitFilter.FieldFilters.Remove(item);
|
||||
|
||||
//Перебираем FieldFilter
|
||||
foreach (var mappedFieldFilter in mappedUnitFilter.FieldFilters)
|
||||
{
|
||||
var origFieldFilter = origUnitFilter.FieldFilters.FirstOrDefault(t => t.FieldId == mappedFieldFilter.FieldId);
|
||||
|
||||
if (origFieldFilter == null)//Нет в БД? не проблема - создадим
|
||||
{
|
||||
var newFieldFilter = CreateFieldFilter(origUnitFilter.Id, mappedFieldFilter.FieldId, mappedFieldFilter.ValueMask);
|
||||
origUnitFilter.FieldFilters.Add(newFieldFilter);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (origFieldFilter.ValueMask != mappedFieldFilter.ValueMask)
|
||||
{
|
||||
origFieldFilter.ValueMask = mappedFieldFilter.ValueMask;
|
||||
origFieldFilter.DateModified = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region RelationshipFilters
|
||||
//Сразу удаляем неактуальные
|
||||
var relationshipFiltersToDelete = origUnitFilter.RelationshipFilters.Where(t => !mappedUnitFilter.RelationshipFilters.Any(m => m.FieldId == t.FieldId));
|
||||
foreach (var item in relationshipFiltersToDelete)
|
||||
origUnitFilter.RelationshipFilters.Remove(item);
|
||||
|
||||
//Перебираем RelationshipFilters
|
||||
foreach (var mappedRelationshipFilter in mappedUnitFilter.RelationshipFilters)
|
||||
{
|
||||
var origRelationshipFilter = origUnitFilter.RelationshipFilters.FirstOrDefault(t => t.FieldId == mappedRelationshipFilter.FieldId);
|
||||
|
||||
if (origRelationshipFilter == null)//Нет в БД? не проблема - создадим
|
||||
{
|
||||
var newRelationshipFilter = CreateRelationshipFilter(mappedRelationshipFilter.FieldId,
|
||||
mappedRelationshipFilter.IsParent,
|
||||
mappedRelationshipFilter.IsFullMatch,
|
||||
mappedRelationshipFilter.IsInverse,
|
||||
mappedRelationshipFilter.ValueMask);
|
||||
|
||||
origUnitFilter.RelationshipFilters.Add(newRelationshipFilter);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (origRelationshipFilter.IsParent != mappedRelationshipFilter.IsParent)
|
||||
origRelationshipFilter.IsParent = mappedRelationshipFilter.IsParent;
|
||||
|
||||
if (origRelationshipFilter.ValueMask != mappedRelationshipFilter.ValueMask)
|
||||
origRelationshipFilter.ValueMask = mappedRelationshipFilter.ValueMask;
|
||||
|
||||
if (origRelationshipFilter.IsFullMatch != mappedRelationshipFilter.IsFullMatch)
|
||||
origRelationshipFilter.IsFullMatch = mappedRelationshipFilter.IsFullMatch;
|
||||
|
||||
if (origRelationshipFilter.IsInverse != mappedRelationshipFilter.IsInverse)
|
||||
origRelationshipFilter.IsInverse = mappedRelationshipFilter.IsInverse;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}*/
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
private static JobRelationshipFilter CreateRelationshipFilter(Guid fieldId, bool isParent, bool isFullMatch, bool isInverse, string valueMask)
|
||||
|
||||
@@ -25,6 +25,7 @@ using PARR.Domain.Entities.Base.History;
|
||||
using PARR.Domain.Entities.JobGroupEntities;
|
||||
using PARR.Domain.Entities.Schedule;
|
||||
using PARR.Domain.Enums;
|
||||
using PARR.Domain.Exceptions;
|
||||
|
||||
namespace PARR.API.Controllers.V1
|
||||
{
|
||||
@@ -62,9 +63,9 @@ namespace PARR.API.Controllers.V1
|
||||
MqSettings mqSettings
|
||||
)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._mapper = mapper;
|
||||
this._uriService = uriService;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_uriService = uriService;
|
||||
_groupRepository = groupRepository;
|
||||
_jobRepository = jobRepository;
|
||||
_esppConfigRepository = esppConfigRepository;
|
||||
@@ -72,8 +73,8 @@ namespace PARR.API.Controllers.V1
|
||||
_matchingStatusRepository = matchingStatusRepository;
|
||||
_scheduleResponseAreaTimeOffsetRepository = scheduleResponseAreaTimeOffsetRepository;
|
||||
_jobAutoControlRepository = jobAutoControlRepository;
|
||||
this._mqService = mqService;
|
||||
this._mqSettings = mqSettings;
|
||||
_mqService = mqService;
|
||||
_mqSettings = mqSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +140,12 @@ namespace PARR.API.Controllers.V1
|
||||
.Include(t => t.ScheduleExcludeTypeCalendar)
|
||||
.Include(t => t.DistributionConfig).ThenInclude(t => t.DistributionPeriod)
|
||||
.Include(t => t.AutoControl)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (jobGroup == null)
|
||||
@@ -176,7 +183,9 @@ namespace PARR.API.Controllers.V1
|
||||
ScheduleExcludeTypeCalendarId = request.ScheduleExcludeTypeCalendarId,
|
||||
IsAutoDistributionEnabled = request.IsAutoDistributionEnabled,
|
||||
IsResponseAreaTimezone = request.IsWorkGroupTimezone,
|
||||
IsGroupByResponsible = request.IsGroupByResponsible
|
||||
IsGroupByResponsible = request.IsGroupByResponsible,
|
||||
UnitFilters = new List<JobGroupUnitFilter>(),
|
||||
EkMask = request.EkMask,
|
||||
//IsAgent = request.IsAgent,
|
||||
//AgentName = request.AgentName,
|
||||
//AgentTimeOutSec = request.AgentTimeOutSec,
|
||||
@@ -213,6 +222,12 @@ namespace PARR.API.Controllers.V1
|
||||
|
||||
#endregion
|
||||
|
||||
#region Фильтры
|
||||
|
||||
jobGroup.UnitFilters = MapUnitFilters(request.UnitFilters, jobGroup.Id);
|
||||
|
||||
#endregion
|
||||
|
||||
//Добавляем настройки планировщика
|
||||
request.Schedule.ForEach(item =>
|
||||
{
|
||||
@@ -237,6 +252,12 @@ namespace PARR.API.Controllers.V1
|
||||
.Include(t => t.ScheduleExcludeTypeCalendar)
|
||||
.Include(t => t.DistributionConfig).ThenInclude(t => t.DistributionPeriod)
|
||||
.Include(t => t.AutoControl)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.FirstAsync(t => t.Id == jobGroup.Id);
|
||||
|
||||
var locationUri = _uriService.GetUri(ApiRoutes.JobGroup.Get, ApiRoutes.JobGroup.getParam, createdJobGroup.Id);
|
||||
@@ -257,23 +278,35 @@ namespace PARR.API.Controllers.V1
|
||||
[HttpPut(ApiRoutes.JobGroup.Update)]
|
||||
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] JobGroupRequest request)
|
||||
{
|
||||
// Специально не будем догружать инфу о фильтрах, чтоб не было слишком тяжелого запроса списка
|
||||
var orig = await _groupRepository.Get()
|
||||
.Include(t => t.Jobs)
|
||||
.ThenInclude(t => t.Tnk)
|
||||
.Include(t => t.EsppSchValues)
|
||||
.Include(t => t.AutoControl)
|
||||
.Include(t => t.DistributionConfig).ThenInclude(t => t.DistributionPeriod)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (orig == null)
|
||||
return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = $"Ошибка при изменении группы заданий на выполнение работ. Не найдена группа с Id: {id}" } }));
|
||||
|
||||
// Тип группы работ изменять нельзя
|
||||
if (orig.GroupTypeId != request.GroupTypeId)
|
||||
throw new AppValidationException("Запрещено изменение поля 'Тип работ'");
|
||||
|
||||
|
||||
//Расписание было изменено, ниже добавим задание в очередь на обновление расписаний у связанных шаблонов
|
||||
var isScheduleChanged = IsScheduleChanged(orig, request);
|
||||
|
||||
orig.GroupName = request.Name.Trim();
|
||||
//orig.IsUmbrella = request.IsUmbrella;
|
||||
orig.GroupTypeId = request.GroupTypeId;
|
||||
// Тип группы работ изменять нельзя
|
||||
//orig.GroupTypeId = request.GroupTypeId;
|
||||
orig.GroupingUnitFieldId = await GetGroupingUnitFieldIdAsync(request);
|
||||
orig.ShortDescription = request.ShortDescription.Trim();
|
||||
orig.FullDescription = request.FullDescription.Trim();
|
||||
@@ -287,6 +320,7 @@ namespace PARR.API.Controllers.V1
|
||||
orig.IsResponseAreaTimezone = request.IsWorkGroupTimezone;
|
||||
// IsGroupByResponsible - может быть значение, только если тип ГРУППОВОЙ
|
||||
orig.IsGroupByResponsible = await GetIsGroupByResponsibleValue(request);
|
||||
orig.EkMask = request.EkMask;
|
||||
//orig.IsAgent = request.IsAgent;
|
||||
//orig.AgentName = request.AgentName;
|
||||
//orig.AgentTimeOutSec = request.AgentTimeOutSec;
|
||||
@@ -364,7 +398,19 @@ namespace PARR.API.Controllers.V1
|
||||
|
||||
#endregion
|
||||
|
||||
//обновляем планировщик
|
||||
#region Фильтры
|
||||
|
||||
orig.UnitFilters.Clear();
|
||||
|
||||
var newFilters = MapUnitFilters(request.UnitFilters, orig.Id);
|
||||
foreach (var filter in newFilters)
|
||||
{
|
||||
orig.UnitFilters.Add(filter);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region обновляем планировщик
|
||||
orig.EsppSchValues.Clear();
|
||||
request.Schedule.ForEach(item =>
|
||||
{
|
||||
@@ -375,6 +421,7 @@ namespace PARR.API.Controllers.V1
|
||||
TypeValueId = item.TypeValueId
|
||||
});
|
||||
});
|
||||
#endregion
|
||||
|
||||
if (!await _groupRepository.CommitAsync())
|
||||
return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = "Ошибка при изменении группы заданий на выполнение работ." } }));
|
||||
@@ -413,6 +460,12 @@ namespace PARR.API.Controllers.V1
|
||||
.Include(t => t.ScheduleExcludeTypeCalendar)
|
||||
.Include(t => t.DistributionConfig).ThenInclude(t => t.DistributionPeriod)
|
||||
.Include(t => t.AutoControl)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.FieldFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.Include(t => t.UnitFilters)
|
||||
.ThenInclude(t => t.RelationshipFilters)
|
||||
.ThenInclude(t => t.UnitField)
|
||||
.FirstAsync(t => t.Id == orig.Id);
|
||||
|
||||
var response = _mapper.Map<JobGroupResponse>(updatedJobGroup);
|
||||
@@ -458,6 +511,66 @@ namespace PARR.API.Controllers.V1
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Обновление юнит фильтров
|
||||
/// </summary>
|
||||
/// <param name="requestFilters"></param>
|
||||
/// <param name="jobGroupId"></param>
|
||||
/// <returns></returns>
|
||||
private List<JobGroupUnitFilter> MapUnitFilters(List<UnitFilterRequest> requestFilters, Guid jobGroupId)
|
||||
{
|
||||
return requestFilters.Select(requestFilter =>
|
||||
{
|
||||
var unitFilter = new JobGroupUnitFilter
|
||||
{
|
||||
DateCreated = DateTimeOffset.UtcNow,
|
||||
JobGroupId = jobGroupId,
|
||||
UnitFilter = requestFilter.UnitFilterMask
|
||||
};
|
||||
|
||||
// Добавляем FieldFilters
|
||||
if (requestFilter.FieldFilters != null)
|
||||
{
|
||||
foreach (var fieldFilterRequest in requestFilter.FieldFilters)
|
||||
{
|
||||
var fieldFilter = new JobGroupFieldFilter
|
||||
{
|
||||
DateCreated = DateTimeOffset.UtcNow,
|
||||
FieldId = fieldFilterRequest.FieldId,
|
||||
ValueMask = fieldFilterRequest.ValueMask ?? string.Empty,
|
||||
IsInverse = fieldFilterRequest.IsInverse,
|
||||
UnitFilter = unitFilter
|
||||
};
|
||||
unitFilter.FieldFilters.Add(fieldFilter);
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем RelationshipFilters
|
||||
if (requestFilter.RelationshipFilters != null)
|
||||
{
|
||||
foreach (var relFilterRequest in requestFilter.RelationshipFilters)
|
||||
{
|
||||
var relFilter = new JobGroupRelationshipFilter
|
||||
{
|
||||
FieldId = relFilterRequest.FieldId,
|
||||
IsParent = relFilterRequest.IsParent ?? false,
|
||||
ValueMask = relFilterRequest.ValueMask ?? string.Empty,
|
||||
IsFullMatch = relFilterRequest.IsFullMatch ?? false,
|
||||
IsInverse = relFilterRequest.IsInverse ?? false,
|
||||
UnitFilter = unitFilter
|
||||
};
|
||||
unitFilter.RelationshipFilters.Add(relFilter);
|
||||
}
|
||||
}
|
||||
|
||||
return unitFilter;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Удалить настройки автоконтроля для связанных Job
|
||||
/// </summary>
|
||||
|
||||
@@ -65,6 +65,8 @@ namespace PARR.API.Controllers.V1
|
||||
|
||||
var jobGroup = await jobGroupService.Get().AsNoTracking()
|
||||
.Include(t => t.GroupType)
|
||||
.Include(g => g!.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
|
||||
.Include(g => g!.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.FirstAsync(t => t.Id == job.GroupId);
|
||||
job.Group = jobGroup;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PARR.API.Contracts.V1;
|
||||
using PARR.API.Contracts.V1.Requests.Queries;
|
||||
using PARR.API.Contracts.V1.Responses.Base;
|
||||
using PARR.API.Contracts.V1.Responses.Statistics;
|
||||
using PARR.API.Controllers.V1.Base;
|
||||
using PARR.Core.Services.RobotMetrics;
|
||||
using PARR.Domain.Common.Roles;
|
||||
using PARR.Domain.DTOs.RobotMetrics;
|
||||
using PARR.Domain.Enums;
|
||||
|
||||
namespace PARR.API.Controllers.V1.Statistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Метрики заданий роботам
|
||||
/// </summary>
|
||||
[Authorize(Roles = ParrRoles.Administrator.Role)]
|
||||
public class StatRobotMetricsController : BaseApiController
|
||||
{
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IRobotMetricsService _robotMetricsService;
|
||||
|
||||
public StatRobotMetricsController(
|
||||
IMapper mapper,
|
||||
IRobotMetricsService robotMetricsService
|
||||
)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_robotMetricsService = robotMetricsService;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Статистика по Заданиям Роботу, график
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet(ApiRoutes.StatRobotMetrics.GetRobotStatusMetrics)]
|
||||
public async Task<IActionResult> GetRobotStatusMetrics([FromRoute] RobotsEnum robotCode, [FromRoute] ChartPeriod period, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = await _robotMetricsService.GetRobotStatusMetricsAsync(robotCode, period, cancellationToken);
|
||||
|
||||
var response = _mapper.Map<List<StatRobotStatusChartPoint>>(data);
|
||||
|
||||
return Ok(new Response<List<StatRobotStatusChartPoint>>(response, true));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Статистика по Статусам Заданий, график
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet(ApiRoutes.StatRobotMetrics.GetTaskStatusMetrics)]
|
||||
public async Task<IActionResult> GetTaskStatusMetrics([FromRoute] RobotsEnum robotCode, [FromRoute] ChartPeriod period, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = await _robotMetricsService.GetTaskStatusMetricsAsync(robotCode, period, cancellationToken);
|
||||
|
||||
var response = _mapper.Map<List<StatTaskStatusChartPoint>>(data);
|
||||
|
||||
return Ok(new Response<List<StatTaskStatusChartPoint>>(response, true));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Статистика по статусам заданий, гибкий фильтр
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet(ApiRoutes.StatRobotMetrics.GetFilteredMetrics)]
|
||||
public async Task<IActionResult> GetFilteredMetrics([FromQuery] RobotFilteredMetricsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// ------- Правильность расчетов этого метода доконца не проверена -------
|
||||
|
||||
var filter = _mapper.Map<MetricFilter>(request);
|
||||
|
||||
var data = await _robotMetricsService.GetFilteredMetricsAsync(filter, cancellationToken);
|
||||
|
||||
var response = _mapper.Map<List<StatFilteredChartPoint>>(data);
|
||||
|
||||
return Ok(new Response<List<StatFilteredChartPoint>>(response, true));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ using PARR.API.Helpers;
|
||||
using PARR.Core.Repositories.Interfaces;
|
||||
using PARR.Core.Services.NextRunServices;
|
||||
using PARR.Domain.Common.Roles;
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Enums;
|
||||
|
||||
namespace PARR.API.Controllers.V1.Statistics
|
||||
@@ -20,16 +21,16 @@ namespace PARR.API.Controllers.V1.Statistics
|
||||
[Authorize(Roles = ParrRoles.Administrator.Role)]
|
||||
public class StatTemplateController : BaseApiController
|
||||
{
|
||||
private readonly ITemplateRepository templateService;
|
||||
private readonly INextRunService nextRunService;
|
||||
private readonly ITemplateRepository _templateRepository;
|
||||
private readonly INextRunService _nextRunService;
|
||||
|
||||
public StatTemplateController(
|
||||
ITemplateRepository templateService,
|
||||
ITemplateRepository templateRepository,
|
||||
INextRunService nextRunService
|
||||
)
|
||||
{
|
||||
this.templateService = templateService;
|
||||
this.nextRunService = nextRunService;
|
||||
_templateRepository = templateRepository;
|
||||
_nextRunService = nextRunService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -41,12 +42,12 @@ namespace PARR.API.Controllers.V1.Statistics
|
||||
{
|
||||
var response = new StatTemplateResponse
|
||||
{
|
||||
ActivateScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveSchedule),
|
||||
ActivateTemplateCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveTemplate),
|
||||
TemplateAgentCount = await templateService.Get().AsNoTracking().CountAsync(t => t.Job!.Group!.IsAgent),
|
||||
TemplateCount = await templateService.Get().AsNoTracking().CountAsync(),
|
||||
SyncEsppScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.ScheduleOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)),
|
||||
SyncEsppTemplatesCount = await templateService.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.TemplateOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok))
|
||||
ActivateScheduleCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.IsActiveSchedule),
|
||||
ActivateTemplateCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.IsActiveTemplate),
|
||||
TemplateAgentCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.Job!.Group!.IsAgent),
|
||||
TemplateCount = await _templateRepository.Get().AsNoTracking().CountAsync(),
|
||||
SyncEsppScheduleCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.ScheduleOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)),
|
||||
SyncEsppTemplatesCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.TemplateOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok))
|
||||
};
|
||||
|
||||
return Ok(new Response<StatTemplateResponse>(response, true));
|
||||
@@ -71,7 +72,7 @@ namespace PARR.API.Controllers.V1.Statistics
|
||||
userEnd.AddDays(1),
|
||||
timeZoneQuery.TimeZoneOffset);
|
||||
|
||||
var allRecords = await templateService.Get()
|
||||
var allRecords = await _templateRepository.Get()
|
||||
.AsNoTracking()
|
||||
.FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd)
|
||||
.Select(t => new { t.Id, t.DateCreated })
|
||||
@@ -80,7 +81,7 @@ namespace PARR.API.Controllers.V1.Statistics
|
||||
var resultDict = allRecords.GroupByUserDate(t => t.DateCreated, timeZoneQuery.TimeZoneOffset);
|
||||
|
||||
|
||||
var daysList = await nextRunService.GetWorkDaysAsync(userStart, userEnd, false);
|
||||
var daysList = await _nextRunService.GetWorkDaysAsync(userStart, userEnd, false);
|
||||
|
||||
var response = daysList.Select(date => new StatTemplatePeriodResponse
|
||||
{
|
||||
@@ -92,5 +93,27 @@ namespace PARR.API.Controllers.V1.Statistics
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Получить кол-во шаблонов у которых ИД расписания null и нет задания на создание расписания
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet(ApiRoutes.StatTemplate.GetTemplatesWithoutScheduleAndTaskCount)]
|
||||
public async Task<IActionResult> GetTemplatesWithoutScheduleAndTaskCount()
|
||||
{
|
||||
var count = await _templateRepository.Get()
|
||||
.CountAsync(t =>
|
||||
t.ScheduleEsppId == null
|
||||
&& !t.RobotConfigurations.Any(x =>
|
||||
x.RobotCode == (int)RobotsEnum.ScheduleOrder
|
||||
&& x.TaskStatusCode == (int)TaskStatusEnum.Creating
|
||||
)
|
||||
);
|
||||
|
||||
var resposne = new StatTemplatesWithoutScheduleResponse(count);
|
||||
|
||||
return Ok(new Response<StatTemplatesWithoutScheduleResponse>(resposne, true));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ using PARR.API.Extensions;
|
||||
using PARR.API.Services.Interfaces;
|
||||
using PARR.Core.Common.Helpers;
|
||||
using PARR.Core.Repositories.Interfaces;
|
||||
using PARR.Core.Repositories.Interfaces.Schedule;
|
||||
using PARR.Core.Services.Shortcodes;
|
||||
using PARR.Domain.Common.Pagination;
|
||||
using PARR.Domain.Common.Roles;
|
||||
@@ -292,6 +291,7 @@ namespace PARR.API.Controllers.V1
|
||||
{
|
||||
response.WorkGroup = await _shortcodesService.ApplyShortcodesAsync(template.Job!.WorkGroupMask, template);
|
||||
response.ResponseArea = await _shortcodesService.ApplyShortcodesAsync(template.Job!.ResponseAreaMask, template);
|
||||
response.EsppEk = await _shortcodesService.ApplyShortcodesAsync(template.Job!.Group!.EkMask, template);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,21 +27,18 @@ namespace PARR.API.Controllers.V1
|
||||
private readonly ILogger<TnkController> logger;
|
||||
private readonly IMapper mapper;
|
||||
private readonly ITnkRepository tnkService;
|
||||
//private readonly IValidator<TnkRequest> validator;
|
||||
private readonly IUriService uriService;
|
||||
|
||||
public TnkController(
|
||||
ILogger<TnkController> logger,
|
||||
IMapper mapper,
|
||||
ITnkRepository tnkService,
|
||||
//IValidator<TnkRequest> validator,
|
||||
IUriService uriService
|
||||
)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.mapper = mapper;
|
||||
this.tnkService = tnkService;
|
||||
//this.validator = validator;
|
||||
this.uriService = uriService;
|
||||
}
|
||||
|
||||
@@ -127,23 +124,16 @@ namespace PARR.API.Controllers.V1
|
||||
[HttpPost(ApiRoutes.Tnk.Create)]
|
||||
public async Task<IActionResult> Create([FromBody] TnkRequest request)
|
||||
{
|
||||
//var resultValidate = await validator.ValidateAsync(request);
|
||||
//if (!resultValidate.IsValid)
|
||||
// return BadRequest(new Response(resultValidate.Errors));
|
||||
|
||||
var existName = await tnkService.Get().FirstOrDefaultAsync(t => t.Name.ToLower() == request.Name.Trim().ToLower());
|
||||
if (existName != null)
|
||||
return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { FieldName = nameof(request.Name), Message = $"Вид работы с именем \"{request.Name}\" уже существует." } }));
|
||||
|
||||
//var existEsppId = await tnkService.Get().FirstOrDefaultAsync(t => t.EsppId == request.EsppId);
|
||||
//if (existEsppId != null)
|
||||
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { FieldName = nameof(request.EsppId), Message = $"ТНК с EsppId: {request.EsppId} уже существует." } }));
|
||||
|
||||
var tnk = new Tnk
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name.Trim(),
|
||||
EsppId = request.EsppId,
|
||||
DurationMinutes = request.DurationMinutes,
|
||||
ShortName = request.ShortName?.Trim(),
|
||||
SubprocessId = request.SubprocessId
|
||||
};
|
||||
@@ -168,15 +158,6 @@ namespace PARR.API.Controllers.V1
|
||||
[HttpPut(ApiRoutes.Tnk.Update)]
|
||||
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] TnkRequest request)
|
||||
{
|
||||
//var resultValidate = await validator.ValidateAsync(request);
|
||||
//if (!resultValidate.IsValid)
|
||||
// return BadRequest(new Response(resultValidate.Errors));
|
||||
|
||||
////проверка на уникальность esppId
|
||||
//var existEsppId = await tnkService.Get().FirstOrDefaultAsync(t => t.Id != id && t.EsppId == request.EsppId);
|
||||
//if (existEsppId != null)
|
||||
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { FieldName = nameof(request.EsppId), Message = $"Уже есть ТНК с EsppId: {request.EsppId}" } }));
|
||||
|
||||
var orig = await tnkService.Get()
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
@@ -186,6 +167,7 @@ namespace PARR.API.Controllers.V1
|
||||
orig.Name = request.Name.Trim();
|
||||
orig.EsppId = request.EsppId;
|
||||
orig.SubprocessId = request.SubprocessId;
|
||||
orig.DurationMinutes = request.DurationMinutes;
|
||||
orig.ShortName = request.ShortName?.Trim();
|
||||
orig.DateModified = DateTimeOffset.UtcNow;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
// определяем статус код, в зависимости от типа исключения
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PARR.API.Settings;
|
||||
using PARR.Domain.Settings;
|
||||
|
||||
namespace PARR.API.Installers
|
||||
{
|
||||
@@ -27,6 +28,10 @@ namespace PARR.API.Installers
|
||||
configuration.GetSection(nameof(CommonSettings)).Bind(commonSettings);
|
||||
services.AddSingleton(commonSettings);
|
||||
|
||||
var helpDocumentSettings = new HelpDocumentSettings();
|
||||
configuration.GetSection(nameof(HelpDocumentSettings)).Bind(helpDocumentSettings);
|
||||
services.AddSingleton(helpDocumentSettings);
|
||||
|
||||
//TODO: add other
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
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.HelpDocument;
|
||||
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;
|
||||
@@ -15,6 +22,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;
|
||||
|
||||
@@ -78,7 +86,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
|
||||
|
||||
@@ -254,12 +262,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
|
||||
|
||||
@@ -342,7 +359,8 @@ namespace PARR.API.MappingProfiles
|
||||
.Include<Job, JobResponse>();
|
||||
|
||||
CreateMap<Job, JobResponse>()
|
||||
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl));
|
||||
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl))
|
||||
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t => t.UnitFilter)));
|
||||
|
||||
CreateMap<JobUnitFilter, UnitFilterResponse>()
|
||||
.ForMember(d => d.UnitFilterMask, o => o.MapFrom(s => s.UnitFilter))
|
||||
@@ -363,13 +381,17 @@ 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>()
|
||||
.ForMember(d => d.GroupType, o => o.MapFrom(s => s.GroupType))
|
||||
.ForMember(d => d.GroupingUnitField, o => o.MapFrom(s => s.GroupingUnitField))
|
||||
.ForMember(d => d.IsWorkGroupTimezone, o => o.MapFrom(s => s.IsResponseAreaTimezone))
|
||||
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl));
|
||||
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl))
|
||||
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t => t.UnitFilter)));
|
||||
|
||||
CreateMap<JobGroup, JobGroupBaseResponse>()
|
||||
.ForMember(d => d.Name, o => o.MapFrom(s => s.GroupName))
|
||||
@@ -383,6 +405,21 @@ namespace PARR.API.MappingProfiles
|
||||
.ForMember(d => d.ScheduleExcludeTypeCalendar, o => o.MapFrom(s => s.ScheduleExcludeTypeCalendar));
|
||||
//.ForMember(d => d.DistributionConfig, o => o.MapFrom(s => s.DistributionConfig));
|
||||
|
||||
//------- unit filters
|
||||
CreateMap<JobGroupUnitFilter, UnitFilterResponse>()
|
||||
.ForMember(d => d.UnitFilterMask, o => o.MapFrom(s => s.UnitFilter))
|
||||
.ForMember(d => d.FieldFilters, o => o.MapFrom(s => s.FieldFilters.OrderBy(o => o.UnitField!.AihitName)))
|
||||
.ForMember(d => d.RelationshipFilters, o => o.MapFrom(s => s.RelationshipFilters.OrderBy(o => o.UnitField!.AihitName)));
|
||||
|
||||
CreateMap<JobGroupFieldFilter, FieldResponse>();
|
||||
|
||||
CreateMap<JobGroupFieldFilter, FieldFilterResponse>()
|
||||
.ForMember(d => d.Field, o => o.MapFrom(s => s.UnitField));
|
||||
|
||||
CreateMap<JobGroupRelationshipFilter, RelationshipFilterResponse>()
|
||||
.ForMember(d => d.Field, o => o.MapFrom(s => s.UnitField));
|
||||
//---------
|
||||
|
||||
CreateMap<JobGroupAutoControl, JobGroupAutoControlResponse>();
|
||||
|
||||
#endregion
|
||||
@@ -500,6 +537,41 @@ namespace PARR.API.MappingProfiles
|
||||
|
||||
#endregion
|
||||
|
||||
#region StatRobotMetrics
|
||||
|
||||
CreateMap<TaskStatusChartPoint, StatTaskStatusChartPoint>();
|
||||
CreateMap<RobotStatusChartPoint, StatRobotStatusChartPoint>();
|
||||
CreateMap<FilteredChartPoint, StatFilteredChartPoint>();
|
||||
|
||||
#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
|
||||
|
||||
#region HelpDocumentResponse
|
||||
|
||||
CreateMap<HelpDocumentResult, HelpDocumentResponse>();
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using PARR.API.Contracts.V1.Requests;
|
||||
using PARR.API.Contracts.V1.Requests.Queries;
|
||||
using PARR.Domain.Common.Pagination;
|
||||
using PARR.Domain.DTOs.RobotMetrics;
|
||||
using PARR.Domain.DTOs.RobotSnapshotDTO;
|
||||
using PARR.Domain.Entities.JobEntities;
|
||||
|
||||
@@ -52,6 +53,9 @@ namespace PARR.API.MappingProfiles
|
||||
#endregion
|
||||
|
||||
CreateMap<StatRobotSnapshotQuery, RobotSnapshotQuery>();
|
||||
|
||||
|
||||
CreateMap<RobotFilteredMetricsQuery, MetricFilter>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace PARR.API.Validators
|
||||
IUnitFieldRepository unitFieldRepository,
|
||||
IScheduleExcludeTypeRepository scheduleExcludeTypeRepository,
|
||||
IScheduleExcludeTypeCalendarRepository scheduleExcludeTypeCalendarRepository,
|
||||
IDistributionPeriodRepository distributionPeriodRepository
|
||||
IDistributionPeriodRepository distributionPeriodRepository,
|
||||
UnitFiltersBaseRequestValidator unitFilterValidator
|
||||
)
|
||||
{
|
||||
RuleFor(t => t.Name)
|
||||
@@ -38,6 +39,9 @@ namespace PARR.API.Validators
|
||||
.MustAsync(async (entity, value, c) => await jobGroupTypeRepository.GetAsync(value) != null)
|
||||
.WithMessage("Указан несуществующий Id типа");
|
||||
|
||||
// Валидация фильтров (они могут быть пустые, мы так договорились)
|
||||
Include(unitFilterValidator);
|
||||
|
||||
//Проверяем существует ли такой GroupingUnitFieldId в unitField
|
||||
RuleFor(t => t.GroupingUnitFieldId)
|
||||
.MustAsync(async (entity, value, c) =>
|
||||
@@ -150,6 +154,8 @@ namespace PARR.API.Validators
|
||||
})
|
||||
.WithMessage("Некорректные параметры автоконтроля");
|
||||
|
||||
RuleFor(t => t.EkMask)
|
||||
.NotNull().NotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ namespace PARR.API.Validators
|
||||
private readonly ITnkRepository _tnkRepository;
|
||||
private readonly IJobGroupRepository _jobGroupRepository;
|
||||
private readonly IUnitFieldRepository _unitFieldRepository;
|
||||
//private JobGroup? jobGroup;
|
||||
|
||||
public JobRequestValidator(
|
||||
ITnkRepository tnkRepository,
|
||||
IJobGroupRepository jobGroupRepository,
|
||||
IUnitFieldRepository unitFieldRepository
|
||||
IUnitFieldRepository unitFieldRepository,
|
||||
UnitFiltersBaseRequestValidator unitFilterValidator
|
||||
)
|
||||
{
|
||||
_tnkRepository = tnkRepository;
|
||||
@@ -38,12 +38,28 @@ namespace PARR.API.Validators
|
||||
.MustAsync(async (entity, value, c) => await IsGroupExist(entity))
|
||||
.WithMessage("Указан несуществующий Id группы работ({PropertyValue})");
|
||||
|
||||
RuleForEach(t => t.UnitFilters)
|
||||
.NotNull().NotEmpty();
|
||||
#region UnitFiters
|
||||
|
||||
When(t => t.UnitFilters != null && t.UnitFilters.Any(), () =>
|
||||
{
|
||||
RuleFor(t => t.UnitFilters)
|
||||
.MustAsync(async (entity, value, c) =>
|
||||
{
|
||||
// Проверяем флаг в БД: разрешены ли фильтры для этой группы
|
||||
return await jobGroupRepository.Get()
|
||||
.AnyAsync(t => t.Id == entity.GroupId && t.GroupType!.IsAllowJobUnitFilter == true, c);
|
||||
})
|
||||
.WithMessage("Дополнительные фильтры запрещены")
|
||||
// Stop делает так, что если флаг равен false, FluentValidation остановится
|
||||
// и НЕ пойдет выполнять Include базового валидатора (сэкономим запрос к БД)
|
||||
.DependentRules(() =>
|
||||
{
|
||||
// Этот валидатор сработает ПОСЛЕ успешной проверки флага IsAllowJobUnitFilter
|
||||
Include(unitFilterValidator);
|
||||
});
|
||||
});
|
||||
|
||||
RuleForEach(t => t.UnitFilters)
|
||||
.MustAsync(async (entity, value, c) => await IsUnitFiltersCorrect(entity))
|
||||
.WithMessage("Неверно заданы параметры фильтров. Внимательнее, пожалуйста!");
|
||||
#endregion
|
||||
|
||||
RuleFor(t => t.Relationships)
|
||||
.MustAsync(async (entity, value, c) =>
|
||||
@@ -95,35 +111,6 @@ namespace PARR.API.Validators
|
||||
.WithMessage("Некорректные параметры автоконтроля");
|
||||
}
|
||||
|
||||
private async Task<bool> IsUnitFiltersCorrect(JobRequest entity)
|
||||
{
|
||||
foreach (var unitFilter in entity.UnitFilters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(unitFilter.UnitFilterMask))
|
||||
return false;
|
||||
|
||||
//if (unitFilter.FieldFilters == null || !unitFilter.FieldFilters.Any())
|
||||
// return false;
|
||||
|
||||
if (unitFilter.FieldFilters != null && unitFilter.FieldFilters.Any())
|
||||
foreach (var fieldFilter in unitFilter.FieldFilters)
|
||||
{
|
||||
var fieldId = fieldFilter.FieldId;
|
||||
if (await _unitFieldRepository.GetAsync(fieldId) == null)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unitFilter.RelationshipFilters != null && unitFilter.RelationshipFilters.Any())
|
||||
foreach (var relationshipFilter in unitFilter.RelationshipFilters!)
|
||||
{
|
||||
var fieldId = relationshipFilter.FieldId;
|
||||
if (await _unitFieldRepository.GetAsync(fieldId) == null)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> IsGroupExist(JobRequest entity)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,11 @@ namespace PARR.API.Validators
|
||||
|
||||
//RuleFor(t => t.EsppId).NotEmpty().NotNull().WithMessage("EsppId не может быть пустым");
|
||||
|
||||
RuleFor(t => t.DurationMinutes)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(t => t.DurationMinutes.HasValue)
|
||||
.WithMessage("Значение не может быть отрицательным");
|
||||
|
||||
RuleFor(t => t.SubprocessId).NotEmpty().NotNull();
|
||||
|
||||
RuleFor(t => t.SubprocessId)
|
||||
|
||||
85
PARR.API/Validators/UnitFiltersBaseRequestValidator.cs
Normal file
85
PARR.API/Validators/UnitFiltersBaseRequestValidator.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PARR.API.Contracts.V1.Requests;
|
||||
using PARR.Core.Repositories.Interfaces.Unit;
|
||||
|
||||
namespace PARR.API.Validators
|
||||
{
|
||||
/// <summary>
|
||||
/// Валидация фильтроп вдя JobGroup, Job
|
||||
/// </summary>
|
||||
public class UnitFiltersBaseRequestValidator: AbstractValidator<UnitFiltersBaseRequest>
|
||||
{
|
||||
private readonly IUnitFieldRepository _unitFieldRepository;
|
||||
|
||||
public UnitFiltersBaseRequestValidator(IUnitFieldRepository unitFieldRepository)
|
||||
{
|
||||
_unitFieldRepository = unitFieldRepository;
|
||||
|
||||
// Это правило будет применяться ко ВСЕМ, кто наследует UnitFiltersBaseRequest
|
||||
RuleForEach(t => t.UnitFilters)
|
||||
.MustAsync(async (entity, value, c) => await IsUnitFiltersCorrect(entity))
|
||||
.WithMessage("Неверно заданы параметры фильтров.");
|
||||
}
|
||||
|
||||
private async Task<bool> IsUnitFiltersCorrect(UnitFiltersBaseRequest entity)
|
||||
{
|
||||
// 1. Проверим маски, они не должны быть пустыми
|
||||
foreach (var unitFilter in entity.UnitFilters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(unitFilter.UnitFilterMask))
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Собираем все уникальные FieldId из всех фильтров в памяти
|
||||
var requestedFieldIds = entity.UnitFilters
|
||||
.SelectMany(uf =>
|
||||
(uf.FieldFilters ?? Enumerable.Empty<FieldFilterRequest>()).Select(ff => ff.FieldId)
|
||||
.Concat((uf.RelationshipFilters ?? Enumerable.Empty<RelationshipFilterRequest>()).Select(rf => rf.FieldId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Если фильтров с ID нет, то и проверять в БД нечего
|
||||
if (!requestedFieldIds.Any())
|
||||
return true;
|
||||
|
||||
// 3. Выполняем ОДИН запрос к БД.
|
||||
var existingFieldIds = await _unitFieldRepository.Get()
|
||||
.Where(field => requestedFieldIds.Contains(field.Id))
|
||||
.Select(field => field.Id)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
// 4. Если количество найденных ID в БД меньше, чем мы запрашивали,
|
||||
// значит какого-то fieldId не существует
|
||||
return requestedFieldIds.Count == existingFieldIds.Count;
|
||||
|
||||
#region Old
|
||||
//foreach (var unitFilter in entity.UnitFilters)
|
||||
//{
|
||||
// if (string.IsNullOrEmpty(unitFilter.UnitFilterMask))
|
||||
// return false;
|
||||
|
||||
// if (unitFilter.FieldFilters != null && unitFilter.FieldFilters.Any())
|
||||
// foreach (var fieldFilter in unitFilter.FieldFilters)
|
||||
// {
|
||||
// var fieldId = fieldFilter.FieldId;
|
||||
// if (await _unitFieldRepository.GetAsync(fieldId) == null)
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// if (unitFilter.RelationshipFilters != null && unitFilter.RelationshipFilters.Any())
|
||||
// foreach (var relationshipFilter in unitFilter.RelationshipFilters!)
|
||||
// {
|
||||
// var fieldId = relationshipFilter.FieldId;
|
||||
// if (await _unitFieldRepository.GetAsync(fieldId) == null)
|
||||
// return false;
|
||||
// }
|
||||
//}
|
||||
|
||||
//return true;
|
||||
#endregion
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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,10 +113,22 @@
|
||||
}
|
||||
],
|
||||
"RabbitMq": {
|
||||
"ThresholdConnections": 33
|
||||
"ThresholdConnections": 32
|
||||
}
|
||||
},
|
||||
"CommonSettings": {
|
||||
"ScheduleCooldownDuration": "03:00:00"
|
||||
},
|
||||
"HelpDocumentSettings": {
|
||||
"GitLabSettings": {
|
||||
"BaseUrl": "http://gitlab.dvgd.oao.rzd/api/v4/",
|
||||
"ProjectId": "1343",
|
||||
"Token": "glpat-2PBp1c9hryQHUJnoZn3S",
|
||||
"DefaultBranch": "main",
|
||||
"RepositoryPath": "http://gitlab.dvgd.oao.rzd/devptk/parr/parr_help_docs"
|
||||
},
|
||||
"CacheTTL": "23:59:00",
|
||||
"ShortTTL": "00:10:00",
|
||||
"WebhookToken": "ParrSecret2026"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ public class JobGroupCloneHelper
|
||||
ScheduleExcludeTypeCalendarId = source.ScheduleExcludeTypeCalendarId,
|
||||
DateCreated = DateTimeOffset.UtcNow,
|
||||
Jobs = new HashSet<Job>(),
|
||||
EsppSchValues = new HashSet<EsppSchValue>()
|
||||
EsppSchValues = new HashSet<EsppSchValue>(),
|
||||
EkMask = source.EkMask
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,21 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using PARR.Core.Common.Helpers;
|
||||
using PARR.Core.Common.Implementations;
|
||||
using PARR.Core.Common.Interfaces;
|
||||
using PARR.Core.Services.HelpDocument.Implementations;
|
||||
using PARR.Core.Services.HelpDocument.Interfaces;
|
||||
using PARR.Core.Services.MatchingStatusService;
|
||||
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;
|
||||
@@ -28,7 +37,6 @@ using PARR.Core.Services.UnitService.Implementations;
|
||||
using PARR.Core.Services.UnitService.Interfaces;
|
||||
using PARR.Core.Services.Workload.Implementations;
|
||||
using PARR.Core.Services.Workload.Interfaces;
|
||||
using PARR.Domain.Entities.RobotEntities;
|
||||
using PARR.Domain.Enums;
|
||||
using PARR.Domain.Settings;
|
||||
|
||||
@@ -101,6 +109,8 @@ namespace PARR.Core
|
||||
services.AddScoped<IShortcodeHandler, FieldShortcodeHandler>();
|
||||
services.AddScoped<IShortcodeHandler, TagShortcodeHandler>();
|
||||
services.AddScoped<IShortcodeHandler, RelatedUnitTagShortcodeHandler>();
|
||||
services.AddScoped<IShortcodeHandler, ParentUnitByNameShortcodeHandler>();
|
||||
services.AddScoped<IShortcodeHandler, ParentUnitByFieldValueShortcodeHandler>();
|
||||
|
||||
services.AddScoped<IShortcodesService, ShortcodesService>();
|
||||
#endregion
|
||||
@@ -111,10 +121,15 @@ 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>();
|
||||
|
||||
services.AddScoped<IRobotMetricsService, RobotMetricsService>();
|
||||
|
||||
//services.AddScoped<IUserService, UserService>();
|
||||
|
||||
#endregion
|
||||
@@ -163,6 +178,16 @@ namespace PARR.Core
|
||||
|
||||
#endregion
|
||||
|
||||
#region
|
||||
|
||||
// Добавляем дефолтные настройки, при необходимости переопределить в воркерах (API)
|
||||
services.TryAddSingleton<HelpDocumentSettings>();
|
||||
|
||||
services.AddHttpClient<IHelpDocumentProvider, GitLabHelpProvider>();
|
||||
services.AddScoped<IHelpDocumentService, HelpDocumentService>();
|
||||
|
||||
#endregion
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
10
PARR.Core/Extensions/EnumerableExtensions.cs
Normal file
10
PARR.Core/Extensions/EnumerableExtensions.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace PARR.Core.Extensions
|
||||
{
|
||||
public static class EnumerableExtensions
|
||||
{
|
||||
public static int MaxOrDefault(this IEnumerable<int> source)
|
||||
{
|
||||
return source.Any() ? source.Max() : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ namespace PARR.Core.Infrastructure.Mapping.RobotTask
|
||||
//.ForMember(d => d.Initiator, o => o.MapFrom<RobotTaskTemplateInitiatorResolver>())
|
||||
//.ForMember(d => d.Category, o => o.MapFrom<RobotTaskTemplateCategoryResolver>())
|
||||
.ForMember(d => d.WorkGroup, o => o.MapFrom(s => s.Template!.Job!.WorkGroupMask))
|
||||
.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Unit!.Name))
|
||||
//.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Unit!.Name))
|
||||
// Берем ЭК на у юнита, а в группе работ, маску ЭК (шорткод)
|
||||
.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Job!.Group!.EkMask))
|
||||
.ForMember(d => d.Name, o => o.MapFrom(s => s.Template!.Name))
|
||||
.ForMember(d => d.ProcessName, o => o.MapFrom(s => s.Template!.Job!.Tnk!.Subprocess!.Process!.Name))
|
||||
.ForMember(d => d.ProcessEsppId, o => o.MapFrom(s => s.Template!.Job!.Tnk!.Subprocess!.Process!.EsppId))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.16" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.16" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.RobotEntities;
|
||||
|
||||
namespace PARR.Core.Repositories.Interfaces
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using PARR.Core.Repositories.Base;
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.RobotEntities;
|
||||
|
||||
namespace PARR.Core.Repositories.Interfaces
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.RobotEntities;
|
||||
|
||||
namespace PARR.Core.Repositories.Interfaces
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PARR.Domain.Entities;
|
||||
using PARR.Domain.Entities.RobotEntities;
|
||||
|
||||
namespace PARR.Core.Repositories.Interfaces
|
||||
{
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public interface IStatusTemplateRepository
|
||||
{
|
||||
IQueryable<Domain.Entities.TaskStatus> Get();
|
||||
IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public interface ITaskStatusRepository
|
||||
{
|
||||
IQueryable<Domain.Entities.TaskStatus> Get();
|
||||
IQueryable<Domain.Entities.RobotEntities.TaskStatus> Get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Services.HelpDocument.Interfaces;
|
||||
using PARR.Domain.DTOs.HelpDocument;
|
||||
using PARR.Domain.Settings;
|
||||
using System.Web;
|
||||
|
||||
namespace PARR.Core.Services.HelpDocument.Implementations
|
||||
{
|
||||
/// <summary>
|
||||
/// Получение справочной документации из GitLab
|
||||
/// </summary>
|
||||
internal class GitLabHelpProvider : IHelpDocumentProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HelpDocumentSettings _helpDocumentSettings;
|
||||
private readonly ILogger<GitLabHelpProvider> _logger;
|
||||
|
||||
public GitLabHelpProvider(HttpClient httpClient, HelpDocumentSettings helpDocumentSettings, ILogger<GitLabHelpProvider> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_helpDocumentSettings = helpDocumentSettings;
|
||||
_logger = logger;
|
||||
|
||||
// Базовая настройка http
|
||||
_httpClient = httpClient;
|
||||
|
||||
if (_helpDocumentSettings.GitLabSettings == null)
|
||||
{
|
||||
_logger.LogCritical("Инициализация провайдера справки провалена: не задана секция GitLabSettings");
|
||||
throw new ArgumentNullException(nameof(_helpDocumentSettings.GitLabSettings), "Не заданы настройки подключения к GitLab");
|
||||
}
|
||||
|
||||
_httpClient.BaseAddress = new Uri(_helpDocumentSettings.GitLabSettings.BaseUrl);
|
||||
_httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", _helpDocumentSettings.GitLabSettings.Token);
|
||||
}
|
||||
|
||||
|
||||
public async Task<HelpDocumentResult?> GetDocumentAsync(string helpId)
|
||||
{
|
||||
//1. Формируем путь к файлу
|
||||
var filePath = $"{helpId}.md";
|
||||
|
||||
// 2. GitLab API требует, чтобы относительный путь к файлу в URL был закодирован (экранирован)
|
||||
var urlEncodedPath = HttpUtility.UrlEncode(filePath);
|
||||
|
||||
// 3. Собираем эндпоинт для получения RAW контента файла
|
||||
string relativeRequestUrl = $"projects/{_helpDocumentSettings.GitLabSettings!.ProjectId}/repository/files/{urlEncodedPath}/raw?ref={_helpDocumentSettings.GitLabSettings.DefaultBranch}";
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// Создаем URI строго с флагом UriKind.Relative. Это заставит HttpClient отправить %2F в сыром виде!
|
||||
var relativeUri = new Uri(relativeRequestUrl, UriKind.Relative);
|
||||
|
||||
_logger.LogDebug("Отправка HTTP GET запроса к GitLab API. Относительный URI: {RelativeUri}", relativeRequestUrl);
|
||||
|
||||
var response = await _httpClient.GetAsync(relativeUri);
|
||||
|
||||
// Если файла нет (404) или токен не подошел (401/403)
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Документ справки не найден или недоступен в GitLab. HelpId: {HelpId}, StatusCode: {StatusCode}",
|
||||
helpId, response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Файл справки успешно получен из GitLab. Размер ответа: {ContentLength} байт",
|
||||
response.Content.Headers.ContentLength);
|
||||
|
||||
// Получаем весь текст файла (и шапку, и контент)
|
||||
string rawFileContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// 4. Парсим файл
|
||||
var result = ParseMarkdownFile(helpId, rawFileContent);
|
||||
|
||||
_logger.LogDebug("Документ справки успешно загружен и распарсен. HelpId: {HelpId}, Title: '{Title}'",
|
||||
helpId, result.Title);
|
||||
|
||||
var editUrl = GenerateEditUrl(helpId);
|
||||
|
||||
return result with
|
||||
{
|
||||
EditUrl = editUrl
|
||||
};
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Ошибка при запросе справки {HelpId} из GitLab", helpId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GenerateEditUrl(string helpId)
|
||||
{
|
||||
if (_helpDocumentSettings.GitLabSettings == null)
|
||||
return null;
|
||||
|
||||
var repoPath = _helpDocumentSettings.GitLabSettings.RepositoryPath;
|
||||
var branch = _helpDocumentSettings.GitLabSettings.DefaultBranch ?? "main";
|
||||
|
||||
return $"{repoPath}/-/edit/{branch}/{helpId}.md";
|
||||
}
|
||||
|
||||
private HelpDocumentResult ParseMarkdownFile(string helpId, string rawContent)
|
||||
{
|
||||
_logger.LogDebug("Начало парсинга структуры Markdown для HelpId: {HelpId}", helpId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawContent))
|
||||
{
|
||||
_logger.LogWarning("Получен пустой контент для HelpId: {HelpId}", helpId);
|
||||
return new HelpDocumentResult { HelpId = helpId };
|
||||
}
|
||||
|
||||
// Стандартное разделение строк с поддержкой разных типов окончаний (Windows/Linux)
|
||||
var lines = rawContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
|
||||
|
||||
var title = string.Empty;
|
||||
string? prevId = null;
|
||||
string? nextId = null;
|
||||
|
||||
var contentStartIndex = 0;
|
||||
var isInsideFrontMatter = false;
|
||||
var frontMatterClosed = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
// Ищем маркеры начала и конца метаданных "---"
|
||||
if (line == "---")
|
||||
{
|
||||
if (!isInsideFrontMatter && !frontMatterClosed)
|
||||
{
|
||||
isInsideFrontMatter = true;
|
||||
_logger.LogDebug("Обнаружено начало блока метаданных (Front Matter) на строке {LineIndex}", i);
|
||||
continue;
|
||||
}
|
||||
else if (isInsideFrontMatter)
|
||||
{
|
||||
isInsideFrontMatter = false;
|
||||
frontMatterClosed = true;
|
||||
contentStartIndex = i + 1; // Контент начинается строго со следующей строки
|
||||
_logger.LogDebug("Обнаружен конец блока метаданных на строке {LineIndex}", i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Читаем свойства внутри блока метаданных
|
||||
if (isInsideFrontMatter)
|
||||
{
|
||||
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
title = line.Substring("title:".Length).Trim();
|
||||
_logger.LogDebug("В метаданных найден Title: '{Title}'", title);
|
||||
}
|
||||
// Парсим Предыдущий шаг
|
||||
else if (line.StartsWith("prev_id:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prevId = line.Substring("prev_id:".Length).Trim();
|
||||
_logger.LogDebug("В метаданных найден PrevId: '{PrevId}'", prevId);
|
||||
}
|
||||
// Парсим Следующий шаг
|
||||
else if (line.StartsWith("next_id:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
nextId = line.Substring("next_id:".Length).Trim();
|
||||
_logger.LogDebug("В метаданных найден NextId: '{NextId}'", nextId);
|
||||
}
|
||||
|
||||
// Тут в будущем можно парсить другие поля, например: icon, min_role и т.д.
|
||||
}
|
||||
}
|
||||
|
||||
string cleanContent;
|
||||
|
||||
if (frontMatterClosed)
|
||||
{
|
||||
// Собираем обратно чистый контент, исключая строки с шапкой
|
||||
cleanContent = string.Join(Environment.NewLine, lines.Skip(contentStartIndex));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Если шапки "---" вообще не было в файле, берем весь текст как есть
|
||||
_logger.LogDebug("Блок метаданных '---' не найден в файле. Весь текст перенесен в Content.");
|
||||
cleanContent = rawContent;
|
||||
title = Path.GetFileNameWithoutExtension(helpId); // Дефолтный заголовок из имени файла
|
||||
}
|
||||
|
||||
return new HelpDocumentResult
|
||||
{
|
||||
HelpId = helpId,
|
||||
Title = title,
|
||||
Content = cleanContent,
|
||||
PrevId = prevId,
|
||||
NextId = nextId
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Common.Interfaces;
|
||||
using PARR.Core.Services.HelpDocument.Interfaces;
|
||||
using PARR.Domain.Cache;
|
||||
using PARR.Domain.DTOs.HelpDocument;
|
||||
using PARR.Domain.Settings;
|
||||
|
||||
namespace PARR.Core.Services.HelpDocument.Implementations
|
||||
{
|
||||
internal class HelpDocumentService : IHelpDocumentService
|
||||
{
|
||||
private readonly IHelpDocumentProvider _helpProvider;
|
||||
private readonly IRedisCacheService _redisCacheService;
|
||||
private readonly HelpDocumentSettings _helpDocumentSettings;
|
||||
private readonly ILogger<HelpDocumentService> _logger;
|
||||
|
||||
public HelpDocumentService(
|
||||
IHelpDocumentProvider helpProvider,
|
||||
IRedisCacheService redisCacheService,
|
||||
HelpDocumentSettings helpDocumentSettings,
|
||||
ILogger<HelpDocumentService> logger
|
||||
)
|
||||
{
|
||||
_helpProvider = helpProvider;
|
||||
_redisCacheService = redisCacheService;
|
||||
_helpDocumentSettings = helpDocumentSettings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
public async Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(helpId))
|
||||
{
|
||||
_logger.LogWarning("Значение helpId: '{helpId}' пустое.", helpId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
|
||||
|
||||
_logger.LogDebug("Запрос справки. Проверка наличия в Redis по ключу: {CacheKey}", cacheKey);
|
||||
|
||||
// 1. Попытка получить данные из Redis
|
||||
var cachedData = await _redisCacheService.GetCachedDataAsync<HelpDocumentResult?>(cacheKey, useCompression: true);
|
||||
|
||||
if (cachedData != null)
|
||||
{
|
||||
_logger.LogInformation("Документ справки успешно извлечен из кэша Redis. HelpId: {HelpId}", helpId);
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Кэш пуст или недоступен. Запрос документа напрямую через провайдер для HelpId: {HelpId}", helpId);
|
||||
|
||||
// 2. Если в Redis пусто, идем в GitLab через наш провайдер
|
||||
var remoteDocument = await _helpProvider.GetDocumentAsync(helpId);
|
||||
|
||||
if (remoteDocument == null)
|
||||
{
|
||||
_logger.LogWarning("Документ не найден ни в Redis, ни в GitLab. HelpId: {HelpId}", helpId);
|
||||
|
||||
|
||||
// Создаем понятный для пользователя текст-заглушку если нет документации
|
||||
remoteDocument = new HelpDocumentResult
|
||||
{
|
||||
HelpId = helpId,
|
||||
Title = "Раздел в разработке",
|
||||
Content = "### 📚 Отсутствует документация по данному разделу.\n\n" +
|
||||
"Если у вас возникли срочные вопросы, свяжитесь с теми, кто знает на них ответы :)."
|
||||
};
|
||||
|
||||
// Кэшируем заглушку на короткий срок (например, на 5-10 минут), а не на 24 часа.
|
||||
// Это нужно, чтобы как только аналитик создаст файл и прилетит хук, кэш обновился,
|
||||
// но при этом частые клики пользователей по сломанному вопросику не спамили GitLab запросами 404.
|
||||
await _redisCacheService.SetCachedDataAsync(cacheKey, remoteDocument, _helpDocumentSettings.ShortTTL, useCompression: true);
|
||||
|
||||
return remoteDocument;
|
||||
}
|
||||
|
||||
// 3. Сохраняем полученный документ в Redis для последующих запросов
|
||||
await _redisCacheService.SetCachedDataAsync(cacheKey, remoteDocument, _helpDocumentSettings.CacheTTL, useCompression: true);
|
||||
|
||||
_logger.LogDebug("Вернул документацию из провайдера для HelpId: {HelpId}", helpId);
|
||||
|
||||
return remoteDocument;
|
||||
}
|
||||
|
||||
|
||||
public async Task ClearCacheAsync(string helpId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(helpId))
|
||||
{
|
||||
_logger.LogWarning("Попытка очистки кэша: передан пустой helpId.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
|
||||
|
||||
_logger.LogInformation("Удаление документа справки из кэша Redis по вебхуку. Ключ: {CacheKey}", cacheKey);
|
||||
|
||||
await _redisCacheService.DeleteCachedDataAsync(cacheKey);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using PARR.Domain.DTOs.HelpDocument;
|
||||
|
||||
namespace PARR.Core.Services.HelpDocument.Interfaces
|
||||
{
|
||||
internal interface IHelpDocumentProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Запрашивает сырой файл .md из источника (например gitlab)
|
||||
/// </summary>
|
||||
/// <param name="helpId"></param>
|
||||
/// <returns></returns>
|
||||
Task<HelpDocumentResult?> GetDocumentAsync(string helpId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using PARR.Domain.DTOs.HelpDocument;
|
||||
|
||||
namespace PARR.Core.Services.HelpDocument.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Сервис загрузки справочной документации
|
||||
/// </summary>
|
||||
public interface IHelpDocumentService
|
||||
{
|
||||
/// <summary>
|
||||
/// Получить документ справки по его уникальному идентификатору (с учетом кэширования в Redis)
|
||||
/// </summary>
|
||||
/// <param name="helpId">Уникальный путь справке</param>
|
||||
/// <returns></returns>
|
||||
Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId);
|
||||
|
||||
/// <summary>
|
||||
/// Сбрасывает кэш в Redis для конкретного документа справки (вызывается вебхуком)
|
||||
/// </summary>
|
||||
/// <param name="helpId"></param>
|
||||
/// <returns></returns>
|
||||
Task ClearCacheAsync(string helpId);
|
||||
}
|
||||
}
|
||||
37
PARR.Core/Services/RobotMetrics/IRobotMetricsService.cs
Normal file
37
PARR.Core/Services/RobotMetrics/IRobotMetricsService.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using PARR.Domain.DTOs.RobotMetrics;
|
||||
using PARR.Domain.Enums;
|
||||
|
||||
namespace PARR.Core.Services.RobotMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Отчетность по метрикам заданий и работы роботов.
|
||||
/// </summary>
|
||||
public interface IRobotMetricsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Статистика по Заданиям Роботу
|
||||
/// </summary>
|
||||
/// <param name="robotCode"></param>
|
||||
/// <param name="period"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<RobotStatusChartPoint>> GetRobotStatusMetricsAsync(RobotsEnum robotCode, ChartPeriod period, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Статистика по Статусам Заданий
|
||||
/// </summary>
|
||||
/// <param name="robotCode"></param>
|
||||
/// <param name="period"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<TaskStatusChartPoint>> GetTaskStatusMetricsAsync(RobotsEnum robotCode, ChartPeriod period, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Статистика с применением гибких фильтров
|
||||
/// </summary>
|
||||
/// <param name="filter"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<FilteredChartPoint>> GetFilteredMetricsAsync(MetricFilter filter, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
260
PARR.Core/Services/RobotMetrics/RobotMetricsService.cs
Normal file
260
PARR.Core/Services/RobotMetrics/RobotMetricsService.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Extensions;
|
||||
using PARR.Core.Repositories.Interfaces;
|
||||
using PARR.Core.Repositories.Interfaces.RobotRepositories;
|
||||
using PARR.Domain.DTOs.RobotMetrics;
|
||||
using PARR.Domain.Enums;
|
||||
using PARR.Domain.Exceptions;
|
||||
|
||||
namespace PARR.Core.Services.RobotMetrics
|
||||
{
|
||||
internal class RobotMetricsService : IRobotMetricsService
|
||||
{
|
||||
private readonly IRobotConfigurationSnapshotRepository _snapshotRepository;
|
||||
private readonly IRobotConfigurationRepository _configurationRepository;
|
||||
private readonly ILogger<RobotMetricsService> _logger;
|
||||
|
||||
public RobotMetricsService(
|
||||
IRobotConfigurationSnapshotRepository snapshotRepository,
|
||||
IRobotConfigurationRepository configurationRepository,
|
||||
ILogger<RobotMetricsService> logger
|
||||
)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_configurationRepository = configurationRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<FilteredChartPoint>> GetFilteredMetricsAsync(MetricFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
// ------- Правильность расчетов этого метода доконца не проверена -------
|
||||
|
||||
if (filter == null)
|
||||
throw new AppValidationException("Фильтр не может быть пустым.");
|
||||
|
||||
if (filter.IntervalMinutes < 1)
|
||||
throw new AppValidationException("Интервал группировки не может быть меньше 1 минуты.");
|
||||
|
||||
var query = _snapshotRepository.Get().AsNoTracking();
|
||||
|
||||
if (filter.RobotCode.HasValue)
|
||||
query = query.Where(t => t.RobotCode == (int)filter.RobotCode.Value);
|
||||
|
||||
if (filter.RobotStatusCode.HasValue)
|
||||
query = query.Where(t => t.RobotStatusCode == (int)filter.RobotStatusCode.Value);
|
||||
|
||||
if (filter.TaskStatusCode.HasValue)
|
||||
query = query.Where(t => t.TaskStatusCode == (int)filter.TaskStatusCode);
|
||||
|
||||
// Если даты не переданы, берем последние 24 часа по умолчанию
|
||||
var dateFrom = filter.DateFrom ?? DateTimeOffset.UtcNow.AddDays(-1);
|
||||
query = query.Where(s => s.DateCreated >= dateFrom);
|
||||
|
||||
if (filter.DateTo.HasValue)
|
||||
query = query.Where(s => s.DateCreated <= filter.DateTo.Value);
|
||||
|
||||
var dbData = await query.ToListAsync(cancellationToken);
|
||||
|
||||
var dbGrouped = dbData
|
||||
.GroupBy(s => RoundToInterval(s.DateCreated, filter.IntervalMinutes))
|
||||
.ToDictionary(t => t.Key, t => t.ToList());
|
||||
|
||||
var dateTo = filter.DateTo ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var result = GenerateTimeGrid(dateFrom, dateTo, filter.IntervalMinutes)
|
||||
.Select(time => new FilteredChartPoint(
|
||||
Timestamp: time,
|
||||
//Count: dbGrouped.TryGetValue(time, out var points) ? points.Max(x => x.Count) : 0
|
||||
Count: dbGrouped.TryGetValue(time, out var points)
|
||||
? points.GroupBy(x => x.DateCreated) // Группируем по точной минуте снапшота
|
||||
.Select(g => g.Sum(x => x.Count)) // Складываем всё, что подошли под фильтр в эту минуту
|
||||
.MaxOrDefault() // Берем максимальный пик за весь интервал (например, за час)
|
||||
: 0 // Если снапшотов не было — честный ноль
|
||||
)).ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<RobotStatusChartPoint>> GetRobotStatusMetricsAsync(RobotsEnum robotCode, ChartPeriod period, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRobot(robotCode);
|
||||
CalculatePeriodDates(period, out var fromDate, out var intervalMinutes);
|
||||
|
||||
// История из снапшотов
|
||||
var snapshots = await _snapshotRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Where(s => s.RobotCode == (int)robotCode && s.DateCreated >= fromDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var dbGrouped = snapshots
|
||||
.GroupBy(t => RoundToInterval(t.DateCreated, intervalMinutes))
|
||||
.ToDictionary(t => t.Key, t => t.ToList());
|
||||
|
||||
// Генерим сетку значений, если значений нет, вставляем нули
|
||||
var history = GenerateTimeGrid(fromDate, DateTimeOffset.UtcNow, intervalMinutes)
|
||||
.Select(time => dbGrouped.TryGetValue(time, out var points)
|
||||
? new RobotStatusChartPoint(
|
||||
Timestamp: time,
|
||||
// группируем по точной минуте снапшота, складываем внутренности, а потом ищем пик (Max) за весь интервал
|
||||
Wait: //points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Wait).MaxOrDefault(x => x.Count),
|
||||
points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Wait)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(t => t.Sum(x => x.Count))
|
||||
.MaxOrDefault(),
|
||||
InProgress: //points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.InProgress).MaxOrDefault(x => x.Count),
|
||||
points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.InProgress)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count))
|
||||
.MaxOrDefault(),
|
||||
Error: //points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Error).MaxOrDefault(x => x.Count)
|
||||
points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Error)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count)) // Честная сумма всех ошибок в рамках одной минуты снапшота
|
||||
.MaxOrDefault(),
|
||||
Complete:
|
||||
points.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Complete)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count)) // Честная сумма всех ошибок в рамках одной минуты снапшота
|
||||
.MaxOrDefault()
|
||||
)
|
||||
: new RobotStatusChartPoint(time, Wait: 0, InProgress: 0, Error: 0, Complete: 0)
|
||||
).ToList();
|
||||
|
||||
// Последнее значение в конце графика из реальной таблицы
|
||||
var liveRaw = await _configurationRepository
|
||||
.Get()
|
||||
.AsNoTracking()
|
||||
.Where(t => t.RobotCode == (int)robotCode)
|
||||
.GroupBy(t => t.RobotStatusCode)
|
||||
.Select(g => new { RobotStatusCode = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
history.Add(new RobotStatusChartPoint(
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
Wait: liveRaw.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Wait).Sum(x => x.Count),
|
||||
InProgress: liveRaw.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.InProgress).Sum(x => x.Count),
|
||||
Error: liveRaw.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Error).Sum(x => x.Count),
|
||||
Complete: liveRaw.Where(x => x.RobotStatusCode == (int)RobotStatusEnum.Complete).Sum(x => x.Count)
|
||||
));
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<TaskStatusChartPoint>> GetTaskStatusMetricsAsync(RobotsEnum robotCode, ChartPeriod period, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRobot(robotCode);
|
||||
CalculatePeriodDates(period, out var fromDate, out var intervalMinutes);
|
||||
|
||||
// История из снапшотов
|
||||
var snapshots = await _snapshotRepository.Get()
|
||||
.AsNoTracking()
|
||||
.Where(s => s.RobotCode == (int)robotCode && s.DateCreated >= fromDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var dbGrouped = snapshots
|
||||
.GroupBy(s => RoundToInterval(s.DateCreated, intervalMinutes))
|
||||
.ToDictionary(t => t.Key, t => t.ToList());
|
||||
|
||||
var history = GenerateTimeGrid(fromDate, DateTimeOffset.UtcNow, intervalMinutes)
|
||||
.Select(time => dbGrouped.TryGetValue(time, out var points)
|
||||
? new TaskStatusChartPoint(
|
||||
Timestamp: time,
|
||||
// группируем по точной минуте снапшота, складываем внутренности, а потом ищем пик (Max) за весь интервал
|
||||
Creating: //points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Creating).MaxOrDefault(x => x.Count),
|
||||
points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Creating)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count))
|
||||
.MaxOrDefault(),
|
||||
Updating: //points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Updating).MaxOrDefault(x => x.Count),
|
||||
points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Updating)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count))
|
||||
.MaxOrDefault(),
|
||||
Ok: //points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Ok).MaxOrDefault(x => x.Count)
|
||||
points.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Ok)
|
||||
.GroupBy(x => x.DateCreated)
|
||||
.Select(g => g.Sum(x => x.Count))
|
||||
.MaxOrDefault()
|
||||
)
|
||||
: new TaskStatusChartPoint(time, Creating: 0, Updating: 0, Ok: 0)
|
||||
).ToList();
|
||||
|
||||
// Живой текущий кадр в конец графика
|
||||
var liveRaw = await _configurationRepository
|
||||
.Get()
|
||||
.AsNoTracking()
|
||||
.Where(t => t.RobotCode == (int)robotCode)
|
||||
.GroupBy(t => t.TaskStatusCode)
|
||||
.Select(g => new { TaskStatusCode = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
history.Add(new TaskStatusChartPoint(
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
Creating: liveRaw.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Creating).Sum(x => x.Count),
|
||||
Updating: liveRaw.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Updating).Sum(x => x.Count),
|
||||
Ok: liveRaw.Where(x => x.TaskStatusCode == (int)TaskStatusEnum.Ok).Sum(x => x.Count)
|
||||
));
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
|
||||
private void ValidateRobot(RobotsEnum robotCode)
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(RobotsEnum), robotCode))
|
||||
throw new NotFoundException($"Робот с кодом {robotCode} не найден в системе.");
|
||||
}
|
||||
|
||||
|
||||
private void CalculatePeriodDates(ChartPeriod period, out DateTimeOffset fromDate, out int intervalMinutes)
|
||||
{
|
||||
switch (period)
|
||||
{
|
||||
case ChartPeriod.TwoHours:
|
||||
fromDate = DateTimeOffset.UtcNow.AddHours(-2);
|
||||
intervalMinutes = 2;
|
||||
break;
|
||||
case ChartPeriod.TwentyFourHours:
|
||||
fromDate = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
intervalMinutes = 30;
|
||||
break;
|
||||
case ChartPeriod.SevenDays:
|
||||
fromDate = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
intervalMinutes = 60;
|
||||
break;
|
||||
default:
|
||||
throw new AppValidationException("Указан неподдерживаемый период времени.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private DateTimeOffset RoundToInterval(DateTimeOffset dt, int intervalMinutes)
|
||||
{
|
||||
var minutes = (dt.Minute / intervalMinutes) * intervalMinutes;
|
||||
return new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, minutes, 0, dt.Offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Генератор сетки времени
|
||||
/// </summary>
|
||||
/// <param name="fromDate"></param>
|
||||
/// <param name="toDate"></param>
|
||||
/// <param name="intervalMinutes"></param>
|
||||
/// <returns></returns>
|
||||
private IEnumerable<DateTimeOffset> GenerateTimeGrid(DateTimeOffset fromDate, DateTimeOffset toDate, int intervalMinutes)
|
||||
{
|
||||
var startTime = RoundToInterval(fromDate, intervalMinutes);
|
||||
var endTime = RoundToInterval(toDate, intervalMinutes);
|
||||
|
||||
for (var time = startTime; time <= endTime; time = time.AddMinutes(intervalMinutes))
|
||||
{
|
||||
yield return time;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,20 @@ 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 { Ek = await _shortcodesService.ApplyShortcodesAsync(task.Ek, 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 +89,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 +112,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 +132,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 +155,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
|
||||
{
|
||||
// Берем первую задачу из списка доступных
|
||||
acquiredTaskId = availableTasks.First();
|
||||
logger.LogDebug("Задача не требует захвата, взята первая из доступных: {TaskId}", acquiredTaskId);
|
||||
_logger.LogDebug("Задача не требует захвата, взята первая из доступных: {TaskId}", acquiredTaskId);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,20 +175,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 +201,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 +225,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 +505,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 +525,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 +550,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 +629,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 +656,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");
|
||||
}
|
||||
|
||||
|
||||
9
PARR.Core/Services/RobotTask/Models/RobotTaskDetails.cs
Normal file
9
PARR.Core/Services/RobotTask/Models/RobotTaskDetails.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
UnitName = 16,
|
||||
UnitsInTemplate = 32,
|
||||
UnitTags = 64,
|
||||
UnitsInTemplateTags = 128
|
||||
UnitsInTemplateTags = 128,
|
||||
ParentUnits = 256
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Services.Shortcodes.Enums;
|
||||
using PARR.Core.Services.Shortcodes.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PARR.Core.Services.Shortcodes.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Шорткод %РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:АТРИБУТ1:МАСКА1&АТРИБУТ2:МАСКА2>>АТРИБУТ_РЕЗУЛЬТАТА%
|
||||
/// Возвращает значение атрибута первого ближайшего родителя, удовлетворяющего всем условиям фильтрации.
|
||||
/// При неверном синтаксисе выбрасывает InvalidOperationException.
|
||||
/// </summary>
|
||||
internal class ParentUnitByFieldValueShortcodeHandler : IShortcodeHandler
|
||||
{
|
||||
private readonly ILogger<ParentUnitByFieldValueShortcodeHandler> _logger;
|
||||
|
||||
public int Order => 56;
|
||||
|
||||
// Паттерн: захватываем все содержимое между ключевым словом и конечным %
|
||||
private static readonly Regex _pattern = new(
|
||||
@"%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:(?<payload>[^%]+)%",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public Regex Pattern => _pattern;
|
||||
|
||||
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.ParentUnits;
|
||||
|
||||
public ParentUnitByFieldValueShortcodeHandler(ILogger<ParentUnitByFieldValueShortcodeHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<string> ResolveAsync(string input, TemplateForShortcode template, string caller, CancellationToken ct = default)
|
||||
{
|
||||
var matches = Pattern.Matches(input);
|
||||
if (matches.Count == 0) return Task.FromResult(input);
|
||||
|
||||
var uniquePayloads = matches.Cast<Match>()
|
||||
.Select(m => m.Groups["payload"].Value.Trim())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var resolvedValues = new Dictionary<string, string>();
|
||||
|
||||
foreach (var payload in uniquePayloads)
|
||||
{
|
||||
// Здесь может быть выброшено исключение при неверном синтаксисе
|
||||
var result = ProcessPayload(template, payload, caller);
|
||||
resolvedValues[payload] = result;
|
||||
}
|
||||
|
||||
var output = input;
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
var payload = m.Groups["payload"].Value.Trim();
|
||||
if (resolvedValues.TryGetValue(payload, out var val))
|
||||
{
|
||||
output = output.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(output);
|
||||
}
|
||||
|
||||
private string ProcessPayload(TemplateForShortcode template, string payload, string caller)
|
||||
{
|
||||
// 1. Проверка наличия разделителя '>>'
|
||||
var separatorIndex = payload.IndexOf(">>", StringComparison.Ordinal);
|
||||
|
||||
if (separatorIndex <= 0 || separatorIndex >= payload.Length - 2)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
|
||||
$"Отсутствует или расположен неправильно разделитель '>>' в выражении: '{payload}'. " +
|
||||
$"Ожидаемый формат: АТРИБУТ:МАСКА>>АТРИБУТ_РЕЗУЛЬТАТА");
|
||||
}
|
||||
|
||||
string conditionsStr = payload.Substring(0, separatorIndex).Trim();
|
||||
string resultFieldName = payload.Substring(separatorIndex + 2).Trim();
|
||||
|
||||
// 2. Валидация имени результирующего поля
|
||||
if (string.IsNullOrEmpty(resultFieldName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
|
||||
$"Имя результирующего атрибута после '>>' не может быть пустым. Выражение: '{payload}'");
|
||||
}
|
||||
|
||||
// 3. Парсинг условий
|
||||
var conditions = ParseConditions(conditionsStr);
|
||||
|
||||
if (conditions.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
|
||||
$"Не найдено ни одного валидного условия фильтрации перед '>>'. Выражение: '{payload}'");
|
||||
}
|
||||
|
||||
// 4. Поиск подходящего родителя (Бизнес-логика: если не нашли - это НЕ ошибка синтаксиса, а отсутствие данных)
|
||||
if (template.ParentUnits == null || template.ParentUnits.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("[{Caller}] Список родителей пуст для шаблона.", caller);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
var matchingParent = template.ParentUnits.FirstOrDefault(p =>
|
||||
{
|
||||
foreach (var cond in conditions)
|
||||
{
|
||||
if (!p.FieldValues.TryGetValue(cond.AttributeName, out var currentValue))
|
||||
return false;
|
||||
|
||||
if (!MatchesMask(currentValue, cond.Mask))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (matchingParent == null)
|
||||
{
|
||||
_logger.LogDebug("[{Caller}] Не найден родитель, подходящий под условия: {Payload}", caller, payload);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
// 5. Извлечение значения
|
||||
if (matchingParent.FieldValues.TryGetValue(resultFieldName, out var finalValue))
|
||||
{
|
||||
return finalValue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("[{Caller}] Родитель найден, но атрибут '{Field}' отсутствует у него.", caller, resultFieldName);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
private List<(string AttributeName, string Mask)> ParseConditions(string raw)
|
||||
{
|
||||
var list = new List<(string, string)>();
|
||||
if (string.IsNullOrEmpty(raw)) return list;
|
||||
|
||||
var pairs = raw.Split('&', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var parts = pair.Split(':', 2);
|
||||
|
||||
// Строгая проверка формата пары "Ключ:Значение"
|
||||
if (parts.Length != 2 ||
|
||||
string.IsNullOrWhiteSpace(parts[0]) ||
|
||||
string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
// Если условие битое, считаем весь запрос синтаксически неверным
|
||||
throw new InvalidOperationException(
|
||||
$"Неверный формат условия фильтрации: '{pair}'. Ожидается 'АТРИБУТ:МАСКА'.");
|
||||
}
|
||||
|
||||
list.Add((parts[0].Trim(), parts[1].Trim()));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool MatchesMask(string value, string mask)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(mask))
|
||||
return false;
|
||||
|
||||
if (mask.Contains('*'))
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(mask).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(value, mask, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PARR.Core.Services.Shortcodes.Enums;
|
||||
using PARR.Core.Services.Shortcodes.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PARR.Core.Services.Shortcodes.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Шорткод %РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:МАСКА_ИМЕНИ:ИМЯ_АТРИБУТА%
|
||||
/// Возвращает значение атрибута первого ближайшего родителя, чье имя подходит под маску
|
||||
/// </summary>
|
||||
internal class ParentUnitByNameShortcodeHandler : IShortcodeHandler
|
||||
{
|
||||
private readonly ILogger<ParentUnitByNameShortcodeHandler> logger;
|
||||
|
||||
public int Order => 55;
|
||||
|
||||
private static readonly Regex _pattern = new(
|
||||
@"%РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:([^:]+):([^%]+)%",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public Regex Pattern => _pattern;
|
||||
|
||||
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.ParentUnits;
|
||||
|
||||
public ParentUnitByNameShortcodeHandler(ILogger<ParentUnitByNameShortcodeHandler> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<string> ResolveAsync(string input, TemplateForShortcode template, string caller, CancellationToken ct = default)
|
||||
{
|
||||
var matches = Pattern.Matches(input);
|
||||
if (matches.Count == 0) return Task.FromResult(input);
|
||||
|
||||
var uniqueRequests = matches.Cast<Match>()
|
||||
.Select(m => new
|
||||
{
|
||||
FullMatch = m.Value,
|
||||
Mask = m.Groups[1].Value.Trim(),
|
||||
FieldName = m.Groups[2].Value.Trim()
|
||||
})
|
||||
.GroupBy(x => new { x.Mask, x.FieldName })
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var request in uniqueRequests)
|
||||
{
|
||||
resolved[request.FullMatch] = GetFieldValue(template, request.Mask, request.FieldName, caller);
|
||||
}
|
||||
|
||||
var result = input;
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (resolved.TryGetValue(m.Value, out var val))
|
||||
result = result.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private string GetFieldValue(TemplateForShortcode template, string mask, string fieldName, string caller)
|
||||
{
|
||||
if (template.ParentUnits == null || template.ParentUnits.Count == 0)
|
||||
{
|
||||
logger.LogDebug("[{Caller}] Родителей нет. Возвращаю 'Нет данных'.", caller);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
var matchingParent = template.ParentUnits
|
||||
.FirstOrDefault(p => MatchesMask(p.UnitName, mask));
|
||||
|
||||
if (matchingParent == null)
|
||||
{
|
||||
logger.LogDebug("[{Caller}] Не найден родитель по маске '{Mask}'. Возвращаю 'Нет данных'.", caller, mask);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
if (matchingParent.FieldValues.TryGetValue(fieldName, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
logger.LogDebug("[{Caller}] Родитель найден, но атрибут '{Field}' отсутствует. Возвращаю 'Нет данных'.", caller, fieldName);
|
||||
return "Нет данных";
|
||||
}
|
||||
|
||||
private static bool MatchesMask(string value, string mask)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(mask))
|
||||
return false;
|
||||
|
||||
if (mask.Contains('*'))
|
||||
{
|
||||
var regexPattern = "^" + Regex.Escape(mask).Replace("\\*", ".*") + "$";
|
||||
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(value, mask, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ internal class RelatedUnitTagShortcodeHandler : IShortcodeHandler
|
||||
private static readonly Regex _pattern = new(@"%ТЕГ_СВЯЗИ:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
public Regex Pattern => _pattern;
|
||||
|
||||
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.UnitsInTemplateTags;
|
||||
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.UnitsInTemplateTags | ShortcodeDataRequirementsEnum.UnitsInTemplate;
|
||||
|
||||
public RelatedUnitTagShortcodeHandler(ILogger<RelatedUnitTagShortcodeHandler> logger)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PARR.Core.Services.Shortcodes.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Данные о родительском ЭК для использования в шорткодах
|
||||
/// </summary>
|
||||
public record ParentUnitForShortcode(
|
||||
Guid UnitId,
|
||||
string UnitName,
|
||||
Dictionary<string, string> FieldValues
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
JobForShortcode? Job,
|
||||
List<UnitInTemplateForShortcode> UnitsInTemplate,
|
||||
List<string> UnitTags,
|
||||
Dictionary<Guid, List<string>> RelatedUnitTags
|
||||
Dictionary<Guid, List<string>> RelatedUnitTags,
|
||||
List<ParentUnitForShortcode> ParentUnits
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,34 +25,40 @@ internal class ShortcodesService : IShortcodesService
|
||||
|
||||
private const int MaxIterations = 3;
|
||||
|
||||
private readonly ILogger<ShortcodesService> logger;
|
||||
private readonly IEnumerable<IShortcodeHandler> handlers;
|
||||
private readonly IRedisCacheService cacheService;
|
||||
private readonly SettingsFromDb settingsFromDb;
|
||||
private readonly IJobRepository jobService;
|
||||
private readonly IUnitRepository unitService;
|
||||
private readonly IUnitFieldRepository unitFieldRepo;
|
||||
private readonly ITemplateRepository templateRepo;
|
||||
private readonly ILogger<ShortcodesService> _logger;
|
||||
private readonly IEnumerable<IShortcodeHandler> _handlers;
|
||||
private readonly IRedisCacheService _cacheService;
|
||||
private readonly SettingsFromDb _settingsFromDb;
|
||||
private readonly IJobRepository _jobService;
|
||||
private readonly IUnitRepository _unitRepo;
|
||||
private readonly IUnitFieldRepository _unitFieldRepo;
|
||||
private readonly IUnitInUnitRepository _unitInUnitRepo;
|
||||
private readonly IUnitInValueRepository _unitInValueRepo;
|
||||
private readonly ITemplateRepository _templateRepo;
|
||||
|
||||
public ShortcodesService(
|
||||
ILogger<ShortcodesService> logger,
|
||||
IEnumerable<IShortcodeHandler> handlers,
|
||||
IRedisCacheService cacheService,
|
||||
SettingsFromDb settingsFromDb,
|
||||
IJobRepository jobService,
|
||||
IUnitRepository unitService,
|
||||
IUnitFieldRepository unitFieldService,
|
||||
IJobRepository jobRepo,
|
||||
IUnitRepository unitRepo,
|
||||
IUnitFieldRepository unitFieldRepo,
|
||||
IUnitInUnitRepository unitInUnitRepo,
|
||||
IUnitInValueRepository unitInValueRepo,
|
||||
ITemplateRepository templateRepo
|
||||
)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.handlers = handlers.OrderBy(h => h.Order).ToList();
|
||||
this.cacheService = cacheService;
|
||||
this.settingsFromDb = settingsFromDb;
|
||||
this.jobService = jobService;
|
||||
this.unitService = unitService;
|
||||
this.unitFieldRepo = unitFieldService;
|
||||
this.templateRepo = templateRepo;
|
||||
this._logger = logger;
|
||||
this._handlers = handlers.OrderBy(h => h.Order).ToList();
|
||||
this._cacheService = cacheService;
|
||||
this._settingsFromDb = settingsFromDb;
|
||||
this._jobService = jobRepo;
|
||||
this._unitRepo = unitRepo;
|
||||
this._unitFieldRepo = unitFieldRepo;
|
||||
this._unitInUnitRepo = unitInUnitRepo;
|
||||
_unitInValueRepo = unitInValueRepo;
|
||||
this._templateRepo = templateRepo;
|
||||
}
|
||||
|
||||
public async Task<string> ApplyShortcodesAsync(string str, Template template, [CallerMemberName] string? caller = null)
|
||||
@@ -61,8 +67,8 @@ internal class ShortcodesService : IShortcodesService
|
||||
var callerName = caller ?? "Unknown";
|
||||
|
||||
// Кэш результата (проверяем один раз на входе)
|
||||
var cacheKey = cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
|
||||
var cachedResult = await cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
|
||||
var cacheKey = _cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
|
||||
var cachedResult = await _cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
|
||||
if (cachedResult != null) return cachedResult.Data.Result;
|
||||
|
||||
// Начальный контекст
|
||||
@@ -83,7 +89,8 @@ internal class ShortcodesService : IShortcodesService
|
||||
Job: initialJob,
|
||||
UnitsInTemplate: initialUnitsInTemplate,
|
||||
UnitTags: new List<string>(),
|
||||
RelatedUnitTags: new Dictionary<Guid, List<string>>()
|
||||
RelatedUnitTags: new Dictionary<Guid, List<string>>(),
|
||||
ParentUnits: new List<ParentUnitForShortcode>()
|
||||
);
|
||||
|
||||
var result = str;
|
||||
@@ -92,7 +99,7 @@ internal class ShortcodesService : IShortcodesService
|
||||
while (iteration < MaxIterations)
|
||||
{
|
||||
// 1. Находим хендлеры, которые сработают для ТЕКУЩЕЙ версии строки
|
||||
var matchingHandlers = handlers
|
||||
var matchingHandlers = _handlers
|
||||
.Where(h => h.Pattern.IsMatch(result))
|
||||
.ToList();
|
||||
|
||||
@@ -121,13 +128,13 @@ internal class ShortcodesService : IShortcodesService
|
||||
|
||||
if (iteration == MaxIterations && result != str)
|
||||
{
|
||||
logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
|
||||
_logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
|
||||
}
|
||||
|
||||
// Сохранение в кэш
|
||||
if (!string.IsNullOrEmpty(result) && result != str && template.Id != Guid.Empty)
|
||||
{
|
||||
await cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
|
||||
await _cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
|
||||
{
|
||||
Data = new ShortcodesResultDto { Result = result },
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
@@ -149,8 +156,8 @@ internal class ShortcodesService : IShortcodesService
|
||||
if (string.IsNullOrEmpty(unitName) && requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitName))
|
||||
{
|
||||
// Логирование и загрузка только если нужно и еще не загружено
|
||||
logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
|
||||
var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
|
||||
_logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
|
||||
var unit = await _unitRepo.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
|
||||
unitName = unit?.Name ?? throw new InvalidOperationException($"Unit {template.UnitId} не найден.");
|
||||
}
|
||||
|
||||
@@ -181,7 +188,7 @@ internal class ShortcodesService : IShortcodesService
|
||||
if (requirements.HasFlag(ShortcodeDataRequirementsEnum.JobTnk)) missing.Add("Tnk");
|
||||
|
||||
// Логируем только если реально пошли в БД
|
||||
logger.LogWarning(
|
||||
_logger.LogWarning(
|
||||
"[{Caller}] Шаблон {TemplateId} требует догрузки данных ({Missing}). Данные догружены.",
|
||||
caller, template.Id, string.Join(", ", missing));
|
||||
|
||||
@@ -193,11 +200,11 @@ internal class ShortcodesService : IShortcodesService
|
||||
if ((unitsInTemplate == null || unitsInTemplate.Count == 0) &&
|
||||
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplate))
|
||||
{
|
||||
logger.LogWarning(
|
||||
_logger.LogWarning(
|
||||
"[{Caller}] Шаблон {TemplateId} передан без UnitsInTemplate. Данные догружены автоматически.",
|
||||
caller, template.Id);
|
||||
|
||||
var fullTemplate = await templateRepo.Get()
|
||||
var fullTemplate = await _templateRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Include(t => t.UnitsInTemplate)
|
||||
.FirstOrDefaultAsync(t => t.Id == template.Id)
|
||||
@@ -209,21 +216,21 @@ internal class ShortcodesService : IShortcodesService
|
||||
}
|
||||
|
||||
|
||||
// 5. Проверка UnitTags
|
||||
// 4. Проверка UnitTags
|
||||
var unitTags = currentData.UnitTags;
|
||||
if ((unitTags == null || unitTags.Count == 0) &&
|
||||
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitTags))
|
||||
{
|
||||
logger.LogWarning(
|
||||
_logger.LogWarning(
|
||||
"[{Caller}] Шаблон {TemplateId} требует теги (UnitTags). Данные догружены автоматически.",
|
||||
caller, template.Id);
|
||||
|
||||
unitTags = await unitService.Get()
|
||||
unitTags = await _unitRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == template.UnitId)
|
||||
.SelectMany(u => u.UnitValues
|
||||
.Where(uv => uv.Field != null &&
|
||||
uv.Field.AihitName == "ПАРР тег" &&
|
||||
uv.Field.Code == "tag" &&
|
||||
uv.Value != null &&
|
||||
uv.Value.Value != null)
|
||||
.Select(uv => uv.Value!.Value!))
|
||||
@@ -231,24 +238,37 @@ internal class ShortcodesService : IShortcodesService
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 6. Проверка RelatedUnitTags
|
||||
// 5. Проверка RelatedUnitTags
|
||||
var relatedUnitTags = currentData.RelatedUnitTags;
|
||||
var relatedUnitIds = template.UnitsInTemplate?.Select(uit => uit.UnitId).Distinct().ToList() ?? new List<Guid>();
|
||||
|
||||
// Приводим template.UnitsInTemplate к List<UnitInTemplateForShortcode> через маппинг
|
||||
var mappedTemplateUnits = template.UnitsInTemplate?
|
||||
.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId))
|
||||
.ToList();
|
||||
|
||||
var actualUnitsInTemplate = unitsInTemplate
|
||||
?? currentData.UnitsInTemplate
|
||||
?? mappedTemplateUnits;
|
||||
|
||||
var relatedUnitIds = actualUnitsInTemplate?
|
||||
.Select(uit => uit.UnitId)
|
||||
.Distinct()
|
||||
.ToList() ?? new List<Guid>();
|
||||
|
||||
if ((relatedUnitTags == null || relatedUnitTags.Count == 0) &&
|
||||
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplateTags) &&
|
||||
relatedUnitIds.Count > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
_logger.LogWarning(
|
||||
"[{Caller}] Шаблон {TemplateId} требует теги связанных ЭК (UnitsInTemplateTags). Данные догружены автоматически.",
|
||||
caller, template.Id);
|
||||
|
||||
var rawTags = await unitService.Get()
|
||||
var rawTags = await _unitRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Where(u => relatedUnitIds.Contains(u.Id))
|
||||
.SelectMany(u => u.UnitValues
|
||||
.Where(uv => uv.Field != null &&
|
||||
uv.Field.AihitName == "ПАРР тег" &&
|
||||
uv.Field.Code == "tag" &&
|
||||
uv.Value != null &&
|
||||
uv.Value.Value != null)
|
||||
.Select(uv => new { UnitId = u.Id, Tag = uv.Value!.Value! }))
|
||||
@@ -260,21 +280,34 @@ internal class ShortcodesService : IShortcodesService
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => x.Tag).ToList());
|
||||
}
|
||||
|
||||
// 6. Проверка ParentUnits (непосредственные родители)
|
||||
var parentUnits = currentData.ParentUnits;
|
||||
if ((parentUnits == null || parentUnits.Count == 0) &&
|
||||
requirements.HasFlag(ShortcodeDataRequirementsEnum.ParentUnits))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[{Caller}] Шаблон {TemplateId} требует данные о родителях (ParentUnits). Данные догружены автоматически.",
|
||||
caller, template.Id);
|
||||
|
||||
parentUnits = await LoadParentUnitsAsync(template.UnitId, caller).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 7. Возвращаем обновленный рекорд
|
||||
return currentData with
|
||||
{
|
||||
UnitName = unitName ?? string.Empty,
|
||||
Job = jobData,
|
||||
UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate,
|
||||
UnitTags = unitTags ?? currentData.UnitTags,
|
||||
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags
|
||||
UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate ?? new List<UnitInTemplateForShortcode>(),
|
||||
UnitTags = unitTags ?? currentData.UnitTags ?? new List<string>(),
|
||||
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags ?? new Dictionary<Guid, List<string>>(),
|
||||
ParentUnits = parentUnits ?? currentData.ParentUnits ?? new List<ParentUnitForShortcode>()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private async Task<JobForShortcode> LoadFullJobAsync(Guid jobId, string caller)
|
||||
{
|
||||
var job = await jobService.Get().AsNoTracking().AsSingleQuery()
|
||||
var job = await _jobService.Get().AsNoTracking().AsSingleQuery()
|
||||
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
|
||||
.Include(j => j.Tnk)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId)
|
||||
@@ -310,7 +343,7 @@ internal class ShortcodesService : IShortcodesService
|
||||
{
|
||||
var result = new List<ShortcodeInfoDto>();
|
||||
|
||||
foreach (var constant in settingsFromDb.TemplateNameConstantPartsList)
|
||||
foreach (var constant in _settingsFromDb.TemplateNameConstantPartsList)
|
||||
{
|
||||
result.Add(new ShortcodeInfoDto
|
||||
{
|
||||
@@ -354,7 +387,23 @@ internal class ShortcodesService : IShortcodesService
|
||||
new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией", Type = ShortcodeTypeEnum.Relationship }
|
||||
});
|
||||
|
||||
var fieldNames = await unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
|
||||
result.AddRange(new[]
|
||||
{
|
||||
new ShortcodeInfoDto
|
||||
{
|
||||
Shortcode = "%РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:МАСКА_ИМЕНИ:ИМЯ_АТРИБУТА%",
|
||||
Description = "Значение атрибута первого ближайшего родителя, чье имя подходит под маску (wildcard *)",
|
||||
Type = ShortcodeTypeEnum.Relationship
|
||||
},
|
||||
new ShortcodeInfoDto
|
||||
{
|
||||
Shortcode = "%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:ИМЯ_АТРИБУТА_ФИЛЬТРА:МАСКА_ЗНАЧЕНИЯ:ИМЯ_АТРИБУТА_РЕЗУЛЬТАТА%",
|
||||
Description = "Значение атрибута первого ближайшего родителя, у которого значение атрибута-фильтра подходит под маску (wildcard *)",
|
||||
Type = ShortcodeTypeEnum.Relationship
|
||||
}
|
||||
});
|
||||
|
||||
var fieldNames = await _unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
|
||||
foreach (var fieldName in fieldNames.OrderBy(n => n.AihitName))
|
||||
{
|
||||
result.Add(new ShortcodeInfoDto
|
||||
@@ -367,4 +416,76 @@ internal class ShortcodesService : IShortcodesService
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<ParentUnitForShortcode>> LoadParentUnitsAsync(Guid unitId, string caller)
|
||||
{
|
||||
// Шаг 1: Получаем ID непосредственных родителей
|
||||
var parentIds = await _unitInUnitRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Where(link => link.ChildUnitId == unitId)
|
||||
.Select(link => link.ParentUnitId)
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (parentIds.Count == 0)
|
||||
return new List<ParentUnitForShortcode>();
|
||||
|
||||
// Шаг 2: Получаем имена родителей с детерминированной сортировкой
|
||||
var baseParents = await _unitRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Where(u => parentIds.Contains(u.Id))
|
||||
.OrderBy(u => u.Name)
|
||||
.Select(u => new { u.Id, u.Name })
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (baseParents.Count == 0)
|
||||
return new List<ParentUnitForShortcode>();
|
||||
|
||||
// Шаг 3: Получаем все значения атрибутов родителей одним запросом
|
||||
var parentIdList = baseParents.Select(p => p.Id).ToList();
|
||||
|
||||
var fieldValuesRaw = await _unitInValueRepo.Get()
|
||||
.AsNoTracking()
|
||||
.Include(uv => uv.Field)
|
||||
.Include(uv => uv.Value)
|
||||
.Where(uv => parentIdList.Contains(uv.UnitId) &&
|
||||
uv.Field != null &&
|
||||
uv.Value != null &&
|
||||
!string.IsNullOrEmpty(uv.Value.Value))
|
||||
.Select(uv => new
|
||||
{
|
||||
UnitId = uv.UnitId,
|
||||
FieldName = uv.Field!.AihitName,
|
||||
Value = uv.Value!.Value
|
||||
})
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Фильтрация пустых значений для гарантии non-nullable type
|
||||
var validFieldValues = fieldValuesRaw
|
||||
.Where(x => !string.IsNullOrEmpty(x.FieldName) &&
|
||||
!string.IsNullOrEmpty(x.Value))
|
||||
.ToList();
|
||||
|
||||
// Шаг 4: Группируем значения по UnitId в словари
|
||||
// Теперь x.Value точно не null, поэтому компилятор удовлетворится
|
||||
var valuesDict = validFieldValues
|
||||
.GroupBy(x => x.UnitId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.ToDictionary(
|
||||
x => x.FieldName!,
|
||||
x => x.Value!,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// Шаг 5: Формируем результат
|
||||
return baseParents.Select(bp => new ParentUnitForShortcode(
|
||||
UnitId: bp.Id,
|
||||
UnitName: bp.Name,
|
||||
FieldValues: valuesDict.TryGetValue(bp.Id, out var dict)
|
||||
? dict
|
||||
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -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 = 1000;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ 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;
|
||||
using PARR.Domain.Entities.JobEntities;
|
||||
using PARR.Domain.Entities.JobGroupEntities;
|
||||
using PARR.Domain.Entities.Unit;
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -84,26 +86,32 @@ 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");
|
||||
if (job.Group.GroupType == null)
|
||||
throw new ArgumentNullException(nameof(job.Group.GroupType), $"Job {job.Id} не содержит GroupType");
|
||||
if (job.UnitFilters == null || !job.UnitFilters.Any())
|
||||
throw new ArgumentNullException(nameof(job.UnitFilters), $"Job {job.Id} не содержит UnitFilters");
|
||||
|
||||
var unitFiltersList = GetJobAndGroupUnitFilters(job);
|
||||
|
||||
if (unitFiltersList.Count == 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Работа '{job.Name}' ({job.Id}) не содержит фильтров в группе или работе."/*, nameof(job)*/);
|
||||
}
|
||||
|
||||
var totalStopwatch = Stopwatch.StartNew();
|
||||
logger.LogInformation("Начало фильтрации юнитов для Job {JobId} с {FilterCount} фильтрами", job.Id, job.UnitFilters.Count);
|
||||
|
||||
// Собираем все контексты юнитов, прошедших фильтрацию
|
||||
logger.LogInformation(
|
||||
"Начало фильтрации юнитов для Job {JobId}. Всего фильтров: {FilterCount} (Group: {GroupFilterCount}, Job: {JobFilterCount})",
|
||||
job.Id,
|
||||
unitFiltersList.Count,
|
||||
job.Group?.UnitFilters?.Count ?? 0,
|
||||
job.UnitFilters?.Count ?? 0);
|
||||
|
||||
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 +121,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 +143,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 +166,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 +193,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 +213,7 @@ internal class UnitFilterService : IUnitFilterService
|
||||
ValidChildIds = new HashSet<Guid>(g.SelectMany(c => c.ValidChildIds))
|
||||
})
|
||||
.ToList();
|
||||
|
||||
logger.LogInformation("Этап базовой фильтрации завершён: собрано {UnitCount} уникальных юнитов", mergedContexts.Count);
|
||||
|
||||
// Этап 2: Применение Umbrella-фильтра
|
||||
@@ -219,25 +236,17 @@ internal class UnitFilterService : IUnitFilterService
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await jobRepository
|
||||
.Get().AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
|
||||
var job = await jobRepository
|
||||
.Get().AsNoTracking()
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters)
|
||||
.AsSplitQuery()
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.Include(j => j.Group).ThenInclude(g => g!.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||
|
||||
if (job == null)
|
||||
@@ -246,71 +255,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)
|
||||
foreach (var filter in GetJobAndGroupUnitFilters(job))
|
||||
{
|
||||
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 +303,84 @@ internal class UnitFilterService : IUnitFilterService
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToList();
|
||||
return result
|
||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
private async Task<Job?> LoadJobWithFiltersAsync(Guid jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await jobRepository
|
||||
.Get().AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
|
||||
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
|
||||
.Include(j => j.Group).ThenInclude(g => g!.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
|
||||
.Include(j => j.Group).ThenInclude(g => g!.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
|
||||
.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает фильтры юнитов из работы и из группы работ.
|
||||
/// </summary>
|
||||
private IReadOnlyList<JobUnitFilter> GetJobAndGroupUnitFilters(Job job)
|
||||
{
|
||||
var filters = new List<JobUnitFilter>();
|
||||
|
||||
if (job.Group?.UnitFilters != null && job.Group.UnitFilters.Count > 0)
|
||||
{
|
||||
filters.AddRange(job.Group.UnitFilters.Select(groupFilter => MapGroupUnitFilterToJobUnitFilter(groupFilter, job.Id)));
|
||||
}
|
||||
|
||||
if (job.UnitFilters != null && job.UnitFilters.Count > 0)
|
||||
{
|
||||
filters.AddRange(job.UnitFilters);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Приводит фильтр группы работ к виду фильтра работы.
|
||||
/// </summary>
|
||||
private static JobUnitFilter MapGroupUnitFilterToJobUnitFilter(JobGroupUnitFilter groupFilter, Guid jobId)
|
||||
{
|
||||
return new JobUnitFilter
|
||||
{
|
||||
Id = groupFilter.Id,
|
||||
DateCreated = groupFilter.DateCreated,
|
||||
UnitFilter = groupFilter.UnitFilter,
|
||||
JobId = jobId,
|
||||
|
||||
FieldFilters = groupFilter.FieldFilters
|
||||
.Select(fieldFilter => new JobFieldFilter
|
||||
{
|
||||
Id = fieldFilter.Id,
|
||||
DateCreated = fieldFilter.DateCreated,
|
||||
UnitFilterId = fieldFilter.UnitFilterId,
|
||||
FieldId = fieldFilter.FieldId,
|
||||
ValueMask = fieldFilter.ValueMask,
|
||||
IsInverse = fieldFilter.IsInverse,
|
||||
UnitField = fieldFilter.UnitField
|
||||
})
|
||||
.ToHashSet(),
|
||||
|
||||
RelationshipFilters = groupFilter.RelationshipFilters
|
||||
.Select(relationshipFilter => new JobRelationshipFilter
|
||||
{
|
||||
UnitFilterId = relationshipFilter.UnitFilterId,
|
||||
IsParent = relationshipFilter.IsParent,
|
||||
FieldId = relationshipFilter.FieldId,
|
||||
ValueMask = relationshipFilter.ValueMask,
|
||||
IsFullMatch = relationshipFilter.IsFullMatch,
|
||||
IsInverse = relationshipFilter.IsInverse,
|
||||
UnitField = relationshipFilter.UnitField
|
||||
})
|
||||
.ToHashSet()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -76,23 +60,16 @@ public class UnitFilterServiceTests
|
||||
ReferenceDate = DateTimeOffset.UtcNow,
|
||||
GroupTypeId = Guid.NewGuid(),
|
||||
ScheduleExcludeTypeId = Guid.NewGuid(),
|
||||
GroupType = BuildTestJobGroupType()
|
||||
GroupType = BuildTestJobGroupType(),
|
||||
EkMask = "%ЭК%"
|
||||
};
|
||||
|
||||
/// <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 +78,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 +86,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 +97,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 +115,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 +189,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 +214,9 @@ public class UnitFilterServiceTests
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter)
|
||||
/// или по связям (RelationshipFilter), корректно исключаются из результата.
|
||||
/// Проверяет, что юниты, не прошедшие фильтрацию по полю или по связям,
|
||||
/// корректно исключаются из результата.
|
||||
/// Логика фильтрации эмулируется через моки FieldMatcher и RelationshipMatcher.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
|
||||
@@ -260,30 +230,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 +282,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 +327,8 @@ public class UnitFilterServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет, что юниты исключаются, если их единственный родительский юнит
|
||||
/// содержит запрещённое значение тега (сценарий с IsInverse = true).
|
||||
/// Проверяет, что юниты исключаются, если их родитель содержит запрещённое значение (IsInverse = true).
|
||||
/// Логика фильтрации связей эмулируется через мок RelationshipMatcher.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
|
||||
@@ -373,7 +346,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 +353,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 +360,6 @@ public class UnitFilterServiceTests
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Формируем тестовый объект Job с настройкой IsInverse = true
|
||||
// IsInverse = true означает: исключить родителей, которые СОВПАДАЮТ с маской
|
||||
var job = new Job
|
||||
{
|
||||
Id = jobId,
|
||||
@@ -430,20 +399,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 +429,8 @@ public class UnitFilterServiceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет логику IsFullMatch = true: юнит исключается,
|
||||
/// если хотя бы один из его родительских юнитов не соответствует фильтру.
|
||||
/// Проверяет логику IsFullMatch = true: юнит исключается,
|
||||
/// если хотя бы один из его родителей не соответствует фильтру.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
|
||||
@@ -478,7 +448,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 +455,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 +463,6 @@ public class UnitFilterServiceTests
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Формируем тестовый объект Job с настройкой IsFullMatch = true
|
||||
// IsFullMatch = true означает: ВСЕ родители должны соответствовать фильтру
|
||||
var job = new Job
|
||||
{
|
||||
Id = jobId,
|
||||
@@ -536,19 +502,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 +531,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 +546,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 +558,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 +566,6 @@ public class UnitFilterServiceTests
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Формируем тестовый объект Job с комбинацией флагов:
|
||||
// IsParent = true: проверяем родительские связи
|
||||
// IsInverse = true: исключаем родителей, которые СОВПАДАЮТ с маской
|
||||
// IsFullMatch = true: ВСЕ родители должны пройти фильтр (ни один не должен совпасть с маской)
|
||||
var job = new Job
|
||||
{
|
||||
Id = jobId,
|
||||
@@ -646,21 +605,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 +630,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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
4096
PARR.DAL/Migrations/20260720233522_tblRobotsUpdateScheme.Designer.cs
generated
Normal file
4096
PARR.DAL/Migrations/20260720233522_tblRobotsUpdateScheme.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
PARR.DAL/Migrations/20260720233522_tblRobotsUpdateScheme.cs
Normal file
68
PARR.DAL/Migrations/20260720233522_tblRobotsUpdateScheme.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
4096
PARR.DAL/Migrations/20260720233632_tblRobotsRename.Designer.cs
generated
Normal file
4096
PARR.DAL/Migrations/20260720233632_tblRobotsRename.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
222
PARR.DAL/Migrations/20260720233632_tblRobotsRename.cs
Normal file
222
PARR.DAL/Migrations/20260720233632_tblRobotsRename.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
4133
PARR.DAL/Migrations/20260721013444_tblTemplateRenamePendings.Designer.cs
generated
Normal file
4133
PARR.DAL/Migrations/20260721013444_tblTemplateRenamePendings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user