Compare commits

..

12 Commits

Author SHA1 Message Date
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
Mikhail Kuznetsov
a087958fd5 fix(aihit-sync): Оптимизация GetDeltaAsync и атомарность сохранения
- GetDeltaAsync: фильтрация по relevantParentNames вместо выгрузки всех связей из БД
- SyncAsync: атомарность через ChangeTracker + CommitAsync без явной транзакции
2026-07-15 14:40:10 +10:00
Mikhail Kuznetsov
4ee2880a8f fix(aihitRelationshipsSyncer): критические исправления RelationshipsSyncService
- ValidateInput: возврат пустого списка вместо null! для предотвращения NRE
- SyncAsync: транзакция для атомарности зеркалирования снимка состояния
- GetOrCreateUnitsAsync: пакетное создание юнитов через CreateRange
- CommitChangesAsync: исключение при ошибке коммита вместо тихого лога
- PassSafeguardCheckAsync: выделен в отдельный метод
- Удалены избыточные комментарии, унифицированы имена переменных
2026-07-15 14:04:35 +10:00
Mikhail Kuznetsov
497d241caf feat(aihitRelationshipsSyncer): Добавлена реализация MinRelationshipsThresholdPct для защиты базы данных от потери связей ЭК 2026-07-14 14:55:49 +10:00
24 changed files with 875 additions and 256 deletions

View File

