feat(api,core): Методы в API для получения метрик заданий роботам, StatRobotMetricsController.

This commit is contained in:
Mikhail Trubnikov
2026-07-16 10:12:56 +10:00
parent 4c80e247f5
commit 751b693e72
17 changed files with 540 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ using PARR.Core.Common.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.RobotTask.Implementations;
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.Workload.Implementations;
using PARR.Core.Services.Workload.Interfaces;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Enums;
using PARR.Domain.Settings;
@@ -115,6 +115,8 @@ namespace PARR.Core
services.AddScoped<IUnitService, UnitService>();
services.AddScoped<UnitCacheService>();
services.AddScoped<IRobotMetricsService, RobotMetricsService>();
//services.AddScoped<IUserService, UserService>();
#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;
}
}
}
}