58 Commits

Author SHA1 Message Date
Mikhail Kuznetsov
8c7d041a8b feat(shortcodesService): шорт-код поиска родительского ЭК по атрибутам теперь работает по множеству условий 2026-10-28 10:56:07 +10:00
Mikhail Trubnikov
236ddee5ef feat(api,core,dal,domain,esppSync): В группу работ добавлено поле EkMask, обновлена логика синхронизации шаблонов и выдачи задания роботу. Обновлен реквест и респонс для группы работ и шаблонов. 2026-10-21 11:21:36 +10:00
Mikhail Kuznetsov
fdfd0f9363 feat(shortcodesService): обновлена информация о доступных шорт-кодах для API 2026-10-15 15:15:19 +10:00
Mikhail Kuznetsov
393f043286 feat(shortcodesService): в шорт-код "РОД_ЭК_ПЕРВЫЙ" реализована возможность поиска по имени и маске атрибута 2026-10-15 14:51:50 +10:00
Mikhail Kuznetsov
5c3b273346 fix(shortcodesService): Исправлена сортировка родителей для тега %РОД_ЭК_ПЕРВЫЙ% 2026-10-15 11:33:40 +10:00
Mikhail Kuznetsov
0fcbe870b9 feat(shortcodesService): добавлен шорткод %РОД_ЭК_ПЕРВЫЙ:ОУ-*:КРАТКОЕ_НАИМЕНОВАНИЕ%. - Позволяет получать значения атрибутов родительских ЭК.
- Родитель определяется по маске, выбирается первый найденный, отсторитрованный по алфавиту
- Имя атрибута определяется по полю AihitName
2026-10-15 10:39:33 +10:00
Mikhail Kuznetsov
f9f234d119 refactor(templateMatcher):
- унификация деактивации шаблонов между разными синхронизаторами
- поиск ЭК выделен в отдельную сущность
2026-10-14 14:28:58 +10:00
Mikhail Trubnikov
394fc059ba feat(api): HelpDocuments - ссылка на редактирование документации 2026-10-13 08:52:33 +10:00
Mikhail Trubnikov
1b02bf63bf feat(api): HelpDocument - ссылки вперед и назад 2026-10-12 16:34:48 +10:00
Mikhail Trubnikov
09d8244b03 feat(api,core,domain): HelpDocument - ответ заглушка, если нет еще такой документации 2026-10-12 15:13:14 +10:00
Mikhail Trubnikov
4e81ee72f4 fix(api): HelpDocumentController - исправлен хук удаления кэша 2026-10-12 14:38:03 +10:00
Mikhail Trubnikov
04d9843cfb feat(api,core,domain): HelpDocumentations - методы по получению и кэшированию документации из 2026-10-12 14:14:30 +10:00
Mikhail Kuznetsov
9f5bae3f32 fix(templateMatcher): возвращена логика деактивации всего если фильтры вернули пустой результат. 2026-10-09 14:20:27 +10:00
Mikhail Trubnikov
a611a27c6e feat(api,core): Доработка юнит фильтров, инклуды и лог. 2026-10-09 12:08:12 +10:00
Mikhail Kuznetsov
d6f4368917 feat(unitFilterService): учитывает фильтры JobGroup 2026-10-08 15:05:48 +10:00
Mikhail Trubnikov
e99ca22251 feat(api): JobGroup обновление фильтров 2026-09-29 16:20:16 +10:00
Mikhail Trubnikov
11eff75686 feat(api): Сортировка юнит фильтров 2026-09-29 10:20:28 +10:00
Mikhail Trubnikov
3818a470ce feat(api): Job - в респонс добавлены групповые фильтры. Обновлена валидация с учетом IsAllowJobUnitFilter 2026-09-28 12:22:30 +10:00
Mikhail Trubnikov
b475d1e10c feat(api): JobGroup - респонс с юнит фильтрами 2026-09-24 16:57:09 +10:00
Mikhail Trubnikov
f624b7f4a7 feat(api): JobGroup - управление юнит фильтрами 2026-09-24 16:03:57 +10:00
Mikhail Trubnikov
2a3afb9a5d feat(api): Запрещено изменение типа группы работ 2026-09-23 16:22:41 +10:00
Mikhail Trubnikov
f3a3a0cf8c feat(api): В ТНК добавлено поле продолжительность минут. 2026-09-15 11:40:50 +10:00
Mikhail Kuznetsov
2befb6b0c3 fix(templateMatcher): восстановлена фильтрация рабочей группы при группировке по ответственному исполнителю 2026-09-15 10:02:00 +10:00
Mikhail Kuznetsov
0629579cf0 reafctor(templateMatcher): сервис синхронизации неактуальных шаблонов вынесен в отдельный класс 2026-09-10 14:12:47 +10:00
Mikhail Kuznetsov
83c6a5f605 feat(templateMatcher):
- добавлен режим DryRun
- изменен подход к фильтрации шаблонов по рабочей группе - перенесено из этапа основной фильтрации в этап посторение подргупп/шаблонов
- при использовании тегов альтернативных рабочих групп фильтрация по списку пропускается
2026-09-09 17:09:57 +10:00
Mikhail Kuznetsov
e02a51f5ed fix(shortcodesService): исправлена загрузка шорткода %ТЕГ_СВЯЗИ:...% - отсутствовало требование загрузки UnitsInTemplate 2026-09-08 09:47:31 +10:00
Mikhail Kuznetsov
fb20395e53 refactor(unitFilterService): логика фильтрации связанных ЭК перенесена в класс, который за это отвечает. 2026-07-31 10:46:48 +10:00
Mikhail Kuznetsov
94bea5c46d Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-30 14:18:14 +10:00
Mikhail Kuznetsov
349cf55862 feat(shortcodesService): Дополнительная детерминация списка в шорткоде СВЯЗИ(-ПН). 2026-07-30 14:18:04 +10:00
Mikhail Trubnikov
4cd6134ad4 fix(core): Исправлена логика выдачи заданий роботам при множественном переименовании, можно брать шаблоны в статусах used и unused 2026-07-30 10:53:41 +10:00
Mikhail Trubnikov
f10a50edae fix(core): Исправлена логика выдачи заданий роботам при множественном переименовании 2026-07-30 10:07:56 +10:00
Mikhail Trubnikov
0ab5b49371 Merge branch 'robot-task-details-2' into dev 2026-07-29 16:32:37 +10:00
Mikhail Trubnikov
acdb6ec893 feat(core,dal,domain,templateUpdater): Задания роботам выдаюстя с учетом подмены и множества переименований 2026-07-29 16:31:50 +10:00
Mikhail Trubnikov
7d5fb23daf feat(core,api): Детали по заданиями роботов 2026-07-29 09:50:21 +10:00
Mikhail Kuznetsov
e36e08aa73 Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-28 16:34:13 +10:00
Mikhail Kuznetsov
343468cf71 feat(unitFilter): Оптимизация фильрации юнитов. Проверка значений аттрибутов теперь выполняется в 2 запроса:
- проверяем доступные в базе значения
- проверяем наличие значений в юнитах по id значений
2026-07-28 16:34:04 +10:00
Mikhail Trubnikov
56cdca0502 feat(core): Задание роботам с подменой на переименование шаблона, выдается только для шаблонов в статусе StatusTypeId==Used 2026-07-28 16:15:01 +10:00
Mikhail Trubnikov
8798529b4d fix(api,core): Исправлена логика выдачи переименованных шаблонов роботам 2026-07-28 14:41:01 +10:00
Mikhail Trubnikov
556d895c7c fix(api): Исправлен маппинг для Template 2026-07-27 09:57:17 +10:00
Mikhail Trubnikov
58275d73f4 Merge branch 'template-rename-pending' into dev
# Conflicts:
#	PARR.DAL/Repositories/TemplateRepository.cs
2026-07-27 09:06:36 +10:00
Mikhail Trubnikov
eacda75649 refactor(core,domain): Переименованы shared dto 2026-07-24 13:35:56 +10:00
Mikhail Trubnikov
90e9f80505 feat(esppSync): При синхронизации шаблонов и расписаний не меняется статус у объектов находящихся на переименовании 2026-07-24 11:59:20 +10:00
Mikhail Trubnikov
b3879062a6 feat(api,core,domain): Изменен контроллер RobotTaskRobotStatusController, метод изменения статуса задания вынесен в сервисы. Добавлена логика успрешного переименования шаблона. 2026-07-23 14:39:55 +10:00
Mikhail Trubnikov
58b4d98b16 feat(dal,core): Рефакторинг методов и логики выдачи заданий роботом при переименовании шаблонов 2026-07-23 10:48:59 +10:00
Mikhail Kuznetsov
d1889460de feat(templateMatcher): Выбирать неактуальные шаблоны по дате последнего изменения, а не создания. Дата создания не гарантирует, что шаблон недавно освободился. 2026-07-23 09:34:59 +10:00
Mikhail Kuznetsov
5f384947a6 fix(templateMatcher): Добавлено управление транзакцией в момент захват неиспользуемого шаблона, для устранения гонки. 2026-07-23 08:36:17 +10:00
Mikhail Trubnikov
1cc2beb9ca feat(core): Логика выдачи задания роботу для переименованных шаблонов 2026-07-22 16:02:04 +10:00
Mikhail Trubnikov
0b0c0b04ad feat(dal,templateUpdater): Сохранение шаблонов для переименования в таблицу TemplateRenamePending 2026-07-22 09:49:27 +10:00
Mikhail Trubnikov
5094945c8e feat(dal,domain): Таблицы роботов перенесены в схему robot. Создана таблица TemplateRenamePendings - Шаблоны находящиеся в процессе переименования 2026-07-21 11:48:21 +10:00
Mikhail Kuznetsov
98245e73e6 Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-20 09:54:38 +10:00
Mikhail Kuznetsov
cdcb4fd9bc fix(unitFilter): Снижен размер чанка для фильтрации по полям для устранения ошибки таймаута при работе templateMatcher 2026-07-20 09:54:28 +10:00
Mikhail Trubnikov
bbb14ee4ea feat(api): Статистика: Получить кол-во шаблонов у которых ИД расписания null и нет задания на создание расписания 2026-07-17 15:35:46 +10:00
Mikhail Kuznetsov
5edbcff35b Merge branch 'dev' of http://gitlab.dvgd.oao.rzd/devptk/parr/parr_api into dev 2026-07-16 15:00:24 +10:00
Mikhail Kuznetsov
6c93e1971f fix(templateMatcher): Исправлен баг в AutoControlResolver и добавивлен Include для AutoControl группы
- Добавлены строгие проверки на null для `GroupType`, `jobGroup.AutoControl` и `job.AutoControl` с генерацией исключений.
- В запрос загрузки данных добавлен `.Include(j => j.Group).ThenInclude(g => g.AutoControl)`, исправивший падение в дефолтные состояния.
2026-07-16 15:00:17 +10:00
Mikhail Trubnikov
68696a2fdc Merge branch 'robot-config-report' into dev 2026-07-16 10:13:40 +10:00
Mikhail Trubnikov
751b693e72 feat(api,core): Методы в API для получения метрик заданий роботам, StatRobotMetricsController. 2026-07-16 10:12:56 +10:00
Mikhail Kuznetsov
6ca6bfbc2e refactor(aihitRelationshipsSyncer): Стилистическое изменение 2026-07-15 16:27:00 +10:00
Mikhail Kuznetsov
178a991d8b Merge branch 'min-relationships-threshold' into 'dev'
feat(aihitRelationshipsSyncer): Добавлена реализация...