@@ -1,236 +1,261 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PARR.AIHITRelationshipsSyncer.Models; using PARR.AIHITRelationshipsSyncer.Models;
using PARR.AIHITRelationshipsSyncer.Services.Interfaces; using PARR.AIHITRelationshipsSyncer.Services.Interfaces;
using PARR.AIHITRelationshipsSyncer.Settings;
using PARR.Core.Repositories.Interfaces.Unit; using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Domain.Entities.Unit; using PARR.Domain.Entities.Unit;
using PARR.Domain.Settings;
namespace PARR.AIHITRelationshipsSyncer.Services.Implementations namespace PARR.AIHITRelationshipsSyncer.Services.Implementations;
internal class RelationshipsSyncService : IRelationshipsSyncService
{ {
internal class RelationshipsSyncService : IRelationshipsSyncService private readonly ILogger<RelationshipsSyncService> _logger;
private readonly IUnitRepository _unitRepository;
private readonly SettingsFromDb _settingsFromDb;
public RelationshipsSyncService(
ILogger<RelationshipsSyncService> logger,
IUnitRepository unitRepository,
SettingsFromDb settingsFromDb)
{ {
private readonly ILogger<RelationshipsSyncService> logger; _logger = logger;
private readonly IUnitRepository unitService; _unitRepository = unitRepository;
private readonly WorkerSettings workerSettings; _settingsFromDb = settingsFromDb;
public RelationshipsSyncService(
ILogger<RelationshipsSyncService> logger,
IUnitRepository unitService,
WorkerSettings workerSettings
)
{
this.logger = logger;
this.unitService = unitService;
this.workerSettings = workerSettings;
}
public async Task SyncAsync(List<AihitData> aihitdata)
{
var validPairs = ValidateInput(aihitdata);
if (!validPairs.Any()) return;
var sourceUnitNames = ExtractUnitNames(validPairs);
var existingUnits = await GetOrCreateUnitsAsync(sourceUnitNames);
var (toRemove, toAdd) = await GetDeltaAsync(validPairs);
await RemoveRelationshipsAsync(toRemove);
await AddRelationshipsAsync(toAdd, existingUnits);
await FinalCommitAsync(toRemove.Count, toAdd.Count);
}
private List<(string Parent, string Child)> ValidateInput(List<AihitData> data)
{
if (data == null || data.Count == 0)
{
logger.LogWarning("Получены пустые или null данные из АИХ ИТ. Пропускаю синхронизацию");
return null!;
}
var validPairs = data
.Where(t => !string.IsNullOrWhiteSpace(t.ParentName) && !string.IsNullOrWhiteSpace(t.ChildName))
.Where(t => t.ParentName != t.ChildName)
.Select(t => (
Parent: t.ParentName!.Trim().ToUpperInvariant(),
Child: t.ChildName!.Trim().ToUpperInvariant()
))
.Distinct()
.ToList();
if (validPairs.Count == 0)
{
logger.LogWarning("После нормализации не осталось валидных связей. Пропускаю синхронизацию");
return new();
}
logger.LogInformation("Получено {Count} валидных связей", validPairs.Count);
return validPairs;
}
private static List<string> ExtractUnitNames(List<(string Parent, string Child)> pairs)
{
return pairs
.SelectMany(x => new[] { x.Parent, x.Child })
.Distinct()
.ToList();
}
private async Task<Dictionary<string, Unit>> GetOrCreateUnitsAsync(List<string> names)
{
var existing = await unitService.Get()
.AsNoTracking()
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
var missing = names.Except(existing.Keys).ToList();
if (!missing.Any()) return existing;
logger.LogInformation("Создание отсутствующих Unit:{Count}", missing.Count);
foreach (var name in missing)
{
var unit = new Unit
{
Id = Guid.NewGuid(),
Name = name,
DateCreated = DateTimeOffset.UtcNow
};
if (await unitService.CreateAsync(unit))
{
logger.LogInformation("Создан Unit: {Name}", name);
existing[name] = unit;
}
else
logger.LogError("Не удалось создать Unit: {Name}", name);
}
await unitService.CommitAsync();
return existing;
}
private async Task<(List<ExistingRelationship> ToRemove, List<(string, string)> ToAdd)> GetDeltaAsync(List<(string Parent, string Child)> sourcePairs)
{
var allExistings = await unitService.Get()
.AsNoTracking()
.Include(u => u.ChildUnits).ThenInclude(r => r.ChildUnit)
.Where(u => u.ChildUnits.Any())
.SelectMany(u => u.ChildUnits, (parent, relationship) => new ExistingRelationship
{
ParentName = parent.Name,
ChildName = relationship.ChildUnit!.Name,
Relationship = relationship
}
)
.ToListAsync();
var sourceSet = new HashSet<(string, string)>(sourcePairs);
var existingSet = new HashSet<(string, string)>(allExistings.Select(x => (x.ParentName, x.ChildName)));
var toRemove = allExistings.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);
}
private async Task RemoveRelationshipsAsync(List<ExistingRelationship> toRemove)
{
if (!toRemove.Any()) return;
var parentNames = toRemove.Select(r => r.ParentName).Distinct().ToList();
var parents = await unitService.Get()
.Include(u => u.ChildUnits)
.Where(u => parentNames.Contains(u.Name))
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
foreach (var rel in toRemove)
{
if (!parents.TryGetValue(rel.ParentName, out var parent))
{
logger.LogWarning("Родитель '{Parent}' не найден при попытке удаления связи - '{Child}'", rel.ParentName, rel.ChildName);
continue;
}
var relationshipToRemove = parent.ChildUnits
.FirstOrDefault(r => r.ChildUnitId == rel.Relationship.ChildUnitId);
if (relationshipToRemove != null)
{
logger.LogInformation("Удаление связи: '{Parent}' - '{Child}'", rel.ParentName, rel.ChildName);
parent.ChildUnits.Remove(relationshipToRemove);
}
else
{
logger.LogWarning("Связь '{Parent}'-'{Child}' не найдена в коллекции ChildUnits для удаления", rel.ParentName, rel.ChildName);
}
}
}
private async Task AddRelationshipsAsync(List<(string Parent, string Child)> toAdd, Dictionary<string, Unit> allUnits)
{
if (!toAdd.Any()) return;
var parentNames = toAdd.Select(x => x.Parent).Distinct().ToList();
var parents = await unitService.Get()
.Include(u => u.ChildUnits)
.Where(u => parentNames.Contains(u.Name))
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
foreach (var (Parent, Child) in toAdd)
{
if (!parents.TryGetValue(Parent, out var parent) ||
!allUnits.TryGetValue(Child, out var child))
{
logger.LogWarning("Пропущена связь '{Parent}' - '{Child}': Unit не найден", Parent, Child);
continue;
}
if (parent!.ChildUnits.Any(r => r.ChildUnitId == child.Id))
{
logger.LogWarning("Связь уже существует: '{Parent}' - '{Child}'", Parent, Child);
continue;
}
parent.ChildUnits.Add(new UnitInUnit
{
ParentUnitId = parent.Id,
ChildUnitId = child.Id,
DateCreated = DateTimeOffset.UtcNow
});
logger.LogInformation("Добавлена связь: '{Parent}' - '{Child}'", Parent, Child);
}
}
private async Task FinalCommitAsync(int removedCount, int addedCount)
{
if (removedCount > 0 || addedCount > 0)
{
if (await unitService.CommitAsync())
{
logger.LogInformation("Синхронизация завершена. Удалено: {Removed}, добавлено: {Added}", removedCount, addedCount);
}
else
logger.LogError("Не удалось сохранить изменения в БД");
}
else
logger.LogInformation("Изменений не обнаружено");
}
private sealed class ExistingRelationship
{
public string ParentName { get; init; }
public string ChildName { get; init; }
public UnitInUnit Relationship { get; init; }
}
} }
}
public async Task SyncAsync(List<AihitData> aihitData)
{
var validPairs = ValidateInput(aihitData);
if (validPairs.Count == 0)
return;
if (!await PassSafeguardCheckAsync(validPairs.Count))
return;
var unitNames = ExtractUnitNames(validPairs);
var unitsByName = await GetOrCreateUnitsAsync(unitNames);
var (toRemove, toAdd) = await GetDeltaAsync(validPairs);
// Удаление и добавление накапливаются в одном ChangeTracker.
// CommitAsync сохраняет всё атомарно через SaveChangesAsync.
await RemoveRelationshipsAsync(toRemove);
await AddRelationshipsAsync(toAdd, unitsByName);
await CommitChangesAsync(toRemove.Count, toAdd.Count);
}
private List<(string ParentName, string ChildName)> ValidateInput(List<AihitData> data)
{
if (data == null || data.Count == 0)
{
_logger.LogWarning("Получены пустые данные из АИХ ИТ. Синхронизация пропущена");
return new List<(string, string)>();
}
var validPairs = data
.Where(item => !string.IsNullOrWhiteSpace(item.ParentName) && !string.IsNullOrWhiteSpace(item.ChildName))
.Where(item => item.ParentName != item.ChildName)
.Select(item => (
ParentName: item.ParentName!.Trim().ToUpperInvariant(),
ChildName: item.ChildName!.Trim().ToUpperInvariant()
))
.Distinct()
.ToList();
if (validPairs.Count == 0)
{
_logger.LogWarning("После нормализации не осталось валидных связей. Синхронизация пропущена");
return new List<(string, string)>();
}
_logger.LogInformation("Получено {Count} валидных связей", validPairs.Count);
return validPairs;
}
private async Task<bool> PassSafeguardCheckAsync(int incomingCount)
{
var previousCount = await _unitRepository.Get()
.SelectMany(u => u.ChildUnits)
.CountAsync();
if (previousCount == 0)
return true;
var currentPercentage = Math.Round((decimal)incomingCount / previousCount * 100, 1, MidpointRounding.AwayFromZero);
if (currentPercentage < _settingsFromDb.MinRelationshipsThresholdPct)
{
_logger.LogWarning(
"Синхронизация отменена: количество полученных связей ниже порогового значения. " +
"Получено: {CurrentCount} ({CurrentPercentage:F1}%), порог: {Threshold}% от прошлого объема ({PreviousCount})",
incomingCount, currentPercentage, _settingsFromDb.MinRelationshipsThresholdPct, previousCount);
return false;
}
return true;
}
private static List<string> ExtractUnitNames(List<(string ParentName, string ChildName)> pairs)
{
return pairs
.SelectMany(pair => new[] { pair.ParentName, pair.ChildName })
.Distinct()
.ToList();
}
private async Task<Dictionary<string, Unit>> GetOrCreateUnitsAsync(List<string> names)
{
var existingUnits = await _unitRepository.Get()
.AsNoTracking()
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
var missingNames = names.Except(existingUnits.Keys).ToList();
if (missingNames.Count == 0)
return existingUnits;
_logger.LogInformation("Создание отсутствующих юнитов: {Count}", missingNames.Count);
foreach (var name in missingNames)
{
var unit = new Unit
{
Id = Guid.NewGuid(),
Name = name,
DateCreated = DateTimeOffset.UtcNow
};
if (!await _unitRepository.CreateAsync(unit))
{
_logger.LogError("Не удалось создать юнит: {Name}", name);
continue;
}
existingUnits[name] = unit;
_logger.LogDebug("Создан юнит: {Name}", name);
}
await _unitRepository.CommitAsync();
return existingUnits;
}
private async Task<(List<ExistingRelationship> ToRemove, List<(string ParentName, string ChildName)> ToAdd)> GetDeltaAsync(
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)
.Where(u => relevantParentNames.Contains(u.Name))
.SelectMany(u => u.ChildUnits, (parent, relationship) => new ExistingRelationship
{
ParentName = parent.Name,
ChildName = relationship.ChildUnit!.Name,
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);
}
private async Task RemoveRelationshipsAsync(List<ExistingRelationship> toRemove)
{
if (toRemove.Count == 0)
return;
var parentNames = toRemove.Select(r => r.ParentName).Distinct().ToList();
var parents = await _unitRepository.Get()
.Include(u => u.ChildUnits)
.Where(u => parentNames.Contains(u.Name))
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
foreach (var relationship in toRemove)
{
if (!parents.TryGetValue(relationship.ParentName, out var parent))
{
_logger.LogWarning("Родитель '{Parent}' не найден при удалении связи с '{Child}'",
relationship.ParentName, relationship.ChildName);
continue;
}
var entityToRemove = parent.ChildUnits
.FirstOrDefault(r => r.ChildUnitId == relationship.Relationship.ChildUnitId);
if (entityToRemove != null)
parent.ChildUnits.Remove(entityToRemove);
}
}
private async Task AddRelationshipsAsync(
List<(string ParentName, string ChildName)> toAdd,
Dictionary<string, Unit> unitsByName)
{
if (toAdd.Count == 0)
return;
var parentNames = toAdd.Select(x => x.ParentName).Distinct().ToList();
var parents = await _unitRepository.Get()
.Include(u => u.ChildUnits)
.Where(u => parentNames.Contains(u.Name))
.ToDictionaryAsync(u => u.Name, StringComparer.Ordinal);
foreach (var (parentName, childName) in toAdd)
{
if (!parents.TryGetValue(parentName, out var parent) ||
!unitsByName.TryGetValue(childName, out var child))
{
_logger.LogWarning("Пропущена связь '{Parent}' - '{Child}': юнит не найден", parentName, childName);
continue;
}
if (parent.ChildUnits.Any(r => r.ChildUnitId == child.Id))
continue;
parent.ChildUnits.Add(new UnitInUnit
{
ParentUnitId = parent.Id,
ChildUnitId = child.Id,
DateCreated = DateTimeOffset.UtcNow
});
}
}
private async Task CommitChangesAsync(int removedCount, int addedCount)
{
if (removedCount == 0 && addedCount == 0)
{
_logger.LogInformation("Изменений не обнаружено");
return;
}
if (!await _unitRepository.CommitAsync())
throw new InvalidOperationException("Не удалось сохранить изменения связей в БД");
_logger.LogInformation("Синхронизация завершена. Удалено: {Removed}, добавлено: {Added}", removedCount, addedCount);
}
private sealed class ExistingRelationship
{
public string ParentName { get; init; } = null!;
public string ChildName { get; init; } = null!;
public UnitInUnit Relationship { get; init; } = null!;
}
}

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/ // https://tproger.ru/translations/luchshie-praktiki-razrabotki-rest-api-20-sovetov/
@@ -224,6 +226,7 @@
{ {
public const string Get = BaseStat + "/templates/"; public const string Get = BaseStat + "/templates/";
public const string GetForPeriod = BaseStat + "/templates/period"; public const string GetForPeriod = BaseStat + "/templates/period";
public const string GetTemplatesWithoutScheduleAndTaskCount = BaseStat + "/templates/without-schedule";
} }
public static class StatStatusTypeTemplates public static class StatStatusTypeTemplates
@@ -324,6 +327,16 @@
public const string GetWorkloadTemplateReport = BaseStat + "/workload/templates/{reportType}/{filter}/{state}/{date}"; 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 #endregion
#region Наряды #region Наряды

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

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

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