See merge request devptk/parr/parr_api!1
2026-07-15 05:17:39 +00:00
193 changed files with 36444 additions and 1959 deletions

View File

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

View File

@@ -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);
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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; }
//}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}
}

View 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; }
}
}

View 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; }
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,8 @@
namespace PARR.API.Contracts.V1.Responses.Statistics
{
public record StatFilteredChartPoint
{
public DateTimeOffset Timestamp { get; init; }
public int Count { get; init; }
}
}

View File

@@ -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; }
}
}

View File

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

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,5 @@
namespace PARR.API.Contracts.V1.Responses.Statistics
{
public record StatTemplatesWithoutScheduleResponse(int Count);
}

View File

@@ -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; }

View File

@@ -10,6 +10,8 @@
public int? EsppId { get; set; }
public int? DurationMinutes { get; set; }
public Guid SubprocessId { get; set; }
}

View 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; }
}
}

View 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("Внутренняя ошибка обработки вебхука");
}
}
}
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));
}
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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>();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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)
{

View File

@@ -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)

View 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
}
}
}

View File

@@ -6,7 +6,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None"
}
},
"Serilog": {
@@ -14,7 +15,8 @@
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
}
},
@@ -111,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"
}
}

View File

@@ -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
};
}

View File

@@ -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;
}

View 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;
}
}
}

View File

@@ -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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
};
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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;
}
}
}
}

View File

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

View File

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

View File

@@ -3,12 +3,15 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.BLL.Helpers;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.TemplateRepositories;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.RobotTask.Interfaces;
using PARR.Core.Services.RobotTask.Models;
using PARR.Core.Services.Shortcodes;
using PARR.Domain.DTOs.RobotTask;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums;
using PARR.Domain.Exceptions;
using PARR.Domain.Settings;
@@ -19,16 +22,18 @@ namespace PARR.Core.Services.RobotTask.Implementations
{
/// <summary>
/// Количество заданий которые рассматриваем для взятия в работу.
/// Рекомендованное значение, кол-во роботов * 3
/// </summary>
private readonly int TakeTasks = 10;
private readonly int TakeTasks = 15 * 3;
private readonly ILogger<RobotTaskService> logger;
private readonly IRobotConfigurationRepository robotConfigurationRepository;
private readonly SettingsFromDb settingsFromDb;
private readonly IRobotHistoryRepository robotHistoryRepository;
private readonly IMapper mapper;
private readonly IShortcodesService shortcodesService;
private readonly INextRunService nextRunService;
private readonly ILogger<RobotTaskService> _logger;
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly SettingsFromDb _settingsFromDb;
private readonly IRobotHistoryRepository _robotHistoryRepository;
private readonly IMapper _mapper;
private readonly IShortcodesService _shortcodesService;
private readonly INextRunService _nextRunService;
private readonly ITemplateRenamePendingRepository _templateRenamePendingRepository;
public RobotTaskService(
ILogger<RobotTaskService> logger,
@@ -37,16 +42,18 @@ namespace PARR.Core.Services.RobotTask.Implementations
IRobotHistoryRepository robotHistoryRepository,
IMapper mapper,
IShortcodesService shortcodesService,
INextRunService nextRunService
INextRunService nextRunService,
ITemplateRenamePendingRepository templateRenamePendingRepository
)
{
this.logger = logger;
this.robotConfigurationRepository = robotConfigurationRepository;
this.settingsFromDb = settingsFromDb;
this.robotHistoryRepository = robotHistoryRepository;
this.mapper = mapper;
this.shortcodesService = shortcodesService;
this.nextRunService = nextRunService;
_logger = logger;
_robotConfigurationRepository = robotConfigurationRepository;
_settingsFromDb = settingsFromDb;
_robotHistoryRepository = robotHistoryRepository;
_mapper = mapper;
_shortcodesService = shortcodesService;
_nextRunService = nextRunService;
_templateRenamePendingRepository = templateRenamePendingRepository;
}
@@ -54,19 +61,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");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@
UnitName = 16,
UnitsInTemplate = 32,
UnitTags = 64,
UnitsInTemplateTags = 128
UnitsInTemplateTags = 128,
ParentUnits = 256
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

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

View File

@@ -0,0 +1,11 @@
namespace PARR.Core.Services.Shortcodes.Models
{
/// <summary>
/// Данные о родительском ЭК для использования в шорткодах
/// </summary>
public record ParentUnitForShortcode(
Guid UnitId,
string UnitName,
Dictionary<string, string> FieldValues
);
}

View File

@@ -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
);
}