@@ -10,6 +10,7 @@ using PARR.API.Helpers;
using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces;
using PARR.Core.Services.NextRunServices; using PARR.Core.Services.NextRunServices;
using PARR.Domain.Common.Roles; using PARR.Domain.Common.Roles;
using PARR.Domain.Entities;
using PARR.Domain.Enums; using PARR.Domain.Enums;
namespace PARR.API.Controllers.V1.Statistics namespace PARR.API.Controllers.V1.Statistics
@@ -20,16 +21,16 @@ namespace PARR.API.Controllers.V1.Statistics
[Authorize(Roles = ParrRoles.Administrator.Role)] [Authorize(Roles = ParrRoles.Administrator.Role)]
public class StatTemplateController : BaseApiController public class StatTemplateController : BaseApiController
{ {
private readonly ITemplateRepository templateService; private readonly ITemplateRepository _templateRepository;
private readonly INextRunService nextRunService; private readonly INextRunService _nextRunService;
public StatTemplateController( public StatTemplateController(
ITemplateRepository templateService, ITemplateRepository templateRepository,
INextRunService nextRunService INextRunService nextRunService
) )
{ {
this.templateService = templateService; _templateRepository = templateRepository;
this.nextRunService = nextRunService; _nextRunService = nextRunService;
} }
/// <summary> /// <summary>
@@ -41,12 +42,12 @@ namespace PARR.API.Controllers.V1.Statistics
{ {
var response = new StatTemplateResponse var response = new StatTemplateResponse
{ {
ActivateScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveSchedule), ActivateScheduleCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.IsActiveSchedule),
ActivateTemplateCount = await templateService.Get().AsNoTracking().CountAsync(t => t.IsActiveTemplate), ActivateTemplateCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.IsActiveTemplate),
TemplateAgentCount = await templateService.Get().AsNoTracking().CountAsync(t => t.Job!.Group!.IsAgent), TemplateAgentCount = await _templateRepository.Get().AsNoTracking().CountAsync(t => t.Job!.Group!.IsAgent),
TemplateCount = await templateService.Get().AsNoTracking().CountAsync(), TemplateCount = await _templateRepository.Get().AsNoTracking().CountAsync(),
SyncEsppScheduleCount = await templateService.Get().AsNoTracking().CountAsync(t => t.RobotConfigurations.Any(c => c.RobotCode == (int)RobotsEnum.ScheduleOrder && c.TaskStatusCode == (int)TaskStatusEnum.Ok)), SyncEsppScheduleCount = await _templateRepository.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)) 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)); return Ok(new Response<StatTemplateResponse>(response, true));
@@ -71,7 +72,7 @@ namespace PARR.API.Controllers.V1.Statistics
userEnd.AddDays(1), userEnd.AddDays(1),
timeZoneQuery.TimeZoneOffset); timeZoneQuery.TimeZoneOffset);
var allRecords = await templateService.Get() var allRecords = await _templateRepository.Get()
.AsNoTracking() .AsNoTracking()
.FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd) .FilterByDateRangeUtc(t => t.DateCreated, utcStart, utcEnd)
.Select(t => new { t.Id, t.DateCreated }) .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 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 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