View File

@@ -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();
}
}

View File

@@ -1,26 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.UnitFilterService.Matchers.Interfaces;
using PARR.Domain.Entities.JobEntities;
using System.Diagnostics;
namespace PARR.Core.Services.UnitFilterService.Matchers;
/// <summary>
/// Сопоставляет юниты с фильтрами по атрибутам.
/// Использует двухэтапный поиск: сначала ValueId, затем UnitId.
/// </summary>
internal class UnitFieldMatcher : IUnitFieldMatcher
{
private const int chunkSize = 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();
}
}

View File

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

View File

@@ -3,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()
};
}
}

View File

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

View File

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

View File

@@ -21,14 +21,6 @@ public class UnitFilterServiceTests
{
#region Helpers
/// <summary>
/// Создаёт тестовую сущность Unit с заданным значением поля.
/// Используется для подготовки данных в тестах фильтрации.
/// </summary>
/// <param name="id">Уникальный идентификатор юнита</param>
/// <param name="name">Имя юнита для отладки</param>
/// <param name="fieldId">Идентификатор поля, к которому привязывается значение</param>
/// <param name="fieldValue">Значение поля</param>
private static Unit BuildTestUnit(Guid id, string name, Guid fieldId, string fieldValue)
{
var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue };
@@ -49,10 +41,6 @@ public class UnitFilterServiceTests
};
}
/// <summary>
/// Создаёт тестовый объект JobGroupType с кодом Umbrella.
/// Используется для тестирования логики групповых работ типа "зонтик".
/// </summary>
private static JobGroupType BuildTestJobGroupType() => new()
{
Id = Guid.NewGuid(),
@@ -61,10 +49,6 @@ public class UnitFilterServiceTests
Description = "Test"
};
/// <summary>
/// Создаёт тестовый объект JobGroup с заполненными обязательными полями.
/// Используется для подготовки навигационных свойств в тестах.
/// </summary>
private static JobGroup BuildTestJobGroup() => new()
{
Id = Guid.NewGuid(),
@@ -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);
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Some files were not shown because too many files have changed in this diff Show More