@@ -5,6 +5,7 @@ using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.MappingProfiles.Resolvers; using PARR.API.MappingProfiles.Resolvers;
using PARR.Core.Repositories.Interfaces.Schedule; using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Domain.DTOs.Matching; using PARR.Domain.DTOs.Matching;
using PARR.Domain.DTOs.RobotMetrics;
using PARR.Domain.DTOs.RobotSnapshotDTO; using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.DTOs.RobotTask; using PARR.Domain.DTOs.RobotTask;
using PARR.Domain.DTOs.Shortcode; using PARR.Domain.DTOs.Shortcode;
@@ -500,6 +501,14 @@ namespace PARR.API.MappingProfiles
#endregion #endregion
#region StatRobotMetrics
CreateMap<TaskStatusChartPoint, StatTaskStatusChartPoint>();
CreateMap<RobotStatusChartPoint, StatRobotStatusChartPoint>();
CreateMap<FilteredChartPoint, StatFilteredChartPoint>();
#endregion
} }
} }

View File

@@ -2,6 +2,7 @@
using PARR.API.Contracts.V1.Requests; using PARR.API.Contracts.V1.Requests;
using PARR.API.Contracts.V1.Requests.Queries; using PARR.API.Contracts.V1.Requests.Queries;
using PARR.Domain.Common.Pagination; using PARR.Domain.Common.Pagination;
using PARR.Domain.DTOs.RobotMetrics;
using PARR.Domain.DTOs.RobotSnapshotDTO; using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.Entities.JobEntities; using PARR.Domain.Entities.JobEntities;
@@ -52,6 +53,9 @@ namespace PARR.API.MappingProfiles
#endregion #endregion
CreateMap<StatRobotSnapshotQuery, RobotSnapshotQuery>(); CreateMap<StatRobotSnapshotQuery, RobotSnapshotQuery>();
CreateMap<RobotFilteredMetricsQuery, MetricFilter>();
} }
} }
} }

View File

@@ -8,6 +8,7 @@ using PARR.Core.Common.Interfaces;
using PARR.Core.Services.MatchingStatusService; using PARR.Core.Services.MatchingStatusService;
using PARR.Core.Services.NextRunServices; using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.NextRunServices.Subservices; using PARR.Core.Services.NextRunServices.Subservices;
using PARR.Core.Services.RobotMetrics;
using PARR.Core.Services.RobotSnapshotServices; using PARR.Core.Services.RobotSnapshotServices;
using PARR.Core.Services.RobotTask.Implementations; using PARR.Core.Services.RobotTask.Implementations;
using PARR.Core.Services.RobotTask.Interfaces; using PARR.Core.Services.RobotTask.Interfaces;
@@ -28,7 +29,6 @@ using PARR.Core.Services.UnitService.Implementations;
using PARR.Core.Services.UnitService.Interfaces; using PARR.Core.Services.UnitService.Interfaces;
using PARR.Core.Services.Workload.Implementations; using PARR.Core.Services.Workload.Implementations;
using PARR.Core.Services.Workload.Interfaces; using PARR.Core.Services.Workload.Interfaces;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums; using PARR.Domain.Enums;
using PARR.Domain.Settings; using PARR.Domain.Settings;
@@ -115,6 +115,8 @@ namespace PARR.Core
services.AddScoped<IUnitService, UnitService>(); services.AddScoped<IUnitService, UnitService>();
services.AddScoped<UnitCacheService>(); services.AddScoped<UnitCacheService>();
services.AddScoped<IRobotMetricsService, RobotMetricsService>();
//services.AddScoped<IUserService, UserService>(); //services.AddScoped<IUserService, UserService>();
#endregion #endregion

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

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

@@ -11,7 +11,7 @@ namespace PARR.Core.Services.UnitFilterService.Matchers;
/// </summary> /// </summary>
internal class UnitFieldMatcher : IUnitFieldMatcher internal class UnitFieldMatcher : IUnitFieldMatcher
{ {
private const int chunkSize = 1000; private const int chunkSize = 200;
private readonly IUnitRepository unitRepository; private readonly IUnitRepository unitRepository;
private readonly ILogger<UnitFieldMatcher> logger; private readonly ILogger<UnitFieldMatcher> logger;

View File

@@ -22,7 +22,7 @@ namespace PARR.DAL.Repositories.Unit
} }
public IQueryable<PARR.Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<PARR.Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false) public IQueryable<Domain.Entities.Unit.Unit> GetUnitByFieldAndValue(IQueryable<Domain.Entities.Unit.Unit> query, Guid fieldId, string valueMask, bool isInverse = false)
{ {
//TODO: вынесено из UnitFilterService //TODO: вынесено из UnitFilterService

View File

@@ -0,0 +1,9 @@
namespace PARR.Domain.DTOs.RobotMetrics
{
/// <summary>
/// Статистика по роботам, согласно гибким фильтрам.
/// </summary>
/// <param name="Timestamp"></param>
/// <param name="Count"></param>
public record FilteredChartPoint(DateTimeOffset Timestamp, int Count);
}

View File

@@ -0,0 +1,16 @@
using PARR.Domain.Enums;
namespace PARR.Domain.DTOs.RobotMetrics
{
public record MetricFilter
{
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; }
// Шаг группировки в минутах (например, 2, 30, 60, 1440)
public int IntervalMinutes { get; set; } = 30;
}
}

View File

@@ -0,0 +1,7 @@
namespace PARR.Domain.DTOs.RobotMetrics
{
/// <summary>
/// Статистика по Робот Сатусам, на графике
/// </summary>
public record RobotStatusChartPoint(DateTimeOffset Timestamp, int Wait, int InProgress, int Error, int Complete);
}

View File

@@ -0,0 +1,11 @@
namespace PARR.Domain.DTOs.RobotMetrics
{
/// <summary>
/// Статистика по Статусам заданий роботам, на графике
/// </summary>
/// <param name="Timestamp"></param>
/// <param name="Creating"></param>
/// <param name="Updating"></param>
/// <param name="Ok"></param>
public record TaskStatusChartPoint(DateTimeOffset Timestamp, int Creating, int Updating, int Ok);
}

View File

@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace PARR.Domain.Enums
{
/// <summary>
/// Периоды для графиков
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChartPeriod
{
/// <summary>
/// Последние 2 часа
/// </summary>
TwoHours = 1,
/// <summary>
/// Последние 24 часа
/// </summary>
TwentyFourHours = 2,
/// <summary>
/// Последняя неделя
/// </summary>
SevenDays = 3
}
}

View File

@@ -16,26 +16,53 @@ namespace PARR.TemplateMatcher.Services.Implementations
bool defaultTemplateState = false, bool defaultTemplateState = false,
bool defaultScheduleState = false) bool defaultScheduleState = false)
{ {
// Тип группы определяет источник настроек // Защита от оптимизации: если группу забыли подгрузить, метод честно падает,
var isGroupLevel = jobGroup?.GroupType?.IsJobGroupAutoControl == true; // потому что без GroupType бизнес-логика не может определить уровень управления
if (jobGroup == null)
if (isGroupLevel && jobGroup!.AutoControl != null)
{ {
throw new InvalidOperationException(
$"Критическая ошибка бизнес-логики: Для работы '{job.Name}' (ID: {job.Id}) " +
$"не передана группа (null). Нужно добавить '.Include(j => j.Group)'.");
}
// 1. Проверяем, что разработчики подгрузили GroupType из базы данных
if (jobGroup.GroupType == null)
{
throw new InvalidOperationException(
$"Для работы '{job.Name}' (ID: {job.Id}) передана группа, " +
$"но её GroupType = null. Нужно добавить '.ThenInclude(g => g.GroupType)' в запрос.");
}
// Теперь компилятор знает, что jobGroup и GroupType гарантированно не null
var isGroupLevel = jobGroup.GroupType.IsJobGroupAutoControl;
// 2. Сценарий: Управление на уровне Группы Работ
if (isGroupLevel)
{
if (jobGroup.AutoControl == null)
throw new InvalidOperationException(
$"В типе группы '{jobGroup.GroupType.Id}' указано управление " +
$"на уровне ГРУППЫ, но у группы '{jobGroup.GroupName}' (ID: {jobGroup.Id}) " +
$"отсутствуют настройки автоконтроля (JobGroup.AutoControl равен null)!");
return ( return (
jobGroup.AutoControl.InitUsedTemplateState, jobGroup.AutoControl.InitUsedTemplateState,
jobGroup.AutoControl.InitUsedScheduleState jobGroup.AutoControl.InitUsedScheduleState
); );
} }
if (!isGroupLevel && job.AutoControl != null) // 3. Сценарий: Управление на уровне конкретной Работы
{ if (job.AutoControl == null)
return ( throw new InvalidOperationException(
job.AutoControl.InitUsedTemplateState, $"В типе группы '{jobGroup.GroupType.Id}' указано управление " +
job.AutoControl.InitUsedScheduleState $"на уровне РАБОТЫ, но у работы '{job.Name}' (ID: {job.Id}) " +
); $"отсутствуют настройки автоконтроля (Job.AutoControl равен null)!");
}
return (defaultTemplateState, defaultScheduleState); return (
job.AutoControl.InitUsedTemplateState,
job.AutoControl.InitUsedScheduleState
);
} }
} }
} }

View File

@@ -26,7 +26,7 @@ internal class LoadJobStage : ISimpleSyncStage
.Include(j => j.AutoControl) .Include(j => j.AutoControl)
.Include(j => j.Tnk) .Include(j => j.Tnk)
.Include(j => j.Group).ThenInclude(g => g!.GroupType) .Include(j => j.Group).ThenInclude(g => g!.GroupType)
.Include(j => j.AutoControl) .Include(j => j.Group).ThenInclude(g => g!.AutoControl)
.Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters) .Include(j => j.UnitFilters).ThenInclude(uf => uf.RelationshipFilters)
.FirstOrDefaultAsync(j => j.Id == context.JobId, ct); .FirstOrDefaultAsync(j => j.Id == context.JobId, ct);