Compare commits

..

19 Commits

Author SHA1 Message Date
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
Mikhail Kuznetsov
4c80e247f5 feat(dal): Добавлен параметр конфигурации MinRelationshipsThresholdPct 2026-07-14 10:29:01 +10:00
Mikhail Trubnikov
83017c6bee fix(snapshotWorker): Исправлен cicd 2026-07-13 16:46:10 +10:00
Mikhail Trubnikov
323231f907 fix(snapshotWorker): Исправлен cicd 2026-07-13 16:34:59 +10:00
Mikhail Trubnikov
b1c3d90b40 fix(dal,snapshotsWorker): Исправлен захват роботом задачи, добавлено увеличение попыток. Исправлен dockerfile для снапшотов. 2026-07-13 16:16:37 +10:00
Mikhail Trubnikov
511721b798 fix(snapshotWorker): Исправлен dockerfile 2026-07-13 15:52:56 +10:00
Mikhail Trubnikov
9f42a733e7 fix: Cicd для SnapshotWorker 2026-07-13 15:46:40 +10:00
Mikhail Trubnikov
50556ef605 feat(core,dal,domain,snapshotWorker): Воркер по управлению снапшотами. Создание сгапшотов для таблицы RobotConfigurations 2026-07-13 15:29:48 +10:00
Mikhail Trubnikov
9194d127d2 fix(core): Задания роботам 2026-07-10 18:35:18 +10:00
Mikhail Trubnikov
33b0576ccc feat(api,core): Задания роботам на обновление расписаний, выдается с периодом охлаждения, если последнее изменение было NextRunWorker или EsppScheduleWorker и значение lastRun+ период охлаждения, меньше чем сейчас. 2026-07-10 14:11:16 +10:00
Mikhail Trubnikov
33cdc29e80 feat(api): RobotHistory - фильтрация по дате 2026-07-09 09:02:29 +10:00
Mikhail Trubnikov
d7d4713953 fix(core): NextRun расчет ЕСПП, исправлен метод регулярного расчета 2026-07-08 14:47:59 +10:00
Mikhail Trubnikov
4af552b29e Merge branch 'dev' of gitlab.dvgd.oao.rzd:devptk/parr/parr_api into dev 2026-07-07 15:33:55 +10:00
Mikhail Trubnikov
9d3273191b fix(core): Доработка методов расчета NextRun по алгоритму ЕСПП, добавлена проверка, что расчитанная дата новее чем refDate. 2026-07-07 15:24:59 +10:00
Mikhail Kuznetsov
246e8e69ae feat(api): Актуализирован параметр ThresholdConnections для мониторинга инфраструктуры. 2026-07-03 16:32:54 +10:00
Mikhail Kuznetsov
e9c9e9f03e feat(nextRun): Увеличено количество Workerов до 4 2026-07-03 15:26:45 +10:00
Mikhail Kuznetsov
fb9578fec0 feat(templateMatcher): В синхронизацию неиспользуемых шаблонов добавлена метрика времени выполнения 2026-07-03 14:01:56 +10:00
49 changed files with 10019 additions and 1010 deletions

View File

@@ -20,6 +20,7 @@ variables:
PROD_TEMPLATE_UPDATER: "parr/parr-template-updater" PROD_TEMPLATE_UPDATER: "parr/parr-template-updater"
PROD_WORKLOAD_BUILDER: "parr/parr-workload-builder" PROD_WORKLOAD_BUILDER: "parr/parr-workload-builder"
PROD_TASK_RECONCILIATION: "parr/parr-task-reconciliation" PROD_TASK_RECONCILIATION: "parr/parr-task-reconciliation"
PROD_SNAPSHOTS: "parr/parr-snapshots"
stages: stages:
@@ -1010,3 +1011,61 @@ prod_task_reconciliation_deploy:
- RUNNER: shell-api-swarm-01 - RUNNER: shell-api-swarm-01
tags: tags:
- ${RUNNER} - ${RUNNER}
### SNAPSHOTS PROD ###
prod_snapshots_build:
stage: build
only:
- /^sn[0-9]+\.[0-9]+\.[0-9]+$/
except:
- branches
services:
- name: docker:20.10.21-dind
command: [
"--insecure-registry=10.99.253.167:8090",
"--registry-mirror=http://10.99.253.167:8090",
"--insecure-registry=harbor.dvgd.rzd",
"--tls=false"
]
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
script:
- APP_VERSION=$(echo $CI_COMMIT_TAG | tr -d sn)
- IMAGE_VERSION=$(echo $CI_COMMIT_TAG | sed 's/sn/v/g')
- AUTHOR=$CI_COMMIT_AUTHOR
- |
docker build \
-t $REPO/$PROD_SNAPSHOTS:$IMAGE_VERSION \
-t $REPO/$PROD_SNAPSHOTS:latest \
-t $PROD_SNAPSHOTS:$IMAGE_VERSION \
-t $PROD_SNAPSHOTS:latest \
--build-arg app_version=$APP_VERSION \
--build-arg commit_author="$AUTHOR" \
-f PARR.SnapshotWorker/Dockerfile .
- docker login -u $HARBOR_PUSH_USER -p $HARBOR_PUSH_PASS $REPO
- docker push --all-tags $REPO/$PROD_SNAPSHOTS
tags:
- docker
prod_snapshots_deploy:
stage: deploy
environment:
name: parr-snapshots
only:
- /^sn[0-9]+\.[0-9]+\.[0-9]+$/
except:
- branches
script:
- IMAGE_VERSION=$(echo $CI_COMMIT_TAG | sed 's/sn/v/g')
- docker login -u $HARBOR_PULL_USER -p $HARBOR_PULL_PASS $REPO
#- tag=$CI_COMMIT_TAG docker compose up -d
- tag=$IMAGE_VERSION docker stack deploy -c docker-compose.snapshots.yml parr-snapshots --with-registry-auth
parallel:
matrix:
- RUNNER: shell-api-swarm-01
tags:
- ${RUNNER}

View File

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

View File

@@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.WorkloadBuilderWorker"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TaskReconciliationWorker", "PARR.TaskReconciliationWorker\PARR.TaskReconciliationWorker.csproj", "{DB701295-1696-4E1A-9088-D3AECF823BA6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TaskReconciliationWorker", "PARR.TaskReconciliationWorker\PARR.TaskReconciliationWorker.csproj", "{DB701295-1696-4E1A-9088-D3AECF823BA6}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.SnapshotWorker", "PARR.SnapshotWorker\PARR.SnapshotWorker.csproj", "{F5318808-942F-4EFB-9BC9-00B9F4704EB5}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -275,6 +277,10 @@ Global
{DB701295-1696-4E1A-9088-D3AECF823BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU {DB701295-1696-4E1A-9088-D3AECF823BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB701295-1696-4E1A-9088-D3AECF823BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB701295-1696-4E1A-9088-D3AECF823BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB701295-1696-4E1A-9088-D3AECF823BA6}.Release|Any CPU.Build.0 = Release|Any CPU {DB701295-1696-4E1A-9088-D3AECF823BA6}.Release|Any CPU.Build.0 = Release|Any CPU
{F5318808-942F-4EFB-9BC9-00B9F4704EB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F5318808-942F-4EFB-9BC9-00B9F4704EB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5318808-942F-4EFB-9BC9-00B9F4704EB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5318808-942F-4EFB-9BC9-00B9F4704EB5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -2,22 +2,31 @@
namespace PARR.API.Contracts.V1.Requests.Queries namespace PARR.API.Contracts.V1.Requests.Queries
{ {
public class RobotHistoryQuery public record RobotHistoryQuery
{ {
/// <summary> /// <summary>
/// Фильтр по ИД шаблона /// Фильтр по ИД шаблона
/// </summary> /// </summary>
public Guid? TemplateId { get; set; } public Guid? TemplateId { get; init; }
/// <summary> /// <summary>
/// Фильтр по ИД робота /// Фильтр по ИД робота
/// </summary> /// </summary>
public RobotsEnum? RobotCode { get; set; } public RobotsEnum? RobotCode { get; init; }
/// <summary> /// <summary>
/// Фильтр по уровню истории /// Фильтр по уровню истории
/// </summary> /// </summary>
public RobotHistoryLevelEnum? HistoryLevel { get; set; } public RobotHistoryLevelEnum? HistoryLevel { get; init; }
/// <summary>
/// Дата начала
/// </summary>
public DateTimeOffset? DateFrom { get; init; }
/// <summary>
/// Дата окончания
/// </summary>
public DateTimeOffset? DateTo { get; init; }
} }
} }

View File

@@ -62,6 +62,7 @@ namespace PARR.API.Controllers.V1
var paginationFilter = mapper.Map<PaginationFilter>(paginationQuery); var paginationFilter = mapper.Map<PaginationFilter>(paginationQuery);
IQueryable<RobotHistory> query = robotHistoryService.Get() IQueryable<RobotHistory> query = robotHistoryService.Get()
.AsNoTracking()
.Include(t => t.RobotConfiguration) .Include(t => t.RobotConfiguration)
.ThenInclude(t => t!.Template) .ThenInclude(t => t!.Template)
.Include(t => t.RobotConfiguration) .Include(t => t.RobotConfiguration)
@@ -79,19 +80,38 @@ namespace PARR.API.Controllers.V1
if (request.HistoryLevel.HasValue) if (request.HistoryLevel.HasValue)
query = query.Where(t => t.HistoryLevel == (int)request.HistoryLevel); query = query.Where(t => t.HistoryLevel == (int)request.HistoryLevel);
if (request.DateFrom.HasValue)
{
var startDateUtc = request.DateFrom.Value.ToUniversalTime();
query = query.Where(t => t.DateCreated >= startDateUtc);
}
if (request.DateTo.HasValue)
{
var endDateUtc = request.DateTo.Value.ToUniversalTime();
query = query.Where(t => t.DateCreated < endDateUtc);
}
var history = await robotHistoryService.GetPage(query, paginationFilter).ToListAsync(); var history = await robotHistoryService.GetPage(query, paginationFilter).ToListAsync();
if (!history.Any()) if (history.Count == 0)
return NoContent(); return NoContent();
var robotsIp = history.Where(t => !string.IsNullOrEmpty(t.RobotIp)).Select(t => t.RobotIp).Distinct().ToList(); var robotsIp = history.Where(t => !string.IsNullOrEmpty(t.RobotIp)).Select(t => t.RobotIp).Distinct().ToList();
var users = await userService.Get().Where(t => robotsIp.Any(x => x == t.Ip)).Distinct().ToListAsync(); var usersDictionary = await userService.Get()
.AsNoTracking()
.Where(t => robotsIp.Contains(t.Ip))
.ToDictionaryAsync(t => t.Ip, t => t);
var response = mapper.Map<List<RobotHistoryResponse>>(history); var response = mapper.Map<List<RobotHistoryResponse>>(history);
response.ForEach(item => foreach (var item in response)
{ {
item.User = mapper.Map<UserBaseResponse>(users.FirstOrDefault(t => t.Ip == item.RobotIp)); if (!string.IsNullOrWhiteSpace(item.RobotIp) && usersDictionary.TryGetValue(item.RobotIp, out var user))
}); {
item.User = mapper.Map<UserBaseResponse>(user);
}
}
var paginationResponse = new PagedResponse<RobotHistoryResponse>(response, true).GetPaginatedProps(paginationFilter, query); var paginationResponse = new PagedResponse<RobotHistoryResponse>(response, true).GetPaginatedProps(paginationFilter, query);
return Ok(paginationResponse); return Ok(paginationResponse);

View File

@@ -7,6 +7,7 @@ using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Base; using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Controllers.V1.Base; using PARR.API.Controllers.V1.Base;
using PARR.API.Services.Interfaces; using PARR.API.Services.Interfaces;
using PARR.API.Settings;
using PARR.Core.Services.RobotTask.Interfaces; using PARR.Core.Services.RobotTask.Interfaces;
using PARR.Domain.Common.Roles; using PARR.Domain.Common.Roles;
using PARR.Domain.Entities.Base.History; using PARR.Domain.Entities.Base.History;
@@ -18,38 +19,22 @@ namespace PARR.API.Controllers.V1
[Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)] [Authorize(Roles = ParrRoles.EsppRobot.RoleOrAdmin)]
public class RobotTaskController : BaseApiController public class RobotTaskController : BaseApiController
{ {
private readonly IRobotTaskService robotTaskService; private readonly IRobotTaskService _robotTaskService;
private readonly CommonSettings _commonSettings;
private readonly IMapper mapper; private readonly IMapper _mapper;
//private readonly SettingsFromDb settingsFromDb; private readonly IClientService _clientService;
//private readonly IRobotConfigurationRepository robotConfigurationService;
//private readonly ILogger<RobotTaskController> logger;
private readonly IClientService clientService;
//private readonly IRobotHistoryRepository robotHistoryService;
//private readonly IShortcodesService shortcodesService;
//private readonly INextRunService nextRunService;
public RobotTaskController( public RobotTaskController(
IMapper mapper, IMapper mapper,
//SettingsFromDb settingsFromDb,
//IRobotConfigurationRepository robotConfigurationService,
//ILogger<RobotTaskController> logger,
IClientService clientService, IClientService clientService,
//IRobotHistoryRepository robotHistoryService, IRobotTaskService robotTaskService,
//IShortcodesService shortcodesService, CommonSettings commonSettings
//INextRunService nextRunService
IRobotTaskService robotTaskService
) )
{ {
this.robotTaskService = robotTaskService; _robotTaskService = robotTaskService;
this.mapper = mapper; _commonSettings = commonSettings;
//this.settingsFromDb = settingsFromDb; _mapper = mapper;
//this.robotConfigurationService = robotConfigurationService; _clientService = clientService;
//this.logger = logger;
this.clientService = clientService;
//this.robotHistoryService = robotHistoryService;
//this.shortcodesService = shortcodesService;
//this.nextRunService = nextRunService;
} }
@@ -66,314 +51,37 @@ namespace PARR.API.Controllers.V1
switch (robotCode) switch (robotCode)
{ {
case RobotsEnum.TemplateOrder: case RobotsEnum.TemplateOrder:
var templateTask = await robotTaskService.GetTemplateTaskAsync( var templateTask = await _robotTaskService.GetTemplateTaskAsync(
taskStatusCode, taskStatusCode,
requestQuery.SetInProgressStatus ?? false, requestQuery.SetInProgressStatus ?? false,
clientService.GetClientIp()?.ToString(), _clientService.GetClientIp()?.ToString(),
requestQuery.RobotId requestQuery.RobotId
); );
var templateResponse = mapper.Map<RobotTaskTemplateResponse>(templateTask); var templateResponse = _mapper.Map<RobotTaskTemplateResponse>(templateTask);
return Ok(new Response<RobotTaskTemplateResponse>(templateResponse, true)); return Ok(new Response<RobotTaskTemplateResponse>(templateResponse, true));
case RobotsEnum.ScheduleOrder: case RobotsEnum.ScheduleOrder:
var historyIniciator = new HistoryInitiator var historyIniciator = new HistoryInitiator
{ {
InitiatorComment = "Задание роботу, расписание.", InitiatorComment = "Задание роботу, расписание.",
InitiatorIp=clientService.GetClientIp()?.ToString(), InitiatorIp = _clientService.GetClientIp()?.ToString(),
InitiatorParrComponentId = ParrComponentsEnum.Api InitiatorParrComponentId = ParrComponentsEnum.Api
}; };
var scheduleTask = await robotTaskService.GetScheduleTaskAsync( var scheduleTask = await _robotTaskService.GetScheduleTaskAsync(
taskStatusCode, taskStatusCode,
requestQuery.SetInProgressStatus ?? false, requestQuery.SetInProgressStatus ?? false,
historyIniciator.InitiatorIp, historyIniciator.InitiatorIp,
requestQuery.RobotId, requestQuery.RobotId,
historyIniciator historyIniciator,
_commonSettings.ScheduleCooldownDuration
); );
var scheduleResponse = mapper.Map<RobotTaskScheduleResponse>(scheduleTask); var scheduleResponse = _mapper.Map<RobotTaskScheduleResponse>(scheduleTask);
return Ok(new Response<RobotTaskScheduleResponse>(scheduleResponse, true)); return Ok(new Response<RobotTaskScheduleResponse>(scheduleResponse, true));
default: default:
throw new AppValidationException("Некорректное значение robotCode"); throw new AppValidationException("Некорректное значение robotCode");
} }
} }
#region Old
// /// <summary>
// /// Получить задание для робота по коду робота и по статусу задания
// /// </summary>
// /// <param name="robotCode"></param>
// /// <param name="taskStatusCode"></param>
// /// <returns></returns>
// [HttpGet(ApiRoutes.RobotTask.GetByRobotAndStatusTask)]
// public async Task<IActionResult> GetByRobotAndStatusTask([FromRoute] RobotsEnum robotCode, [FromRoute] TaskStatusEnum taskStatusCode, [FromQuery] RobotTaskQuery requestQuery)
// {
// //Ищем все задания с превышенным кол-вом попыток и с просроченным временем и ставим им статус ошибки
// await robotConfigurationService.MarkExpiredTasksAsFailedAsync(settingsFromDb.RobotAttemptsNumber, settingsFromDb.RobotWaitTime);
// var query = robotConfigurationService.Get()
// .AsSingleQuery()
// .Where(t => t.RobotCode == (int)robotCode && t.TaskStatusCode == (int)taskStatusCode);
// switch (robotCode)
// {
// case RobotsEnum.TemplateOrder:
// // шаблоны
// query = query
// .Include(t => t.Template)
// .ThenInclude(t => t!.Unit)
// .ThenInclude(t => t!.UnitValues)
// .ThenInclude(t => t.Field)
// .Include(t => t.Template)
// .ThenInclude(t => t!.Unit)
// .ThenInclude(t => t!.UnitValues)
// .ThenInclude(t => t.Value)
// .Include(t => t.Template)
// .ThenInclude(a => a!.Job)
// .ThenInclude(t => t!.Group)
// .ThenInclude(g => g.GroupType)
// .Include(t => t.Template)
// .ThenInclude(w => w!.Job)
// .ThenInclude(t => t!.Tnk)
// .ThenInclude(s => s!.Subprocess)
// .ThenInclude(p => p!.Process);
// query = query
// .Include(t => t.Template)
// .ThenInclude(t => t!.UnitsInTemplate);
// break;
// case RobotsEnum.ScheduleOrder:
// //расписание
// query = query
// .Include(t => t.Template)
// .ThenInclude(t => t!.Unit)
// .ThenInclude(t => t!.UnitValues)
// .ThenInclude(t => t.Field)
// .Include(t => t.Template)
// .ThenInclude(t => t!.Unit)
// .ThenInclude(t => t!.UnitValues)
// .ThenInclude(t => t.Value)
// .Include(t => t.Template)
// .ThenInclude(a => a!.Job)
// .ThenInclude(t => t!.Group)
// .ThenInclude(g => g.GroupType)
// .Include(t => t.Template)
// .ThenInclude(a => a!.Job)
// .ThenInclude(t => t!.Group)
// .ThenInclude(t => t!.EsppSchValues)
// .ThenInclude(t => t!.EsppSchTypeConfig)
// .ThenInclude(t => t!.EsppSchTypeSchedule)
// .Include(t => t.Template)
// .ThenInclude(t => t!.Job)
// .ThenInclude(t => t!.Group)
// .ThenInclude(t => t!.ScheduleExcludeType)
// .Include(t => t.Template)
// .ThenInclude(t => t!.Job)
// .ThenInclude(t => t!.Group)
// .ThenInclude(t => t.ScheduleExcludeTypeCalendar)
// .Include(t => t.Template)
// .ThenInclude(t => t!.Job)
// .ThenInclude(t => t!.Tnk)
// .Include(t => t.Template)
// .ThenInclude(t => t!.UnitsInTemplate);
// //выбираем только записи с созданными шаблонами (у которых статус 20 или 30), а только потом у них ищем расписания
// var createdTemplates = robotConfigurationService.Get()
// .Where(t => t.RobotCode == (int)RobotsEnum.TemplateOrder && (t.TaskStatusCode == (int)TaskStatusEnum.Ok))
// .Select(t => t.TemplateId);
// query = query.Where(t => t.RobotCode == (int)RobotsEnum.ScheduleOrder && createdTemplates.Contains(t.TemplateId));
// // query = query.Where(t => t.RobotCode == (int)RobotsEnum.ScheduleOrder && t.TemplateId==Guid.Parse("7cb7c3be-506d-40e0-a63f-4554edb52459"));
// break;
// default:
// break;
// }
// // сортируем по NextRun, чтобы те у которых дата след срабатывания ближе к текущей, выполнились скорее
// query = query.OrderBy(t => t.Template!.NextRun).ThenBy(t => t.Template!.IsActiveSchedule).ThenBy(t => t.Template.IsActiveTemplate);
// RobotConfiguration? task = null;
// //ищем задание в ожидании, если нашли, выбираем его
// task = await query.FirstOrDefaultAsync(t => t.RobotStatusCode == (int)RobotStatusEnum.Wait);
// if (task == null)
// {
// //ищем задания в работе, которые можно перезапустить
// //Поиск по `RobotStatusCode` = 22.
// //Далее проверяется `LastStatusUpdated`, что время последнего смены статуса не превышает допустимого(берется из настроек, поле `RobotWaitTime`)
// //и что текущая попытка не больше разрешенной(берется из настроек, поле `RobotAttemptsNumber`) - если это так, берется эта запись.
// var endDate = DateTimeOffset.UtcNow.Add(-settingsFromDb.RobotWaitTime);
// task = await query
// .FirstOrDefaultAsync(t =>
// t.RobotStatusCode == (int)RobotStatusEnum.InProgress
// && t.AttemptsNumber < settingsFromDb.RobotAttemptsNumber
// && t.LastRobotStatusUpdated < endDate
// );
// }
// if (task == null)
// return NotFound();
// if (requestQuery?.SetInProgressStatus == true)
// {
// var resultSetStatus = await SetInProgressStatusAsync(task.Id);
// if (resultSetStatus == false)
// {
// logger.LogError($"Ошибка при установке статуса {RobotStatusEnum.InProgress.ToString()} для задания RobotConfigutationId {task.Id} (при выдаче задания роботу)");
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = "Ошибка при выдаче задания." } }));
// }
// }
// switch (robotCode)
// {
// case RobotsEnum.TemplateOrder:
// { //RobotTaskTemplateResponse
// var robotTaskTemplateResponse = mapper.Map<RobotTaskTemplateResponse>(task);
// robotTaskTemplateResponse.FullDescription = NormalizeLineEndingsToCrlf(await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.FullDescription, task.Template!));
// robotTaskTemplateResponse.ShortDescription = await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.ShortDescription, task.Template!);
// robotTaskTemplateResponse.Solution = NormalizeLineEndingsToCrlf(await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.Solution, task.Template!));
// robotTaskTemplateResponse.TnkName = await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.TnkName, task.Template!);
// robotTaskTemplateResponse.WorkName = await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.WorkName, task.Template!);
// robotTaskTemplateResponse.WorkGroup = await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.WorkGroup, task.Template!);
// robotTaskTemplateResponse.ResponseArea = await shortcodesService.ApplyShortcodesAsync(robotTaskTemplateResponse.ResponseArea, task.Template!);
// return Ok(new Response<RobotTaskTemplateResponse>(robotTaskTemplateResponse, true));
// }
// case RobotsEnum.ScheduleOrder:
// { // если был запрос на расписание, проверяем у него nextRun, lastRun, обновляем их
// var resultUpdateNextRun = await UpdateNextRunAsync(task);
// if (!resultUpdateNextRun)
// {
// logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}", task.TemplateId);
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = "Ошибка при расчете NextRun" } }));
// }
// //RobotTaskScheduleResponse
// var robotTaskScheduleResponse = mapper.Map<RobotTaskScheduleResponse>(task);
// robotTaskScheduleResponse.Timezone = settingsFromDb.EsppScheduleTimezone;
// robotTaskScheduleResponse.WorkGroup = await shortcodesService.ApplyShortcodesAsync(robotTaskScheduleResponse.WorkGroup, task.Template!);
// robotTaskScheduleResponse.ResponseArea = await shortcodesService.ApplyShortcodesAsync(robotTaskScheduleResponse.ResponseArea, task.Template!);
// //var nextRunWithRobotTz = nextRunService.GetNextRunWithTimezoneEsppAndResponseArea(task.Template!.NextRun, task.Template!.Job?.Group?.IsResponseAreaTimezone, robotTaskScheduleResponse.ResponseArea);
// //nextRun в часовой зоне УЗ Робота ЕСПП
// var nextRunWithRobotTz = task.Template!.NextRun.Add(nextRunService.GetEsppAccountOffset());
// //на всякий случай еще раз проверяем, что дата не устарела и отправляем задание
// if (nextRunWithRobotTz < DateTimeOffset.UtcNow)
// {
// logger.LogError("Ошибка при расчете NextRun для templateId: {templateId}, итоговое значение для робота, меньше чем сейчас {nextRunWithRobotTz}<{now}",
// task.TemplateId, nextRunWithRobotTz, DateTimeOffset.UtcNow);
// return BadRequest(new Response(false, new List<ErrorModel> { new ErrorModel { Message = "Ошибка при расчете NextRun" } }));
// }
// robotTaskScheduleResponse.NextStart = EsppScheduleHelpers.GetNextRun(nextRunWithRobotTz);
// robotTaskScheduleResponse.GenerationTime = EsppScheduleHelpers.GetGenerationTime(nextRunWithRobotTz);
// return Ok(new Response<RobotTaskScheduleResponse>(robotTaskScheduleResponse, true));
// }
// default:
// break;
// }
// return BadRequest();
// }
// /// <summary>
// /// Обоновить NextRun если он устарел
// /// </summary>
// /// <param name="task"></param>
// /// <returns></returns>
// private async Task<bool> UpdateNextRunAsync(RobotConfiguration task)
// {
// var template = task.Template!;
// //var nextRun = await esppScheduleTransformService.GetNextDateAsync(template.Job!.GroupId, template!.Job!.Group!.ReferenceDate);
// var nextRun = await nextRunService.GetNextRunForTemplateAsync(template.Id, false);
// if (!nextRun.HasValue)
// {
// 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);
// return false;
// }
// if (nextRun != template.NextRun)
// {
// logger.LogDebug($"Для шаблона id {template.Id} обновляю nextRun, новое значение {nextRun}, старое значение {template.NextRun}");
// template.LastRun = template.NextRun;
// template.NextRun = nextRun.Value;
// await robotConfigurationService.CommitAsync(new HistoryInitiator { InitiatorComment = "При получении задания роботом, обновил NextRun", InitiatorIp = clientService.GetClientIp()?.ToString(), InitiatorParrComponentId = ParrComponentsEnum.Api });
// }
// return true;
// }
// /// <summary>
// /// Устанавливаем статус "Робот взял в работу", пишем в историю работы роботов инф о начале работ
// /// </summary>
// /// <param name="taskId"></param>
// /// <returns></returns>
// private async Task<bool> SetInProgressStatusAsync(Guid taskId)
// {
// var config = await robotConfigurationService.GetAsync(taskId);
// //изменение статуса робота
// robotConfigurationService.ChangeRobotStatus(RobotStatusEnum.InProgress, config!);
// if (!await robotConfigurationService.CommitAsync())
// return false;
// //записываем в лог робота
// var history = new RobotHistory
// {
// Id = Guid.NewGuid(),
// HistoryLevel = (int)RobotHistoryLevelEnum.Start,
// TaskStatusCode = config.TaskStatusCode,
// RobotConfigurationId = config.Id,
// RobotIp = clientService.GetClientIp()?.ToString()
// };
// if (!await robotHistoryService.CreateAsync(history) || !await robotHistoryService.CommitAsync())
// return false;
// return true;
// }
// /// <summary>
// /// Приводит переносы строк в тексте к формату CRLF (\r\n)
// /// </summary>
// /// <param name="text">Исходный текст</param>
// /// <returns>Текст с унифицированными переносами строк</returns>
// private string NormalizeLineEndingsToCrlf(string? text)
// {
// if (string.IsNullOrEmpty(text))
// return string.Empty;
// // Заменяем любые варианты переносов (\r\n, \r, \n) на единый \r\n
// return System.Text.RegularExpressions.Regex.Replace(text, @"\r\n|\r|\n", "\r\n");
// }
#endregion
} }
} }

View File

@@ -23,6 +23,10 @@ namespace PARR.API.Installers
configuration.GetSection(nameof(MonitoringSettings)).Bind(monitoringSettings); configuration.GetSection(nameof(MonitoringSettings)).Bind(monitoringSettings);
services.AddSingleton(monitoringSettings); services.AddSingleton(monitoringSettings);
var commonSettings = new CommonSettings();
configuration.GetSection(nameof(CommonSettings)).Bind(commonSettings);
services.AddSingleton(commonSettings);
//TODO: add other //TODO: add other
} }
} }

View File

@@ -0,0 +1,15 @@
namespace PARR.API.Settings
{
/// <summary>
/// Общие настройки API
/// </summary>
public record CommonSettings
{
/// <summary>
/// Период охлаждения (кулдаун) для расписаний.
/// Запрещает повторно брать активные шаблоны в работу, если с момента их последнего запуска прошло меньше этого времени.
/// Применяется только для инициаторов EsppScheduleSync и NextRun.
/// </summary>
public TimeSpan ScheduleCooldownDuration { get; init; } = TimeSpan.Zero;
}
}

View File

@@ -111,7 +111,10 @@
} }
], ],
"RabbitMq": { "RabbitMq": {
"ThresholdConnections": 29 "ThresholdConnections": 33
} }
},
"CommonSettings": {
"ScheduleCooldownDuration": "03:00:00"
} }
} }

View File

@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PARR.Core.Common.Helpers; using PARR.Core.Common.Helpers;
using PARR.Core.Common.Implementations; using PARR.Core.Common.Implementations;
using PARR.Core.Common.Interfaces; using PARR.Core.Common.Interfaces;
@@ -12,6 +13,8 @@ using PARR.Core.Services.RobotTask.Implementations;
using PARR.Core.Services.RobotTask.Interfaces; using PARR.Core.Services.RobotTask.Interfaces;
using PARR.Core.Services.Shortcodes; using PARR.Core.Services.Shortcodes;
using PARR.Core.Services.Shortcodes.Handlers; using PARR.Core.Services.Shortcodes.Handlers;
using PARR.Core.Services.Snapshots.Implementations;
using PARR.Core.Services.Snapshots.Interfaces;
using PARR.Core.Services.TaskServices.Handlers; using PARR.Core.Services.TaskServices.Handlers;
using PARR.Core.Services.TaskServices.Handlers.Factory; using PARR.Core.Services.TaskServices.Handlers.Factory;
using PARR.Core.Services.TaskServices.Implementations; using PARR.Core.Services.TaskServices.Implementations;
@@ -25,6 +28,7 @@ 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 +119,16 @@ namespace PARR.Core
#endregion #endregion
#region Сервисы сбора снапшотов
services.TryAddSingleton<ISnapshotSettings, DefaultSnapshotSettings>();
services.AddScoped<ISnapshotProvider, RobotConfigurationSnapshotService>();
services.AddScoped<ISnapshotProvider, RobotSnapshotService>();
// тут другие сервисы, реализующие ISnapshotProvider
#endregion
#region Workload #region Workload
services.AddScoped<WorkloadCacheService>(); services.AddScoped<WorkloadCacheService>();

View File

@@ -0,0 +1,9 @@
using PARR.Core.Repositories.Base;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.Core.Repositories.Interfaces.RobotRepositories
{
public interface IRobotConfigurationSnapshotRepository : IBaseRepository<RobotConfigurationSnapshot>
{
}
}

View File

@@ -70,23 +70,12 @@ namespace PARR.Core.Services.NextRunServices.Subservices
var nextRun = referenceDate; var nextRun = referenceDate;
//var finalOffset = new TimeSpan(3, 0, 0) + (offset ?? TimeSpan.Zero);
var finalOffset = offset ?? TimeSpan.Zero; var finalOffset = offset ?? TimeSpan.Zero;
var offsetReferenceDate = referenceDate.ToOffset(finalOffset); var offsetReferenceDate = referenceDate.ToOffset(finalOffset);
logger.LogDebug("Дата после добавления offset - {offsetReferenceDate}", offsetReferenceDate); logger.LogDebug("Дата после добавления offset - {offsetReferenceDate}", offsetReferenceDate);
////---------
//// Для рассчета по МСК времени, потому что в ЮТС может быть еще ВСК, а по МСК это уже ПНД
//var offsetReferenceDate = referenceDate.ToOffset(new TimeSpan(3, 0, 0));
////---------
//// может быть offset ,это работает когда смещение для УЗ ЕСПП и применение ЗО РГ
//if (offset.HasValue)
// offsetReferenceDate = offsetReferenceDate.Off(offsetReferenceDate.Offset + offset);
// //offsetReferenceDate = offsetReferenceDate.Add(offset.Value);
switch (esppSchedule.TypeSchedule.Id) switch (esppSchedule.TypeSchedule.Id)
{ {
@@ -108,6 +97,9 @@ namespace PARR.Core.Services.NextRunServices.Subservices
case (int)EsppSchTypeScheduleEnum.Annually2: case (int)EsppSchTypeScheduleEnum.Annually2:
nextRun = GetNextDateAnnually2(esppSchedule.Values, offsetReferenceDate); nextRun = GetNextDateAnnually2(esppSchedule.Values, offsetReferenceDate);
break; break;
default:
logger.LogError("Неподдерживаемый тип расписания ЕСПП: {TypeId}", esppSchedule.TypeSchedule.Id);
throw new NotSupportedException($"Тип расписания {esppSchedule.TypeSchedule.Id} не поддерживается.");
} }
logger.LogDebug("Рассчитанная дата, с учетом offset: {nextRun}", nextRun); logger.LogDebug("Рассчитанная дата, с учетом offset: {nextRun}", nextRun);
@@ -139,6 +131,14 @@ namespace PARR.Core.Services.NextRunServices.Subservices
return esppSchedule; return esppSchedule;
} }
/// <summary>
/// Регулярно. Каждые 1 час, 2 часа, Каждые пол года...
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private DateTimeOffset GetNextDateRegularly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateRegularly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
var regDict = new Dictionary<string, int> var regDict = new Dictionary<string, int>
@@ -159,27 +159,63 @@ namespace PARR.Core.Services.NextRunServices.Subservices
{"Каждые полгода", 4380}, {"Каждые полгода", 4380},
{"Каждые 1,5 года", 13140}, {"Каждые 1,5 года", 13140},
{"Каждые 3 года", 26280}, {"Каждые 3 года", 26280},
}; };
var key = values[0].Value.Value;
if (!regDict.TryGetValue(values[0].Value.Value, out int regNum)) if (!regDict.TryGetValue(key, out int regHours))
{ {
logger.LogError("Неизвестное значение: {Value}", values[0].Value.Value); logger.LogError("Неизвестное значение: {Value}", key);
throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {key}");
} }
#region Старая логика
//var calcDay = referenceDate;
////TODO: вот это повторяется от метода к методу
////Если итоговая дата указывает на прошлое, то повторяем расчёт и уходим в рекурсию
//if (calcDay < DateTimeOffset.UtcNow /* || calcDay <= referenceDate*/)
// calcDay = GetNextDateRegularly(values, calcDay.AddHours(regHours));
//return calcDay;
#endregion
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
var calcDay = referenceDate; var calcDay = referenceDate;
//TODO: вот это повторяется от метода к методу // Если referenceDate далеко в прошлом, не шагаем по минутам в цикле.
//Если итоговая дата указывает на прошлое, то повторяем расчёт и уходим в рекурсию // Сразу вычисляем, сколько целых интервалов нужно прибавить, чтобы догнать текущее время.
if (calcDay < DateTimeOffset.UtcNow) if (calcDay < nowInTargetZone)
calcDay = GetNextDateRegularly(values, calcDay.AddHours(regNum)); {
double totalHoursPast = (nowInTargetZone - calcDay).TotalHours;
// Считаем, сколько полных интервалов помещается в этот отрезок времени
long intervalsToSkip = (long)Math.Ceiling(totalHoursPast / regHours);
calcDay = calcDay.AddHours(intervalsToSkip * regHours);
}
// Проверяем условия и делаем микро-шаги, если необходимо
while (true)
{
if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
{
return calcDay; return calcDay;
} }
calcDay = calcDay.AddHours(regHours);
}
}
/// <summary>
/// Еженедельно, каждый понедельник, вторник, среду...
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private DateTimeOffset GetNextDateWeekly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateWeekly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
if (!dwDict.TryGetValue(values[0].Value.Value, out int dwNum)) if (!dwDict.TryGetValue(values[0].Value.Value, out int dwNum))
@@ -188,71 +224,155 @@ namespace PARR.Core.Services.NextRunServices.Subservices
throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}");
} }
#region Старая логика
//var calcDay = referenceDate;
//if (calcDay < DateTimeOffset.UtcNow)
//{
// var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
// calcDay = new DateTimeOffset(
// nowInTargetTz.Year,
// nowInTargetTz.Month,
// nowInTargetTz.Day,
// referenceDate.Hour,
// referenceDate.Minute,
// referenceDate.Second,
// referenceDate.Offset
// );
//}
//if (((int)calcDay.DayOfWeek) != dwNum)
// calcDay = OffsetToDayOfWeek(dwNum, calcDay);
////Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию
//if (calcDay < DateTimeOffset.UtcNow || calcDay <= referenceDate)
// calcDay = GetNextDateWeekly(values, calcDay.AddDays(7));//а вот тут что-то новенькое
//return calcDay;
#endregion
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
var calcDay = referenceDate; var calcDay = referenceDate;
if (calcDay < DateTimeOffset.UtcNow)
// Сдвигаем дату вперед до нужного дня недели
if ((int)calcDay.DayOfWeek != dwNum)
{ {
var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset); calcDay = OffsetToDayOfWeek(dwNum, calcDay);
calcDay = new DateTimeOffset(
nowInTargetTz.Year,
//DateTimeOffset.UtcNow.Year,
nowInTargetTz.Month,
//DateTimeOffset.UtcNow.Month,
nowInTargetTz.Day,
//DateTimeOffset.UtcNow.Day,
referenceDate.Hour,
referenceDate.Minute,
referenceDate.Second,
referenceDate.Offset
);
} }
// Если получившаяся дата все еще глубоко в прошлом,
// не шагаем неделями в цикле, а мгновенно прыгаем ближе к текущему времени математикой.
if (calcDay < nowInTargetZone)
{
double totalDaysPast = (nowInTargetZone - calcDay).TotalDays;
if (((int)calcDay.DayOfWeek) != dwNum) // Вычисляем, сколько полных недель (по 7 дней) нужно прибавить
calcDay = OffsetToDayOfWeek(dwNum, calcDay); long weeksToSkip = (long)Math.Ceiling(totalDaysPast / 7);
//TODO: вот это повторяется от метода к методу calcDay = calcDay.AddDays(weeksToSkip * 7);
//Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию }
if (calcDay < DateTimeOffset.UtcNow)
calcDay = GetNextDateWeekly(values, calcDay.AddDays(7));//а вот тут что-то новенькое
while (true)
{
// Условие выхода: дата в будущем относительно точки старта И текущего времени
if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
{
return calcDay; return calcDay;
} }
// Если условия не выполнены, делаем шаг ровно в одну неделю (7 дней)
calcDay = calcDay.AddDays(7);
}
}
/// <summary>
/// Ежемесячно, 1, 2, 3... числа
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private DateTimeOffset GetNextDateMonthly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateMonthly(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
if (!int.TryParse(values[0].Value.Value, out int dayNum)) if (!int.TryParse(values[0].Value.Value, out int targetDay))
{ {
logger.LogError("Неизвестное значение дня месяца: {Value}", values[0].Value.Value); logger.LogError("Неизвестное значение дня месяца: {Value}", values[0].Value.Value);
throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}");
} }
var calcDay = new DateTimeOffset(referenceDate.Year, referenceDate.Month, dayNum, referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset); #region старая логика, не учтено попадание например 31 числа на февраль. Нет сравнения что расчитанная дата новее или равна refDate
if (referenceDate < DateTimeOffset.UtcNow) //var calcDay = new DateTimeOffset(referenceDate.Year, referenceDate.Month, targetDay, referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset);
//if (referenceDate < DateTimeOffset.UtcNow)
//{
// var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
// calcDay = new DateTimeOffset(
// nowInTargetTz.Year,
// nowInTargetTz.Month,
// targetDay,
// referenceDate.Hour,
// referenceDate.Minute,
// referenceDate.Second,
// referenceDate.Offset
// );
//}
////Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию
//if (calcDay < DateTimeOffset.UtcNow /*|| calcDay <= referenceDate*/)
// calcDay = GetNextDateMonthly(values, calcDay.AddMonths(1));
//return calcDay;
#endregion
// Начинаем проверку с текущего месяца и года переданной referenceDate
var baseDate = referenceDate;
int monthsToAdd = 0;
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
while (true)
{ {
var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset); // Каждую итерацию мы отталкиваемся от исходной точки и прибавляем +1, +2, +3 месяца
calcDay = new DateTimeOffset( var nextMonthDate = baseDate.AddMonths(monthsToAdd);
nowInTargetTz.Year, int year = nextMonthDate.Year;
nowInTargetTz.Month, int month = nextMonthDate.Month;
dayNum,
referenceDate.Hour, // Если в этом конкретном месяце НЕТ нужного нам дня (например, 31 числа в апреле или 30 в феврале)
referenceDate.Minute, if (targetDay > DateTime.DaysInMonth(year, month))
referenceDate.Second, {
referenceDate.Offset // Пропускаем этот месяц и идем на следующий круг цикла
); monthsToAdd++;
//calcDay = new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, dayNum, referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset); continue;
} }
// Если день существует, безопасно собираем дату
var calcDay = new DateTimeOffset(
year, month, targetDay,
referenceDate.Hour, referenceDate.Minute, referenceDate.Second,
referenceDate.Offset
);
//TODO: вот это повторяется от метода к методу // Условие выхода: дата строго в будущем относительно referenceDate и текущего UTC-времени
//Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
if (calcDay < DateTimeOffset.UtcNow) {
calcDay = GetNextDateMonthly(values, calcDay.AddMonths(1));
return calcDay; return calcDay;
} }
// Если дата правильная, но она в прошлом — переходим к следующему месяцу
monthsToAdd++;
}
}
/// <summary>
/// Ежемесячно-2, Первый понедельник, Третий вторник
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
/// <exception cref="InvalidOperationException"></exception>
private DateTimeOffset GetNextDateMonthly2(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateMonthly2(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
if (!orderDict.TryGetValue(values[0].Value.Value, out int orderNum)) if (!orderDict.TryGetValue(values[0].Value.Value, out int orderNum))
@@ -267,23 +387,65 @@ namespace PARR.Core.Services.NextRunServices.Subservices
throw new ArgumentException($"Неизвестное значение: {values[1].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {values[1].Value.Value}");
} }
#region Старая логика
//var startDay = referenceDate < DateTimeOffset.UtcNow //var startDay = referenceDate < DateTimeOffset.UtcNow
// ? new DateTimeOffset(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month, 1, referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset) // ? new DateTimeOffset(
// : new DateTimeOffset(referenceDate.Year, referenceDate.Month, 1, referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset); // DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Year,
// DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Month,
// 1,
// referenceDate.Hour,
// referenceDate.Minute,
// referenceDate.Second,
// referenceDate.Offset
// )
// : new DateTimeOffset(
// referenceDate.Year,
// referenceDate.Month,
// 1,
// referenceDate.Hour,
// referenceDate.Minute,
// referenceDate.Second,
// referenceDate.Offset
// );
var startDay = referenceDate < DateTimeOffset.UtcNow //var daysList = FindAllDaysInMonth(dwNum, startDay);
? new DateTimeOffset(
DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Year, //DateTimeOffset calcDay;
DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset).Month, //if (orderNum == 5) // Последний
1, //{
referenceDate.Hour, // calcDay = daysList.Last();
referenceDate.Minute, //}
referenceDate.Second, //else
referenceDate.Offset //{
) // if (orderNum > daysList.Count)
: new DateTimeOffset( // {
referenceDate.Year, // logger.LogError("Запрошенный порядковый день недели ({OrderNum}) не существует в месяце {Month}/{Year}. Найдено только {Count} дней.", orderNum, startDay.Month, startDay.Year, daysList.Count);
referenceDate.Month, // throw new InvalidOperationException($"Запрошенный порядковый день недели ({orderNum}) не существует в месяце {startDay.Month}/{startDay.Year}. Найдено только {daysList.Count} дней.");
// }
// else
// calcDay = daysList[orderNum - 1];
//}
//// Если дата <= текущей, переходим на следующий месяц
//if (calcDay <= DateTimeOffset.UtcNow /*|| calcDay <= referenceDate*/)
// return GetNextDateMonthly2(values, startDay.AddMonths(1));
//return calcDay;
#endregion
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
// Начинаем расчет с текущего месяца referenceDate
int monthsToAdd = 0;
while (true)
{
var currentMonthDate = referenceDate.AddMonths(monthsToAdd);
// Безопасно формируем первое число для проверяемого месяца
var startDayOfMonth = new DateTimeOffset(
currentMonthDate.Year,
currentMonthDate.Month,
1, 1,
referenceDate.Hour, referenceDate.Hour,
referenceDate.Minute, referenceDate.Minute,
@@ -291,32 +453,47 @@ namespace PARR.Core.Services.NextRunServices.Subservices
referenceDate.Offset referenceDate.Offset
); );
var daysList = FindAllDaysInMonth(dwNum, startDay); // Ищем все нужные дни недели в этом месяце
var daysList = FindAllDaysInMonth(dwNum, startDayOfMonth);
DateTimeOffset calcDay; DateTimeOffset calcDay;
if (orderNum == 5) // Последний
if (orderNum == 5) // Последний день недели в месяце (например, последняя пятница)
{ {
calcDay = daysList.Last(); calcDay = daysList.Last();
} }
else else
{ {
// Умная защита: если в текущем месяце нет 5-го вторника,
// мы не выкидываем ошибку приложения, а мягко идем искать в следующем месяце
if (orderNum > daysList.Count) if (orderNum > daysList.Count)
{ {
logger.LogError("Запрошенный порядковый день недели ({OrderNum}) не существует в месяце {Month}/{Year}. Найдено только {Count} дней.", orderNum, startDay.Month, startDay.Year, daysList.Count); monthsToAdd++;
throw new InvalidOperationException($"Запрошенный порядковый день недели ({orderNum}) не существует в месяце {startDay.Month}/{startDay.Year}. Найдено только {daysList.Count} дней."); continue;
} }
else
calcDay = daysList[orderNum - 1]; calcDay = daysList[orderNum - 1];
} }
// Если дата <= текущей, переходим на следующий месяц // 3. Условие выхода (точно такое же, как во всех предыдущих методах)
if (calcDay <= DateTimeOffset.UtcNow) if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
return GetNextDateMonthly2(values, startDay.AddMonths(1)); {
return calcDay; return calcDay;
} }
// Если дата правильная, но она в прошлом — переходим к следующему календарному месяцу
monthsToAdd++;
}
}
/// <summary>
/// Ежегодно, Январь 17, Февраль 20
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private DateTimeOffset GetNextDateAnnually(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateAnnually(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
@@ -332,26 +509,73 @@ namespace PARR.Core.Services.NextRunServices.Subservices
throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {values[0].Value.Value}");
} }
//var year = (referenceDate.Year < DateTimeOffset.UtcNow.Year) ? DateTimeOffset.UtcNow.Year : referenceDate.Year; #region Старая логика
//сравниваем года в одном часовом поясе ////сравниваем года в одном часовом поясе
var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset); //var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
var year = (referenceDate.Year < nowInTargetTz.Year) //var year = (referenceDate.Year < nowInTargetTz.Year)
? nowInTargetTz.Year // ? nowInTargetTz.Year
: referenceDate.Year; // : referenceDate.Year;
//var calcDay = new DateTimeOffset(
// year, monthNum, dayNum,
// referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset);
////Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию
//if (calcDay < DateTimeOffset.UtcNow /*|| calcDay <= referenceDate*/)
// calcDay = GetNextDateAnnually(values, calcDay.AddYears(1));
//return calcDay;
#endregion
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
// Начинаем расчет с года переданной referenceDate
int yearsToAdd = 0;
while (true)
{
// Вычисляем текущий проверяемый год относительно исходной referenceDate
var currentYearDate = referenceDate.AddYears(yearsToAdd);
int year = currentYearDate.Year;
// ЗАЩИТА: Если в этом году нет такого дня (например, 29 февраля в невисокосном году)
if (dayNum > DateTime.DaysInMonth(year, monthNum))
{
// Просто пропускаем этот год и переходим к следующему
yearsToAdd++;
continue;
}
// Если день существует, безопасно собираем дату
var calcDay = new DateTimeOffset( var calcDay = new DateTimeOffset(
year, monthNum, dayNum, year,
referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset); monthNum,
dayNum,
//TODO: вот это повторяется от метода к методу referenceDate.Hour,
//Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию referenceDate.Minute,
if (calcDay < DateTimeOffset.UtcNow) referenceDate.Second,
calcDay = GetNextDateAnnually(values, calcDay.AddYears(1)); referenceDate.Offset
);
// Проверяем условия выхода (абсолютно одинаково для всех методов)
if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
{
return calcDay; return calcDay;
} }
// Если дата в прошлом — переходим на следующий год
yearsToAdd++;
}
}
/// <summary>
/// Ежегодно-2, Первый понедельник января
/// </summary>
/// <param name="values"></param>
/// <param name="referenceDate"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private DateTimeOffset GetNextDateAnnually2(List<EsppScheduleValDto> values, DateTimeOffset referenceDate) private DateTimeOffset GetNextDateAnnually2(List<EsppScheduleValDto> values, DateTimeOffset referenceDate)
{ {
var monthADict = new Dictionary<string, int> var monthADict = new Dictionary<string, int>
@@ -386,36 +610,92 @@ namespace PARR.Core.Services.NextRunServices.Subservices
throw new ArgumentException($"Неизвестное значение: {values[1].Value.Value}"); throw new ArgumentException($"Неизвестное значение: {values[1].Value.Value}");
} }
//var year = (referenceDate.Year < DateTimeOffset.UtcNow.Year) ? DateTimeOffset.UtcNow.Year : referenceDate.Year;
// сравниваем года в одном часовом поясе
var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
var year = (referenceDate.Year < nowInTargetTz.Year)
? nowInTargetTz.Year
: referenceDate.Year;
//if (year == DateTimeOffset.UtcNow.Year && referenceDate.Month < DateTimeOffset.UtcNow.Month) #region Старая логика
if (year == nowInTargetTz.Year && referenceDate.Month < nowInTargetTz.Month)
year += 1;
var calcDay = new DateTimeOffset( //// сравниваем года в одном часовом поясе
year, monthNum, 1, //var nowInTargetTz = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset); //var year = (referenceDate.Year < nowInTargetTz.Year)
// ? nowInTargetTz.Year
// : referenceDate.Year;
List<DateTimeOffset> daysList = FindAllDaysInMonth(dwNum, calcDay); ////if (year == DateTimeOffset.UtcNow.Year && referenceDate.Month < DateTimeOffset.UtcNow.Month)
//if (year == nowInTargetTz.Year && referenceDate.Month < nowInTargetTz.Month)
// year += 1;
if (orderNum == 5)//5-последний //var calcDay = new DateTimeOffset(
// year, monthNum, 1,
// referenceDate.Hour, referenceDate.Minute, referenceDate.Second, referenceDate.Offset);
//List<DateTimeOffset> daysList = FindAllDaysInMonth(dwNum, calcDay);
//if (orderNum == 5)//5-последний
// calcDay = daysList.Last();
//else
// calcDay = daysList[orderNum - 1];
////Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию
//if (calcDay < DateTimeOffset.UtcNow /*|| calcDay <= referenceDate*/)
// calcDay = GetNextDateAnnually2(values, calcDay.AddYears(1));
//return calcDay;
#endregion
var nowInTargetZone = DateTimeOffset.UtcNow.ToOffset(referenceDate.Offset);
// Начинаем расчет с года переданной referenceDate
int yearsToAdd = 0;
while (true)
{
// Сдвигаем проверяемый год относительно исходной даты
var currentYearDate = referenceDate.AddYears(yearsToAdd);
int year = currentYearDate.Year;
// Формируем первое число целевого месяца для проверяемого года
var startDayOfMonth = new DateTimeOffset(
year,
monthNum,
1,
referenceDate.Hour,
referenceDate.Minute,
referenceDate.Second,
referenceDate.Offset
);
// Ищем все нужные дни недели в этом месяце
var daysList = FindAllDaysInMonth(dwNum, startDayOfMonth);
DateTimeOffset calcDay;
if (orderNum == 5) // 5 означает "Последний день недели в месяце"
{
calcDay = daysList.Last(); calcDay = daysList.Last();
}
else else
{
// Умная защита: если в этом году в этом месяце НЕТ 5-го выбранного дня недели
if (orderNum > daysList.Count)
{
// Мы не падаем с ошибкой, а просто переходим к проверке следующего года
yearsToAdd++;
continue;
}
calcDay = daysList[orderNum - 1]; calcDay = daysList[orderNum - 1];
}
//TODO: вот это повторяется от метода к методу if (calcDay >= referenceDate && calcDay >= nowInTargetZone)
//Если итоговая дата указывает на прошлое, то повторяем расчёт и ухоим в рекурсию {
if (calcDay < DateTimeOffset.UtcNow)
calcDay = GetNextDateAnnually2(values, calcDay.AddYears(1));
return calcDay; return calcDay;
} }
// Если вычисленная дата в прошлом — переходим на следующий год
yearsToAdd++;
}
}
private List<DateTimeOffset> FindAllDaysInMonth(int dwNum, DateTimeOffset startDay) private List<DateTimeOffset> FindAllDaysInMonth(int dwNum, DateTimeOffset startDay)
{ {

View File

@@ -1,7 +1,9 @@
using AutoMapper; using AutoMapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.RobotRepositories; using PARR.Core.Repositories.Interfaces.RobotRepositories;
using PARR.Core.Services.Snapshots.Interfaces;
using PARR.Domain.DTOs.RobotSnapshotDTO; using PARR.Domain.DTOs.RobotSnapshotDTO;
using PARR.Domain.DTOs.User; using PARR.Domain.DTOs.User;
using PARR.Domain.Entities.RobotEntities; using PARR.Domain.Entities.RobotEntities;
@@ -9,21 +11,32 @@ using PARR.Domain.Exceptions;
namespace PARR.Core.Services.RobotSnapshotServices namespace PARR.Core.Services.RobotSnapshotServices
{ {
internal class RobotSnapshotService : IRobotSnapshotService internal class RobotSnapshotService : IRobotSnapshotService, ISnapshotProvider
{ {
private readonly IRobotSnapshotRepository _robotSnapshotRepository; private readonly IRobotSnapshotRepository _robotSnapshotRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ILogger<RobotSnapshotService> _logger;
private readonly ISnapshotSettings _snapshotSettings;
// Тут любое значение, не используем метод создания снапшота
public TimeSpan Interval => TimeSpan.FromHours(1);
public TimeSpan RetentionPeriod => _snapshotSettings.RobotSnapshotRetentionPeriod;
public RobotSnapshotService( public RobotSnapshotService(
IRobotSnapshotRepository robotSnapshotRepository, IRobotSnapshotRepository robotSnapshotRepository,
IUserRepository userRepository, IUserRepository userRepository,
IMapper mapper IMapper mapper,
ILogger<RobotSnapshotService> logger,
ISnapshotSettings snapshotSettings
) )
{ {
_robotSnapshotRepository = robotSnapshotRepository; _robotSnapshotRepository = robotSnapshotRepository;
_userRepository = userRepository; _userRepository = userRepository;
_mapper = mapper; _mapper = mapper;
_logger = logger;
_snapshotSettings = snapshotSettings;
} }
@@ -348,5 +361,26 @@ namespace PARR.Core.Services.RobotSnapshotServices
} }
public Task TakeSnapshotAsync(CancellationToken cancellationToken)
{
// Метод пустой! Нам не нужно собирать данные по таймеру,
// так как они и так пишутся сюда через контроллер API.
return Task.CompletedTask;
}
public async Task CleanUpOldSnapshotsAsync(CancellationToken cancellationToken)
{
var thresholdDate = DateTimeOffset.UtcNow - RetentionPeriod;
_logger.LogInformation("[{ServiceName}] Запуск очистки старых снапшотов. Удаление данных старше {ThresholdDate}", GetType().Name, thresholdDate);
// Удаляем старые записи напрямую в PostgreSQL
var deletedCount = await _robotSnapshotRepository.Get()
.Where(s => s.DateCreated < thresholdDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogInformation("[{ServiceName}] Очистка завершена. Удалено устаревших строк снапшотов: {Count}", GetType().Name, deletedCount);
}
} }
} }

View File

@@ -52,7 +52,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
public async Task<RobotTaskTemplate> GetTemplateTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId) public async Task<RobotTaskTemplate> GetTemplateTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId)
{ {
var templateTask = await GetTaskAsync(RobotsEnum.TemplateOrder, taskStatusCode, acquireTask, robotIp, robotId); var templateTask = await GetTaskAsync(RobotsEnum.TemplateOrder, taskStatusCode, acquireTask, robotIp, robotId, TimeSpan.Zero);
var task = mapper.Map<RobotTaskTemplate>(templateTask); var task = mapper.Map<RobotTaskTemplate>(templateTask);
@@ -72,9 +72,9 @@ namespace PARR.Core.Services.RobotTask.Implementations
} }
public async Task<RobotTaskSchedule> GetScheduleTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, IHistoryInitiator historyInitiator) public async Task<RobotTaskSchedule> GetScheduleTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, IHistoryInitiator historyInitiator, TimeSpan scheduleCooldownDuration)
{ {
var scheduleTask = await GetTaskAsync(RobotsEnum.ScheduleOrder, taskStatusCode, acquireTask, robotIp, robotId); var scheduleTask = await GetTaskAsync(RobotsEnum.ScheduleOrder, taskStatusCode, acquireTask, robotIp, robotId, scheduleCooldownDuration);
// Проверяем nextRun, lastRun, обновляем их // Проверяем nextRun, lastRun, обновляем их
@@ -121,14 +121,14 @@ namespace PARR.Core.Services.RobotTask.Implementations
/// <param name="robotIp"></param> /// <param name="robotIp"></param>
/// <returns></returns> /// <returns></returns>
/// <exception cref="NotFoundException"></exception> /// <exception cref="NotFoundException"></exception>
private async Task<RobotConfiguration> GetTaskAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId) private async Task<RobotConfiguration> GetTaskAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, TimeSpan scheduleCooldownDuration)
{ {
// 1. Ищем все задания с превышенным кол-вом попыток и просроченным временем, ставим им статус ошибки // 1. Ищем все задания с превышенным кол-вом попыток и просроченным временем, ставим им статус ошибки
await robotConfigurationRepository.MarkExpiredTasksAsFailedAsync(settingsFromDb.RobotAttemptsNumber, settingsFromDb.RobotWaitTime); await robotConfigurationRepository.MarkExpiredTasksAsFailedAsync(settingsFromDb.RobotAttemptsNumber, settingsFromDb.RobotWaitTime);
// 2. Ищем доступные задания // 2. Ищем доступные задания
var availableTasks = await GetAvailableTasksAsync(robotCode, taskStatusCode); var availableTasks = await GetAvailableTasksAsync(robotCode, taskStatusCode, scheduleCooldownDuration);
if (availableTasks.Count == 0) if (availableTasks.Count == 0)
throw new NotFoundException("Нет доступных заданий для робота"); throw new NotFoundException("Нет доступных заданий для робота");
@@ -137,7 +137,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
if (acquireTask) if (acquireTask)
{ {
// Берем задание в работу, устанавливаем ей статус "В работе" // Берем задание в работу, устанавливаем ему статус "В работе"
acquiredTaskId = await AcquireTaskAsync(availableTasks, robotIp, robotId); acquiredTaskId = await AcquireTaskAsync(availableTasks, robotIp, robotId);
if (acquiredTaskId == null) if (acquiredTaskId == null)
@@ -165,21 +165,35 @@ namespace PARR.Core.Services.RobotTask.Implementations
/// <param name="robotCode"></param> /// <param name="robotCode"></param>
/// <param name="taskStatusCode"></param> /// <param name="taskStatusCode"></param>
/// <returns></returns> /// <returns></returns>
private async Task<List<Guid>> GetAvailableTasksAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode) private async Task<List<Guid>> GetAvailableTasksAsync(RobotsEnum robotCode, TaskStatusEnum taskStatusCode, TimeSpan scheduleCooldownDuration)
{ {
var query = robotConfigurationRepository.Get() var query = robotConfigurationRepository.Get()
.AsNoTracking() .AsNoTracking()
.Where(t => t.RobotCode == (int)robotCode && t.TaskStatusCode == (int)taskStatusCode); .Where(t => t.RobotCode == (int)robotCode/* && t.TaskStatusCode == (int)taskStatusCode*/);
// Если это задание для робота расписаний // Если это задание для робота расписаний
if (robotCode == RobotsEnum.ScheduleOrder) if (robotCode == RobotsEnum.ScheduleOrder)
{ {
// Выбираем только записи с созданными шаблонами (у которых статус 30), а только потом ищем у них расписания // Выбираем только записи с созданными шаблонами (у которых статус 30), а только потом ищем у них расписания
var createdTemplates = robotConfigurationRepository.Get() #region Старый не оптимизированный запрос
.Where(t => t.RobotCode == (int)RobotsEnum.TemplateOrder && t.TaskStatusCode == (int)TaskStatusEnum.Ok) //var createdTemplates = robotConfigurationRepository.Get()
.Select(t => t.TemplateId); // .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));
query = query.Where(t => createdTemplates.Contains(t.TemplateId));
// Не берем шаблоны, у которых lastRun + 3 часа < сейчас, и у них последний инициатор был или nextRun (10) или esppSchedule (5), это условие применяется только к активированным расписаниям
var cooldownThreshold = DateTimeOffset.UtcNow.Add(-scheduleCooldownDuration);
query = query.Where(t =>
// Условие кулдауна: проверяем, попадает ли шаблон под ЗАПРЕТ
!(
t.Template!.IsActiveSchedule
&& (t.Template.InitiatorParrComponentId == ParrComponentsEnum.EsppScheduleSync || t.Template.InitiatorParrComponentId == ParrComponentsEnum.NextRun)
&& t.Template.LastRun >= cooldownThreshold
)
);
} }
// Сортируем по nextRun, чтобы те, у кого nextRun ближе к текущей, выполнились скорее // Сортируем по nextRun, чтобы те, у кого nextRun ближе к текущей, выполнились скорее
@@ -189,7 +203,14 @@ namespace PARR.Core.Services.RobotTask.Implementations
var tasks = new List<Guid>(); var tasks = new List<Guid>();
// Ещем первые 10 заданий в статусе ОЖИДАНИЕ // Ещем первые 10 заданий в статусе ОЖИДАНИЕ
tasks = await query.Where(t => t.RobotStatusCode == (int)RobotStatusEnum.Wait).Take(TakeTasks).Select(t => t.Id).ToListAsync(); tasks = await query
.Where(t =>
t.RobotStatusCode == (int)RobotStatusEnum.Wait
&& t.TaskStatusCode == (int)taskStatusCode
).Take(TakeTasks)
.Select(t => t.Id)
.ToListAsync();
logger.LogDebug("Найдено заданий в статусе 'Ожидание' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString()); logger.LogDebug("Найдено заданий в статусе 'Ожидание' {Count} шт. Робот '{Robot}'", tasks.Count, robotCode.ToString());
@@ -203,6 +224,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
var endDate = DateTimeOffset.UtcNow.Add(-settingsFromDb.RobotWaitTime); var endDate = DateTimeOffset.UtcNow.Add(-settingsFromDb.RobotWaitTime);
tasks = await query.Where(t => t.RobotStatusCode == (int)RobotStatusEnum.InProgress tasks = await query.Where(t => t.RobotStatusCode == (int)RobotStatusEnum.InProgress
&& t.TaskStatusCode==(int)taskStatusCode
&& t.AttemptsNumber < settingsFromDb.RobotAttemptsNumber && t.AttemptsNumber < settingsFromDb.RobotAttemptsNumber
&& t.LastRobotStatusUpdated < endDate) && t.LastRobotStatusUpdated < endDate)
.Take(TakeTasks) .Take(TakeTasks)

View File

@@ -29,7 +29,8 @@ namespace PARR.Core.Services.RobotTask.Interfaces
/// <param name="robotIp"></param> /// <param name="robotIp"></param>
/// <param name="robotId"></param> /// <param name="robotId"></param>
/// <param name="historyInitiator"></param> /// <param name="historyInitiator"></param>
/// <param name="scheduleCooldownDuration"></param>
/// <returns></returns> /// <returns></returns>
Task<RobotTaskSchedule> GetScheduleTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, IHistoryInitiator historyInitiator); Task<RobotTaskSchedule> GetScheduleTaskAsync(TaskStatusEnum taskStatusCode, bool acquireTask, string? robotIp, string? robotId, IHistoryInitiator historyInitiator, TimeSpan scheduleCooldownDuration);
} }
} }

View File

@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.RobotRepositories;
using PARR.Core.Services.Snapshots.Interfaces;
using PARR.Domain.Entities.RobotEntities;
using PARR.Domain.Exceptions;
namespace PARR.Core.Services.Snapshots.Implementations
{
/// <summary>
/// Снапшоты для таблицы RobotConfiguration
/// </summary>
internal class RobotConfigurationSnapshotService : ISnapshotProvider
{
private readonly IRobotConfigurationRepository _robotConfigurationRepository;
private readonly IRobotConfigurationSnapshotRepository _robotConfigurationSnapshotRepository;
private readonly ILogger<RobotConfigurationSnapshotService> _logger;
private readonly ISnapshotSettings _snapshotSettings;
public TimeSpan Interval => _snapshotSettings.RobotConfigurationSnapshotInterval;// TimeSpan.FromMinutes(2);
public TimeSpan RetentionPeriod => _snapshotSettings.RobotConfigurationSnapshotRetentionPeriod;//TimeSpan.FromDays(60);
public RobotConfigurationSnapshotService(
IRobotConfigurationRepository robotConfigurationRepository,
IRobotConfigurationSnapshotRepository robotConfigurationSnapshotRepository,
ILogger<RobotConfigurationSnapshotService> logger,
ISnapshotSettings snapshotSettings
)
{
_robotConfigurationRepository = robotConfigurationRepository;
_robotConfigurationSnapshotRepository = robotConfigurationSnapshotRepository;
_logger = logger;
_snapshotSettings = snapshotSettings;
}
public async Task TakeSnapshotAsync(CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
_logger.LogDebug("Сбор метрик для снапшота RobotConfigurations...");
var stats = await _robotConfigurationRepository.Get()
.GroupBy(t => new { t.RobotCode, t.RobotStatusCode, t.TaskStatusCode })
.Select(g => new
{
g.Key.RobotCode,
g.Key.RobotStatusCode,
g.Key.TaskStatusCode,
Count = g.Count()
})
.ToListAsync(cancellationToken);
if (!stats.Any())
{
_logger.LogDebug("Нет активных заданий роботов для создания снапшота.");
return;
}
var snapshots = stats.Select(s => new RobotConfigurationSnapshot
{
Id = Guid.NewGuid(),
DateCreated = now,
RobotCode = s.RobotCode,
RobotStatusCode = s.RobotStatusCode,
TaskStatusCode = s.TaskStatusCode,
Count = s.Count
}).ToList();
if (!await _robotConfigurationSnapshotRepository.AddRangeAsync(snapshots) || !await _robotConfigurationSnapshotRepository.CommitAsync())
throw new DbErrorException("Ошибка при сохранении в БД");
_logger.LogInformation("Успешно сохранен снапшот RobotConfigurations. Записано строк: {Count}. Периодичность: {Interval}", snapshots.Count, Interval);
}
public async Task CleanUpOldSnapshotsAsync(CancellationToken cancellationToken)
{
// Вычисляем граничную дату (всё, что было ДО нее — удаляем)
var thresholdDate = DateTimeOffset.UtcNow.Subtract(RetentionPeriod);
_logger.LogInformation("[{ServiceName}] Запуск очистки старых снапшотов. Удаление данных старше {ThresholdDate}", GetType().Name, thresholdDate);
// Фильтруем старые записи и вызываем ExecuteDeleteAsync для удаления прямо в базе данных
var deletedCount = await _robotConfigurationSnapshotRepository.Get()
.Where(s => s.DateCreated < thresholdDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogInformation("[{ServiceName}] Очистка завершена. Удалено устаревших строк снапшотов: {Count}", GetType().Name, deletedCount);
}
}
}

View File

@@ -0,0 +1,31 @@
namespace PARR.Core.Services.Snapshots.Interfaces
{
/// <summary>
/// Общий снапшот провайдер.
/// Используется для реализации паттерна "Стратегия"
/// </summary>
public interface ISnapshotProvider
{
/// <summary>
/// Периодичность создания снапшота (не меньше 1 минуты).
/// </summary>
TimeSpan Interval { get; }
/// <summary>
/// Срок хранения снапшотов (например, 30 дней). Всё, что старше — удаляется.
/// </summary>
TimeSpan RetentionPeriod { get; }
/// <summary>
/// Метод сбора метрик и записи их в базу данных.
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task TakeSnapshotAsync(CancellationToken cancellationToken);
/// <summary>
/// Метод очистки устаревших снапшотов.
/// </summary>
Task CleanUpOldSnapshotsAsync(CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,35 @@
namespace PARR.Core.Services.Snapshots.Interfaces
{
/// <summary>
/// Настройки снапшотов
/// </summary>
public interface ISnapshotSettings
{
/// <summary>
/// Интервал создания снапшотов для таблицы RobotConfiguration
/// </summary>
TimeSpan RobotConfigurationSnapshotInterval { get; }
/// <summary>
/// Длительность хранения снапшотов для RobotConfiguration в таблице ConfigurationShapshots
/// </summary>
TimeSpan RobotConfigurationSnapshotRetentionPeriod { get; }
/// <summary>
/// Длительность хранения снапшотов работы роботов в таблице Shapshots
/// </summary>
TimeSpan RobotSnapshotRetentionPeriod { get; }
}
/// <summary>
/// Настройки по умолчанию для ISnapshotSettings
/// </summary>
internal record DefaultSnapshotSettings : ISnapshotSettings
{
public TimeSpan RobotConfigurationSnapshotInterval => TimeSpan.FromMinutes(2);
public TimeSpan RobotConfigurationSnapshotRetentionPeriod => TimeSpan.FromDays(60);
public TimeSpan RobotSnapshotRetentionPeriod => TimeSpan.FromDays(60);
}
}

View File

@@ -143,6 +143,7 @@ namespace PARR.DAL.Context
#region Robot #region Robot
public DbSet<RobotSnapshot> RobotSnapshots { get; set; } public DbSet<RobotSnapshot> RobotSnapshots { get; set; }
public DbSet<RobotConfigurationSnapshot> RobotConfigurationSnapshots { get; set; }
#endregion #endregion
@@ -242,7 +243,14 @@ namespace PARR.DAL.Context
}, },
new { Name = nameof(SettingsFromDb.EsppUnitTag), Description = "Префикс тега в поле ЭК \"Дополнительная информация\"", Value = АРР_" }, new { Name = nameof(SettingsFromDb.EsppUnitTag), Description = "Префикс тега в поле ЭК \"Дополнительная информация\"", Value = АРР_" },
new { Name = nameof(SettingsFromDb.JobIdForUnusedTemplates), Description = "Job в который перемещаем ниспользованные шаблоны", Value = "8f85a91c-a223-4686-bb69-1f0ee73624f2" }, new { Name = nameof(SettingsFromDb.JobIdForUnusedTemplates), Description = "Job в который перемещаем ниспользованные шаблоны", Value = "8f85a91c-a223-4686-bb69-1f0ee73624f2" },
new { Name = nameof(SettingsFromDb.DefaultResponseAreaToTimeOffset), Description = "Зона ответственности для получения тайм зоны по умолчанию для расписаний", Value = "17-МСК" } new { Name = nameof(SettingsFromDb.DefaultResponseAreaToTimeOffset), Description = "Зона ответственности для получения тайм зоны по умолчанию для расписаний", Value = "17-МСК" },
new
{
Name = nameof(SettingsFromDb.MinRelationshipsThresholdPct),
Description = "Минимально допустимый процент связей для продолжения синхронизации. Защищает от удаления данных, " +
"если передающая система АИХ ИТ передала аномально малый объем связей. 0 — Защита ОТКЛЮЧЕНА, 100 — Строгий режим.",
Value = "97"
}
); );
}); });
#endregion #endregion

View File

@@ -84,8 +84,12 @@ namespace PARR.DAL
services.AddTransient<IParrComponentRepository, ParrComponentRepository>(); services.AddTransient<IParrComponentRepository, ParrComponentRepository>();
services.AddTransient<ITemplateStatusTypeRepository, TemplateStatusTypeRepository>(); services.AddTransient<ITemplateStatusTypeRepository, TemplateStatusTypeRepository>();
services.AddScoped<IRobotSnapshotRepository, RobotSnapshotRepository>(); #region Robot
services.AddScoped<IRobotSnapshotRepository, RobotSnapshotRepository>();
services.AddScoped<IRobotConfigurationSnapshotRepository, RobotConfigurationSnapshotRepository>();
#endregion
#region Schedule #region Schedule

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblRobotConfigurationSnapshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConfigurationSnapshots",
schema: "robot",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DateCreated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RobotCode = table.Column<int>(type: "integer", nullable: false),
RobotStatusCode = table.Column<int>(type: "integer", nullable: false),
TaskStatusCode = table.Column<int>(type: "integer", nullable: false),
Count = table.Column<int>(type: "integer", nullable: false, comment: "Количество заданий в этой комбинации статусов")
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigurationSnapshots", x => x.Id);
table.ForeignKey(
name: "FK_ConfigurationSnapshots_RobotStatuses_RobotStatusCode",
column: x => x.RobotStatusCode,
principalTable: "RobotStatuses",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ConfigurationSnapshots_Robots_RobotCode",
column: x => x.RobotCode,
principalTable: "Robots",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ConfigurationSnapshots_TaskStatuses_TaskStatusCode",
column: x => x.TaskStatusCode,
principalTable: "TaskStatuses",
principalColumn: "Code",
onDelete: ReferentialAction.Cascade);
},
comment: "Снимки заданий роботам");
migrationBuilder.CreateIndex(
name: "IX_ConfigurationSnapshots_RobotCode_DateCreated",
schema: "robot",
table: "ConfigurationSnapshots",
columns: new[] { "RobotCode", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_ConfigurationSnapshots_RobotStatusCode",
schema: "robot",
table: "ConfigurationSnapshots",
column: "RobotStatusCode");
migrationBuilder.CreateIndex(
name: "IX_ConfigurationSnapshots_TaskStatusCode",
schema: "robot",
table: "ConfigurationSnapshots",
column: "TaskStatusCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ConfigurationSnapshots",
schema: "robot");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblSettingsFromDbMinRelationshipsThresholdPct : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Settings",
columns: new[] { "Name", "Description", "Value" },
values: new object[] { "MinRelationshipsThresholdPct", "Минимально допустимый процент связей для продолжения синхронизации. Защищает от удаления данных, если передающая система АИХ ИТ передала аномально малый объем связей. 0 — Защита ОТКЛЮЧЕНА, 100 — Строгий режим.", "97" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Settings",
keyColumn: "Name",
keyValue: "MinRelationshipsThresholdPct");
}
}
}

View File

@@ -175,7 +175,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.Job", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.Job", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -234,7 +234,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobAutoControl", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobAutoControl", b =>
{ {
b.Property<Guid>("JobId") b.Property<Guid>("JobId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -256,7 +256,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobFieldFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobFieldFilter", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -290,7 +290,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobRelationshipFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobRelationshipFilter", b =>
{ {
b.Property<Guid>("UnitFilterId") b.Property<Guid>("UnitFilterId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -321,7 +321,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobUnitFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobUnitFilter", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -347,7 +347,7 @@ namespace PARR.DAL.Migrations
}); });
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.UnitsInTemplate", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.UnitsInTemplate", b =>
{ {
b.Property<Guid>("TemplateId") b.Property<Guid>("TemplateId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -1004,6 +1004,42 @@ namespace PARR.DAL.Migrations
b.ToTable("RobotConfigurations"); b.ToTable("RobotConfigurations");
}); });
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotConfigurationSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("Count")
.HasColumnType("integer")
.HasComment("Количество заданий в этой комбинации статусов");
b.Property<DateTimeOffset>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<int>("RobotCode")
.HasColumnType("integer");
b.Property<int>("RobotStatusCode")
.HasColumnType("integer");
b.Property<int>("TaskStatusCode")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RobotStatusCode");
b.HasIndex("TaskStatusCode");
b.HasIndex("RobotCode", "DateCreated");
b.ToTable("ConfigurationSnapshots", "robot", t =>
{
t.HasComment("Снимки заданий роботам");
});
});
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotSnapshot", b => modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotSnapshot", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2490,6 +2526,12 @@ namespace PARR.DAL.Migrations
Name = "DefaultResponseAreaToTimeOffset", Name = "DefaultResponseAreaToTimeOffset",
Description = "Зона ответственности для получения тайм зоны по умолчанию для расписаний", Description = "Зона ответственности для получения тайм зоны по умолчанию для расписаний",
Value = "17-МСК" Value = "17-МСК"
},
new
{
Name = "MinRelationshipsThresholdPct",
Description = "Минимально допустимый процент связей для продолжения синхронизации. Защищает от удаления данных, если передающая система АИХ ИТ передала аномально малый объем связей. 0 — Защита ОТКЛЮЧЕНА, 100 — Строгий режим.",
Value = "97"
}); });
}); });
@@ -3232,7 +3274,7 @@ namespace PARR.DAL.Migrations
b.Navigation("DistributionPeriodType"); b.Navigation("DistributionPeriodType");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.Job", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.Job", b =>
{ {
b.HasOne("PARR.Domain.Entities.JobGroupEntities.JobGroup", "Group") b.HasOne("PARR.Domain.Entities.JobGroupEntities.JobGroup", "Group")
.WithMany("Jobs") .WithMany("Jobs")
@@ -3251,18 +3293,18 @@ namespace PARR.DAL.Migrations
b.Navigation("Tnk"); b.Navigation("Tnk");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobAutoControl", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobAutoControl", b =>
{ {
b.HasOne("PARR.Domain.Entities.Job.Job", "Job") b.HasOne("PARR.Domain.Entities.JobEntities.Job", "Job")
.WithOne("AutoControl") .WithOne("AutoControl")
.HasForeignKey("PARR.Domain.Entities.Job.JobAutoControl", "JobId") .HasForeignKey("PARR.Domain.Entities.JobEntities.JobAutoControl", "JobId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Job"); b.Navigation("Job");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobFieldFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobFieldFilter", b =>
{ {
b.HasOne("PARR.Domain.Entities.Unit.UnitField", "UnitField") b.HasOne("PARR.Domain.Entities.Unit.UnitField", "UnitField")
.WithMany("JobFieldFilters") .WithMany("JobFieldFilters")
@@ -3270,7 +3312,7 @@ namespace PARR.DAL.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PARR.Domain.Entities.Job.JobUnitFilter", "UnitFilter") b.HasOne("PARR.Domain.Entities.JobEntities.JobUnitFilter", "UnitFilter")
.WithMany("FieldFilters") .WithMany("FieldFilters")
.HasForeignKey("UnitFilterId") .HasForeignKey("UnitFilterId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -3281,7 +3323,7 @@ namespace PARR.DAL.Migrations
b.Navigation("UnitFilter"); b.Navigation("UnitFilter");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobRelationshipFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobRelationshipFilter", b =>
{ {
b.HasOne("PARR.Domain.Entities.Unit.UnitField", "UnitField") b.HasOne("PARR.Domain.Entities.Unit.UnitField", "UnitField")
.WithMany("RelationshipFilters") .WithMany("RelationshipFilters")
@@ -3289,7 +3331,7 @@ namespace PARR.DAL.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PARR.Domain.Entities.Job.JobUnitFilter", "UnitFilter") b.HasOne("PARR.Domain.Entities.JobEntities.JobUnitFilter", "UnitFilter")
.WithMany("RelationshipFilters") .WithMany("RelationshipFilters")
.HasForeignKey("UnitFilterId") .HasForeignKey("UnitFilterId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -3300,9 +3342,9 @@ namespace PARR.DAL.Migrations
b.Navigation("UnitFilter"); b.Navigation("UnitFilter");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobUnitFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobUnitFilter", b =>
{ {
b.HasOne("PARR.Domain.Entities.Job.Job", "Job") b.HasOne("PARR.Domain.Entities.JobEntities.Job", "Job")
.WithMany("UnitFilters") .WithMany("UnitFilters")
.HasForeignKey("JobId") .HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -3311,7 +3353,7 @@ namespace PARR.DAL.Migrations
b.Navigation("Job"); b.Navigation("Job");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.UnitsInTemplate", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.UnitsInTemplate", b =>
{ {
b.HasOne("PARR.Domain.Entities.Template", "Template") b.HasOne("PARR.Domain.Entities.Template", "Template")
.WithMany("UnitsInTemplate") .WithMany("UnitsInTemplate")
@@ -3507,6 +3549,33 @@ namespace PARR.DAL.Migrations
b.Navigation("Template"); b.Navigation("Template");
}); });
modelBuilder.Entity("PARR.Domain.Entities.RobotEntities.RobotConfigurationSnapshot", b =>
{
b.HasOne("PARR.Domain.Entities.Robot", "Robot")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("RobotCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.RobotStatus", "RobotStatus")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("RobotStatusCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PARR.Domain.Entities.TaskStatus", "TaskStatus")
.WithMany("ConfigurationSnapshots")
.HasForeignKey("TaskStatusCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Robot");
b.Navigation("RobotStatus");
b.Navigation("TaskStatus");
});
modelBuilder.Entity("PARR.Domain.Entities.RobotHistory", b => modelBuilder.Entity("PARR.Domain.Entities.RobotHistory", b =>
{ {
b.HasOne("PARR.Domain.Entities.RobotHistoryLevel", "RobotHistoryLevel") b.HasOne("PARR.Domain.Entities.RobotHistoryLevel", "RobotHistoryLevel")
@@ -3634,7 +3703,7 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.Template", b => modelBuilder.Entity("PARR.Domain.Entities.Template", b =>
{ {
b.HasOne("PARR.Domain.Entities.Job.Job", "Job") b.HasOne("PARR.Domain.Entities.JobEntities.Job", "Job")
.WithMany("Templates") .WithMany("Templates")
.HasForeignKey("JobId") .HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -3802,7 +3871,7 @@ namespace PARR.DAL.Migrations
b.Navigation("Periods"); b.Navigation("Periods");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.Job", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.Job", b =>
{ {
b.Navigation("AutoControl"); b.Navigation("AutoControl");
@@ -3811,7 +3880,7 @@ namespace PARR.DAL.Migrations
b.Navigation("UnitFilters"); b.Navigation("UnitFilters");
}); });
modelBuilder.Entity("PARR.Domain.Entities.Job.JobUnitFilter", b => modelBuilder.Entity("PARR.Domain.Entities.JobEntities.JobUnitFilter", b =>
{ {
b.Navigation("FieldFilters"); b.Navigation("FieldFilters");
@@ -3860,6 +3929,8 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.Robot", b => modelBuilder.Entity("PARR.Domain.Entities.Robot", b =>
{ {
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations"); b.Navigation("RobotConfigurations");
}); });
@@ -3875,6 +3946,8 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.RobotStatus", b => modelBuilder.Entity("PARR.Domain.Entities.RobotStatus", b =>
{ {
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations"); b.Navigation("RobotConfigurations");
}); });
@@ -3937,6 +4010,8 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.TaskStatus", b => modelBuilder.Entity("PARR.Domain.Entities.TaskStatus", b =>
{ {
b.Navigation("ConfigurationSnapshots");
b.Navigation("RobotConfigurations"); b.Navigation("RobotConfigurations");
b.Navigation("RobotHistories"); b.Navigation("RobotHistories");

View File

@@ -131,6 +131,7 @@ namespace PARR.DAL.Repositories
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.RobotStatusCode, (int)RobotStatusEnum.InProgress) .SetProperty(t => t.RobotStatusCode, (int)RobotStatusEnum.InProgress)
.SetProperty(t => t.LastRobotStatusUpdated, DateTimeOffset.UtcNow) .SetProperty(t => t.LastRobotStatusUpdated, DateTimeOffset.UtcNow)
.SetProperty(t => t.AttemptsNumber, t => t.AttemptsNumber + 1)
); );
return affectedRows > 0; return affectedRows > 0;

View File

@@ -0,0 +1,13 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.RobotRepositories;
using PARR.DAL.Context;
using PARR.DAL.Repositories.Base;
using PARR.Domain.Entities.RobotEntities;
namespace PARR.DAL.Repositories.RobotRepositories
{
internal class RobotConfigurationSnapshotRepository : BaseRepository<RobotConfigurationSnapshot>, IRobotConfigurationSnapshotRepository
{
public RobotConfigurationSnapshotRepository(ILogger<RobotConfigurationSnapshotRepository> logger, DataContext dataContext) : base(logger, dataContext) { }
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PARR.Domain.Entities.RobotEntities;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
@@ -17,5 +18,7 @@ namespace PARR.Domain.Entities
public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>(); public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>();
public ICollection<RobotConfigurationSnapshot> ConfigurationSnapshots { get; set; } = new HashSet<RobotConfigurationSnapshot>();
} }
} }

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using PARR.Domain.Constants;
using PARR.Domain.Entities.Base;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities.RobotEntities
{
/// <summary>
/// Снимки заданий роботам
/// </summary>
[Table("ConfigurationSnapshots", Schema = DatabaseSchemas.Robot)]
[Index(nameof(RobotCode), nameof(DateCreated))]
[Comment("Снимки заданий роботам")]
public class RobotConfigurationSnapshot : IBaseEntity
{
[Key]
public Guid Id { get; set; }
public DateTimeOffset DateCreated { get; set; }
[NotMapped]
public DateTimeOffset? DateModified { get; set; }
public int RobotCode { get; set; }
public int RobotStatusCode { get; set; }
public int TaskStatusCode { get; set; }
[Comment("Количество заданий в этой комбинации статусов")]
public int Count { get; set; }
[ForeignKey(nameof(RobotCode))]
public Robot? Robot { get; set; }
[ForeignKey(nameof(RobotStatusCode))]
public RobotStatus? RobotStatus { get; set; }
[ForeignKey(nameof(TaskStatusCode))]
public TaskStatus? TaskStatus { get; set; }
}
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using PARR.Domain.Entities.RobotEntities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities namespace PARR.Domain.Entities
@@ -19,5 +20,7 @@ namespace PARR.Domain.Entities
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>(); public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>();
public ICollection<RobotConfigurationSnapshot> ConfigurationSnapshots { get; set; } = new HashSet<RobotConfigurationSnapshot>();
} }
} }

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using PARR.Domain.Entities.RobotEntities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace PARR.Domain.Entities namespace PARR.Domain.Entities
@@ -21,5 +22,7 @@ namespace PARR.Domain.Entities
public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>(); public ICollection<RobotConfiguration> RobotConfigurations { get; set; } = new HashSet<RobotConfiguration>();
public ICollection<RobotHistory> RobotHistories { get; set; } = new HashSet<RobotHistory>(); public ICollection<RobotHistory> RobotHistories { get; set; } = new HashSet<RobotHistory>();
public ICollection<RobotConfigurationSnapshot> ConfigurationSnapshots { get; set; } = new HashSet<RobotConfigurationSnapshot>();
} }
} }

View File

@@ -138,5 +138,19 @@ namespace PARR.Domain.Settings
/// Зона ответственности для получения тайм зоны по умолчанию для расписаний /// Зона ответственности для получения тайм зоны по умолчанию для расписаний
/// </summary> /// </summary>
public string DefaultResponseAreaToTimeOffset { get; set; } = string.Empty; public string DefaultResponseAreaToTimeOffset { get; set; } = string.Empty;
/// <summary>
/// Минимально допустимый процент связей для продолжения синхронизации.
/// Защищает от удаления данных, если передающая система АИХ ИТ передала аномально малый объем связей.
/// </summary>
/// <remarks>
/// Диапазон: от 0 до 100
/// <list type="bullet">
/// <item><description>0 — Защита ОТКЛЮЧЕНА. Синхронизация запишет даже 0 связей (риск затереть данные).</description></item>
/// <item><description>100 — Строгий режим. Синхронизация упадет, если пропадет хотя бы одна связь.</description></item>
/// <item><description>97 — (Рекомендуется) Стоп, если объем новых связей ниже 97% от предыдущего.</description></item>
/// </list>
/// </remarks>
public int MinRelationshipsThresholdPct { get; set; } = 0;
} }
} }

View File

@@ -61,69 +61,5 @@ namespace PARR.NextRun
}, workerSettings.RepeatEvery); }, workerSettings.RepeatEvery);
} }
///// <summary>
///// Рассчет следующей даты срабатываения
///// </summary>
///// <returns></returns>
//private async Task HandlerAsync(ITemplateService templateService, INextRunService nextRunService, IRobotConfigurationService robotConfigurationService)
//{
// logger.LogInformation("Начинаю обновлять NextRun по расписанию");
// await UpdateNextRunAsync(templateService, nextRunService, robotConfigurationService);
// logger.LogInformation($"Завершено обновление полей NextRun.");
//}
//private async Task UpdateNextRunAsync(ITemplateService templateService, INextRunService nextRunService, IRobotConfigurationService robotConfigurationService)
//{
// // выбираем все шаблоны с просроченным nextRun в статусе Used
// var templatesForUpdate = await templateService.Get()
// .Include(t => t.RobotConfigurations)
// .Where(t =>
// t.StatusTypeId == TemplateStatusTypeEnum.Used
// && t.NextRun < DateTimeOffset.UtcNow
// ).ToListAsync();
// logger.LogInformation("Найдено шаблонов в статусе Used с просроченным NextRun {count} шт.", templatesForUpdate.Count);
// if (!templatesForUpdate.Any())
// return;
// foreach (var template in templatesForUpdate)
// {
// var newNextRun = await nextRunService.GetNextRunForTemplateAsync(template.Id, false);
// if (newNextRun.HasValue)
// {
// template.LastRun = template.NextRun;
// template.NextRun = newNextRun.Value;
// //так как nextRun обновился, пробуем поставить задание на обновление
// var config = robotConfigurationService.GetFromTemplateByRobotCode(RobotsEnum.ScheduleOrder, template);
// robotConfigurationService.SetUpdateTaskStatusIfAllow(config);
// }
// else
// {
// logger.LogError("При расчете nextRun для шаблона {templateId}, '{templateName}', nextRun=null. Это значит что при расчете возникла ошибка.", template.Id, template.Name);
// }
// }
// if (await templateService.CommitAsync(new HistoryInitiator { InitiatorComment = "Обновлён NextRun", InitiatorParrComponentId = ParrComponentsEnum.NextRun }))
// {
// logger.LogInformation("Обновлены значения полей NextRun для шаблонов в статусе Used, {count} шт.", templatesForUpdate.Count);
// }
// else
// {
// logger.LogError("Ошибка при обновлении значений полей NextRun, для шаблонов в статусе Used, {count} шт.", templatesForUpdate.Count);
// }
//}
} }
} }

View File

@@ -99,81 +99,5 @@ namespace PARR.NextRun
} }
} }
///// <summary>
///// Обновить все NextRun в JobGroup
///// </summary>
///// <param name="jobGroupId"></param>
///// <returns></returns>
//private async Task UpdateNextRunAsync(NextRunUpdateMq queryMq)
//{
// using var scope = serviceProvider.CreateScope();
// var templateService = scope.ServiceProvider.GetRequiredService<ITemplateService>();
// var nextRunService = scope.ServiceProvider.GetRequiredService<INextRunService>();
// var robotConfigurationService = scope.ServiceProvider.GetRequiredService<IRobotConfigurationService>();
// // Берем шаблоны только в статусе Used
// var templates = await templateService.Get()
// .Include(t => t.RobotConfigurations)
// .Where(t =>
// t.Job!.GroupId == queryMq.JobGroupId
// && t.StatusTypeId == TemplateStatusTypeEnum.Used
// ).ToListAsync();
// logger.LogInformation("Найдено шаблонов {count} шт. в статусе Used в группе работ {jobGroupId}", templates.Count, queryMq.JobGroupId);
// if (!templates.Any())
// return;
// var updatedTemplates = 0;
// foreach (var template in templates)
// {
// var newNextRun = await nextRunService.GetNextRunForTemplateAsync(template.Id, false);
// if (newNextRun == null)
// {
// logger.LogError("При расчете nextRun для шаблона {templateId}, {templateName} вернулся null", template.Id, template.Name);
// continue;
// }
// if (template.NextRun != newNextRun)
// {
// logger.LogInformation("Обновлен nextRun для шаблона {templateId}, {templateName}, newNextRun: {newNextRun}, oldNextRun: {oldNextRun}",
// template.Id, template.Name, newNextRun, template.NextRun);
// template.LastRun = template.NextRun;
// template.NextRun = newNextRun.Value;
// //так как nextRun обновился, пробуем поставить задание на обновление
// var config = robotConfigurationService.GetFromTemplateByRobotCode(RobotsEnum.ScheduleOrder, template);
// robotConfigurationService.SetUpdateTaskStatusIfAllow(config);
// updatedTemplates++;
// }
// else
// {
// logger.LogDebug("Не требуется обновлять nextRun для шаблона {templateId}, {templateName}. Рассчитанный и исходный равны. NextRun: {NextRun}",
// template.Id, template.Name, template.NextRun);
// }
// }
// if (updatedTemplates > 0)
// {
// if (await templateService.CommitAsync(queryMq.Initiator))
// {
// logger.LogInformation("Успешно обновлены nextRun у {count} шаблонов, группа работ: {jobGroupId}", updatedTemplates, queryMq.JobGroupId);
// }
// else
// {
// logger.LogError("При сохранении nextRun для шаблонов {count} шт, произошла ошибка при сохранении в БД. JobGroupId: {jobGroupId}", updatedTemplates, queryMq.JobGroupId);
// }
// }
// else
// {
// logger.LogInformation("Для группы работ {jobGroupId}, все nextRun актуальны. Нечего обновлять.", queryMq.JobGroupId);
// }
//}
} }
} }

View File

@@ -29,7 +29,7 @@
"QueueName": "parr-next-run-updater", "QueueName": "parr-next-run-updater",
"User": "next_run_updater_reader", "User": "next_run_updater_reader",
"Password": "LJFgsifgFidfsdvi123#!23", "Password": "LJFgsifgFidfsdvi123#!23",
"PrefetchCount": 100 "PrefetchCount": 1
} }
} }
} }

View File

@@ -0,0 +1,39 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM 10.99.253.167:8090/dotnet/runtime:9.0 AS base
WORKDIR /app
# This stage is used to build the service project
FROM 10.99.253.167:8090/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
USER root
WORKDIR /src
COPY ["NuGet.config", "."]
COPY ["PARR.SnapshotWorker/PARR.SnapshotWorker.csproj", "PARR.SnapshotWorker/"]
COPY ["PARR.Core/PARR.Core.csproj", "PARR.Core/"]
COPY ["PARR.Domain/PARR.Domain.csproj", "PARR.Domain/"]
COPY ["PARR.DAL/PARR.DAL.csproj", "PARR.DAL/"]
COPY ["PARR.Infrastructure/PARR.Infrastructure.csproj", "PARR.Infrastructure/"]
RUN dotnet restore "./PARR.SnapshotWorker/PARR.SnapshotWorker.csproj"
COPY . .
WORKDIR "/src/PARR.SnapshotWorker"
RUN dotnet build "./PARR.SnapshotWorker.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG app_version=0.0.0-default
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./PARR.SnapshotWorker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:Version=$app_version
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
#author
ARG commit_author=unknown
LABEL org.opencontainers.image.authors=$commit_author
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PARR.SnapshotWorker.dll"]

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-PARR.SnapshotWorker-87e7d52e-d873-4487-b6f4-e27d412ec0c1</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Elastic.CommonSchema.Serilog" Version="8.19.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PARR.Core\PARR.Core.csproj" />
<ProjectReference Include="..\PARR.DAL\PARR.DAL.csproj" />
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
<ProjectReference Include="..\PARR.Infrastructure\PARR.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using Elastic.CommonSchema.Serilog;
using PARR.Core;
using PARR.Core.Services.Snapshots.Interfaces;
using PARR.DAL;
using PARR.Infrastructure;
using PARR.SnapshotWorker;
using PARR.SnapshotWorker.Settings;
using Serilog;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddLogging(config =>
{
config.ClearProviders();
var logger = new LoggerConfiguration();
if (builder.Environment.IsProduction())
logger.WriteTo.Console(new EcsTextFormatter());
else
logger.WriteTo.Console();
logger.ReadFrom.Configuration(builder.Configuration);
config.AddSerilog(logger.CreateLogger());
});
var commonSettings = new CommonSettings();
builder.Configuration.GetSection(nameof(CommonSettings)).Bind(commonSettings);
builder.Services.AddSingleton(commonSettings);
builder.Services.AddSingleton<ISnapshotSettings>(commonSettings);
builder.Services.AddDalServices(builder.Configuration);
builder.Services.AddCoreServices(builder.Configuration);
builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Configuration.AddDalConfigurations(builder.Services);
builder.Services.AddDallSettings(builder.Configuration);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,15 @@
{
"profiles": {
"PARR.SnapshotWorker": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true
},
"Container (Dockerfile)": {
"commandName": "Docker"
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@@ -0,0 +1,13 @@
using PARR.Core.Services.Snapshots.Interfaces;
namespace PARR.SnapshotWorker.Settings
{
internal record CommonSettings : ISnapshotSettings
{
public TimeSpan RobotConfigurationSnapshotInterval { get; init; }
public TimeSpan RobotConfigurationSnapshotRetentionPeriod { get; init; }
public TimeSpan RobotSnapshotRetentionPeriod { get; init; }
}
}

View File

@@ -0,0 +1,114 @@
using PARR.Core.Common.Interfaces;
using PARR.Core.Services.Snapshots.Interfaces;
namespace PARR.SnapshotWorker
{
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IIntervalService _intervalService;
private readonly IServiceProvider _serviceProvider;
// Хранилище времени СЛЕДУЮЩЕГО запуска для каждого сервиса
private readonly Dictionary<string, DateTimeOffset> _nextRunTimers = new();
// День последней успешной очистки базы данных
private int _lastCleanupDay = -1;
// Интервал, должен быть меньше минуты, чтобы попадать во все возможные интервалы
TimeSpan tickInterval = TimeSpan.FromSeconds(25);
public Worker(
ILogger<Worker> logger,
IIntervalService intervalService,
IServiceProvider serviceProvider
)
{
_logger = logger;
_intervalService = intervalService;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Запуск фонового сервиса создания системных снапшотов");
await _intervalService.IntervalInitAsync(async () =>
{
var now = DateTimeOffset.UtcNow;
using (var scope = _serviceProvider.CreateScope())
{
// Собираем абсолютно все зарегистрированные сервисы снапшотов
var providers = scope.ServiceProvider.GetServices<ISnapshotProvider>();
#region Параллельный сбор снапшотов
var tasksToRun = new List<Task>();
foreach (var provider in providers)
{
var providerKey = provider.GetType().FullName!;
// Если сервис видим впервые, планируем его первый старт прямо сейчас
if (!_nextRunTimers.ContainsKey(providerKey))
{
_nextRunTimers[providerKey] = now;
}
// Если текущее время добежало до запланированного «будильника»
if (now >= _nextRunTimers[providerKey])
{
// СРАЗУ планируем следующий старт, чтобы сетка времени не съезжала
// из-за времени выполнения самого метода
_nextRunTimers[providerKey] = now.Add(provider.Interval);
// Добавляем таску в список для параллельного выполнения (без await!)
tasksToRun.Add(ExecuteSafelyAsync(provider, () => provider.TakeSnapshotAsync(stoppingToken), "Создание снапшота"));
}
}
// Запускаем все готовые снапшоты ОДНОВРЕМЕННО
if (tasksToRun.Any())
{
await Task.WhenAll(tasksToRun);
}
#endregion
#region Ежедневная очистка БД
// Если наступили новые сутки в формате UTC — запускаем ротацию старых данных
if (now.Day != _lastCleanupDay)
{
_logger.LogInformation("Наступили новые сутки. Запуск процесса очистки устаревших снапшотов...");
foreach (var provider in providers)
{
await ExecuteSafelyAsync(provider, () => provider.CleanUpOldSnapshotsAsync(stoppingToken), "Очистка старых данных");
}
// Запоминаем, что за сегодня очистку уже провели успешно
_lastCleanupDay = now.Day;
}
#endregion
}
}, tickInterval);
}
private async Task ExecuteSafelyAsync(ISnapshotProvider provider, Func<Task> action, string operationName)
{
try
{
await action();
}
catch (Exception ex)
{
_logger.LogError(ex, "Критическая ошибка во время операции '{Operation}' в сервисе {ProviderName}", operationName, provider.GetType().Name);
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"ConnectionStrings": {
"RedisConnection": "10.99.253.216:6379,password=ParrP@ssPtk202MMdevDvs"
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "log/log-.txt",
"rollingInterval": "Day"
}
}
]
}
}

View File

@@ -0,0 +1,30 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=10.99.253.184;Database=parr;User Id=app_parr; Password=PosdfkhT&)%sdfligL&%5546;",
"RedisConnection": "parr-redis:6379,password=ParrP@ssPtk202MMdevDvs"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.EntityFrameworkCore": "Error",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Error",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
},
"CommonSettings": {
"RobotConfigurationSnapshotInterval": "00:02:00",
"RobotConfigurationSnapshotRetentionPeriod": "60:00:00:00",
"RobotSnapshotRetentionPeriod": "60:00:00:00"
}
}

View File

@@ -3,11 +3,12 @@ using PARR.Core.Services.MatchingStatusService;
using PARR.Domain.Cache.Models; using PARR.Domain.Cache.Models;
using PARR.Domain.Entities.Base.History; using PARR.Domain.Entities.Base.History;
using PARR.Domain.Enums; using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.GroupedSync;
using PARR.TemplateMatcher.Services.Implementations.GroupedSync; using PARR.TemplateMatcher.Services.Implementations.GroupedSync;
using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Services.Interfaces;
using System.Diagnostics; using System.Diagnostics;
namespace PARR.TemplateMatcher.Services.GroupedSync; namespace PARR.TemplateMatcher.Services.Implementations;
internal class GroupedTemplateSynchronizer : ITemplateSynchronizer internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
{ {
@@ -20,7 +21,8 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
IEnumerable<IGroupedSyncStage> readStages, IEnumerable<IGroupedSyncStage> readStages,
IEnumerable<IGroupedSyncWriteStage> writeStages, IEnumerable<IGroupedSyncWriteStage> writeStages,
IMatchingStatusService matchingStatusService, IMatchingStatusService matchingStatusService,
ILogger<GroupedTemplateSynchronizer> logger) ILogger<GroupedTemplateSynchronizer> logger
)
{ {
_readStages = readStages; _readStages = readStages;
_writeStages = writeStages; _writeStages = writeStages;
@@ -33,7 +35,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
_logger.LogInformation("Начало синхронизации шаблонов для JobGroup {JobGroupId}", jobGroupId); _logger.LogInformation("Начало синхронизации шаблонов для JobGroup {JobGroupId}", jobGroupId);
var existingStatus = await _matchingStatusService.GetStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup); var existingStatus = await _matchingStatusService.GetStatusAsync(jobGroupId, SyncTaskEntityTypeEnum.JobGroup);
if (existingStatus.DetailsJobGroups?.Any() == true) if (existingStatus.DetailsJobGroups?.Count > 0)
{ {
_logger.LogWarning("Синхронизация для JobGroup {JobGroupId} уже запущена. Пропускаем.", jobGroupId); _logger.LogWarning("Синхронизация для JobGroup {JobGroupId} уже запущена. Пропускаем.", jobGroupId);
return; return;

View File

@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PARR.Core.Common.Interfaces.RabbitServices;
using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.JobRepositories; using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Core.Repositories.Interfaces.Unit; using PARR.Core.Repositories.Interfaces.Unit;
@@ -26,41 +25,32 @@ namespace PARR.TemplateMatcher.Services.Implementations;
internal class SimpleTemplateSynchronizer : ITemplateSynchronizer internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
{ {
#if DEBUG #if DEBUG
private readonly Guid targetUnitId = Guid.Parse("358437ac-1eeb-4c00-840c-998326f657ac"); private readonly Guid _targetUnitId = Guid.Parse("358437ac-1eeb-4c00-840c-998326f657ac");
#endif #endif
private const bool DefaultUsedTemplateState = false; private readonly IEnumerable<ISimpleSyncStage> _readStages;
private const bool DefaultUsedScheduleState = false; private readonly IEnumerable<ISimpleSyncWriteStage> _writeStages;
private readonly ILogger<SimpleTemplateSynchronizer> _logger;
private readonly IEnumerable<ISimpleSyncStage> readStages; private readonly IUnitFilterService _unitFilterService;
private readonly IEnumerable<ISimpleSyncWriteStage> writeStages; private readonly ITemplateRepository _templateService;
private readonly ILogger<SimpleTemplateSynchronizer> logger; private readonly IJobRepository _jobService;
private readonly IUnitFilterService unitFilterService; private readonly ITemplateNameNormalizer _templateNameNormalizer;
private readonly MqSettings mqSettings; private readonly ITemplateMqPublisher _templateMqPublisher;
private readonly IRabbitService mqService; private readonly IMatchingStatusService _matchingStatusService;
private readonly ITemplateRepository templateService; private readonly SettingsFromDb _settingsFromDb;
private readonly IJobRepository jobService; private readonly IOptions<TemplateSettings> _templateSettings;
private readonly ITemplateNameNormalizer templateNameNormalizer; private readonly IUnitFieldRepository _unitFieldService;
private readonly ITemplateAllocationService templateAllocationService; private readonly IUnitInValueRepository _unitInValueService;
private readonly ITemplateMqPublisher templateMqPublisher; private readonly IUnitRepository _unitRepository;
private readonly IMatchingStatusService matchingStatusService;
private readonly SettingsFromDb settingsFromDb;
private readonly IOptions<TemplateSettings> templateSettings;
private readonly IUnitFieldRepository unitFieldService;
private readonly IUnitInValueRepository unitInValueService;
private readonly IUnitRepository unitRepository;
public SimpleTemplateSynchronizer( public SimpleTemplateSynchronizer(
IEnumerable<ISimpleSyncStage> readStages, IEnumerable<ISimpleSyncStage> readStages,
IEnumerable<ISimpleSyncWriteStage> writeStages, IEnumerable<ISimpleSyncWriteStage> writeStages,
ILogger<SimpleTemplateSynchronizer> logger, ILogger<SimpleTemplateSynchronizer> logger,
IUnitFilterService unitFilterService, IUnitFilterService unitFilterService,
MqSettings mqSettings,
IRabbitService mqService,
ITemplateRepository templateService, ITemplateRepository templateService,
IJobRepository jobService, IJobRepository jobService,
ITemplateNameNormalizer templateNameNormalizer, ITemplateNameNormalizer templateNameNormalizer,
ITemplateAllocationService templateAllocationService,
ITemplateMqPublisher templateMqPublisher, ITemplateMqPublisher templateMqPublisher,
IMatchingStatusService matchingStatusService, IMatchingStatusService matchingStatusService,
SettingsFromDb settingsFromDb, SettingsFromDb settingsFromDb,
@@ -70,40 +60,37 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
IUnitRepository unitRepository IUnitRepository unitRepository
) )
{ {
this.readStages = readStages; this._readStages = readStages;
this.writeStages = writeStages; this._writeStages = writeStages;
this.logger = logger; this._logger = logger;
this.unitFilterService = unitFilterService; this._unitFilterService = unitFilterService;
this.mqSettings = mqSettings; this._templateService = templateService;
this.mqService = mqService; this._jobService = jobService;
this.templateService = templateService; this._templateNameNormalizer = templateNameNormalizer;
this.jobService = jobService; this._templateMqPublisher = templateMqPublisher;
this.templateNameNormalizer = templateNameNormalizer; this._matchingStatusService = matchingStatusService;
this.templateAllocationService = templateAllocationService; this._settingsFromDb = settingsFromDb;
this.templateMqPublisher = templateMqPublisher; this._templateSettings = templateSettings;
this.matchingStatusService = matchingStatusService; this._unitFieldService = unitFieldService;
this.settingsFromDb = settingsFromDb; this._unitInValueService = unitInValueService;
this.templateSettings = templateSettings; this._unitRepository = unitRepository;
this.unitFieldService = unitFieldService;
this.unitInValueService = unitInValueService;
this.unitRepository = unitRepository;
} }
public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator) public async Task SyncTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator)
{ {
if (jobId == settingsFromDb.JobIdForUnusedTemplates) if (jobId == _settingsFromDb.JobIdForUnusedTemplates)
{ {
logger.LogInformation("Обработка синхронизации для Job неиспользуемых шаблонов '{JobId}'", jobId); _logger.LogInformation("Обработка синхронизации для Job неиспользуемых шаблонов '{JobId}'", jobId);
await SyncUnusedTemplatesAsync(jobId, initiator); await SyncUnusedTemplatesAsync(jobId, initiator);
return; return;
} }
logger.LogInformation("Начало синхронизации шаблонов для Job {JobId}", jobId); _logger.LogInformation("Начало синхронизации шаблонов для Job {JobId}", jobId);
var existingStatus = await matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); var existingStatus = await _matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
if (existingStatus.DetailsJobs?.Any() == true) if (existingStatus.DetailsJobs?.Count > 0)
{ {
logger.LogWarning("Синхронизация для Job {JobId} уже запущена. Пропускаем.", jobId); _logger.LogWarning("Синхронизация для Job {JobId} уже запущена. Пропускаем.", jobId);
return; return;
} }
@@ -113,49 +100,48 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
Action = TemplateMatcherActionEnum.Sync, Action = TemplateMatcherActionEnum.Sync,
Comment = "Начало синхронизации" Comment = "Начало синхронизации"
}; };
await matchingStatusService.SetMatchingStatusAsync( await _matchingStatusService.SetMatchingStatusAsync(
jobId, SyncTaskEntityTypeEnum.Job, jobId, SyncTaskEntityTypeEnum.Job,
new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) }, new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) },
TimeSpan.FromMinutes(35)); TimeSpan.FromMinutes(35));
// Таймер запускается ПОСЛЕ инфраструктурных операций (статус, проверка блокировки) // Таймер запускается ПОСЛЕ инфраструктурных операций (статус, проверка блокировки)
var totalSw = Stopwatch.StartNew(); var totalSw = Stopwatch.StartNew();
var context = new SimpleSyncContext { JobId = jobId, Initiator = initiator };
try try
{ {
var context = new SimpleSyncContext { JobId = jobId, Initiator = initiator }; foreach (var stage in _readStages)
foreach (var stage in readStages)
{ {
var stageSw = Stopwatch.StartNew(); var stageSw = Stopwatch.StartNew();
await stage.ExecuteAsync(context); await stage.ExecuteAsync(context);
stageSw.Stop(); stageSw.Stop();
logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс", _logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс",
context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds); context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds);
} }
foreach (var stage in writeStages) foreach (var stage in _writeStages)
{ {
var stageSw = Stopwatch.StartNew(); var stageSw = Stopwatch.StartNew();
await stage.ExecuteAsync(context); await stage.ExecuteAsync(context);
stageSw.Stop(); stageSw.Stop();
logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс", _logger.LogDebug("[Perf] Job '{JobName}' ({JobId}) | Этап: {Stage} | Время: {Ms} мс",
context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds); context.JobName, jobId, stage.StageName, stageSw.ElapsedMilliseconds);
} }
totalSw.Stop(); totalSw.Stop();
logger.LogInformation("[Perf] Job '{JobName}' ({JobId}) | ИТОГО: {TotalMs} мс", _logger.LogInformation("[Perf] Job '{JobName}' ({JobId}) | ИТОГО: {TotalMs} мс",
context.JobName, jobId, totalSw.ElapsedMilliseconds); context.JobName, jobId, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно"); await UpdateMatchingStatusAsync(jobId, "Синхронизация завершена успешно");
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
logger.LogInformation("Синхронизация шаблонов завершена для Job '{JobName}' ({JobId})", _logger.LogInformation("Синхронизация шаблонов завершена для Job '{JobName}' ({JobId})",
context.JobName, jobId); context.JobName, jobId);
} }
catch (Exception ex) catch (Exception ex)
{ {
totalSw.Stop(); totalSw.Stop();
logger.LogError(ex, "Ошибка при синхронизации Job '{JobName}' ({JobId}) через {ElapsedMs} мс", _logger.LogError(ex, "Ошибка при синхронизации Job '{JobName}' ({JobId}) через {ElapsedMs} мс",
string.Empty, jobId, totalSw.ElapsedMilliseconds); string.Empty, jobId, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}"); await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}");
throw; throw;
@@ -165,19 +151,19 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator) public async Task SyncTemplatesForJobGroupAsync(Guid jobGroupId, HistoryInitiator initiator)
{ {
logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId); _logger.LogWarning("SimpleTemplateSynchronizer: SyncTemplatesForJobGroup вызван для JobGroup {JobGroupId}. Это не поддерживаемая операция.", jobGroupId);
} }
public async Task UpdateTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator) public async Task UpdateTemplatesForJobAsync(Guid jobId, HistoryInitiator initiator)
{ {
logger.LogDebug("Обновление шаблонов для Job {JobId}", jobId); _logger.LogDebug("Обновление шаблонов для Job {JobId}", jobId);
// === Проверка: уже запущена? === // === Проверка: уже запущена? ===
var existingStatus = await matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); var existingStatus = await _matchingStatusService.GetStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
if (existingStatus.DetailsJobs?.Any() == true) if (existingStatus.DetailsJobs?.Count > 0)
{ {
logger.LogWarning("Обновление для Job {JobId} уже запущено. Пропускаем.", jobId); _logger.LogWarning("Обновление для Job {JobId} уже запущено. Пропускаем.", jobId);
return; return;
} }
@@ -187,7 +173,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
Action = TemplateMatcherActionEnum.Update, Action = TemplateMatcherActionEnum.Update,
Comment = "Начало обновления имён" Comment = "Начало обновления имён"
}; };
await matchingStatusService.SetMatchingStatusAsync( await _matchingStatusService.SetMatchingStatusAsync(
jobId, jobId,
SyncTaskEntityTypeEnum.Job, SyncTaskEntityTypeEnum.Job,
new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) }, new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) },
@@ -196,7 +182,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
try try
{ {
var job = await jobService.Get() var job = await _jobService.Get()
.AsNoTracking() .AsNoTracking()
.Include(j => j.AutoControl) .Include(j => j.AutoControl)
.Include(j => j.Tnk) .Include(j => j.Tnk)
@@ -208,18 +194,18 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
if (job == null) if (job == null)
{ {
logger.LogWarning("Job {JobId} не найден.", jobId); _logger.LogWarning("Job {JobId} не найден.", jobId);
await UpdateMatchingStatusAsync(jobId, "Job не найден"); await UpdateMatchingStatusAsync(jobId, "Job не найден");
return; return;
} }
// === Получение отфильтрованных юнитов с полной информацией === // === Получение отфильтрованных юнитов с полной информацией ===
var filteredUnits = await unitFilterService.GetUnitsByJobFilterAsync(jobId); var filteredUnits = await _unitFilterService.GetUnitsByJobFilterAsync(jobId);
if (filteredUnits == null || !filteredUnits.Any()) if (filteredUnits == null || !filteredUnits.Any())
{ {
logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId); _logger.LogInformation("Для Job {JobId} фильтры не дали Unit'ов.", jobId);
await UpdateMatchingStatusAsync(jobId, "Нет Unit'ов — обновление не требуется"); await UpdateMatchingStatusAsync(jobId, "Нет Unit'ов — обновление не требуется");
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
return; return;
} }
@@ -228,17 +214,17 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
#if DEBUG #if DEBUG
// Отладка: проверить, есть ли юнит в unitIds // Отладка: проверить, есть ли юнит в unitIds
if (unitIds.Contains(targetUnitId)) if (unitIds.Contains(_targetUnitId))
{ {
logger.LogDebug("Юнит {TargetUnitId} найден в unitIds.", targetUnitId); _logger.LogDebug("Юнит {TargetUnitId} найден в unitIds.", _targetUnitId);
} }
else else
{ {
logger.LogDebug("Юнит {TargetUnitId} НЕ найден в unitIds.", targetUnitId); _logger.LogDebug("Юнит {TargetUnitId} НЕ найден в unitIds.", _targetUnitId);
} }
#endif #endif
var existingTemplates = await templateService.Get() var existingTemplates = await _templateService.Get()
.AsNoTracking() .AsNoTracking()
.Include(t => t.Unit) .Include(t => t.Unit)
.Include(t => t.UnitsInTemplate) .Include(t => t.UnitsInTemplate)
@@ -254,10 +240,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
{ {
if (unitIds.Contains(template.UnitId)) if (unitIds.Contains(template.UnitId))
{ {
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(template); var expectedName = await _templateNameNormalizer.GetNormalizedTemplateNameAsync(template);
if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(template.Name, expectedName, StringComparison.OrdinalIgnoreCase))
{ {
logger.LogDebug("Шаблон {TemplateId} требует обновления имени: старое = '{OldName}', новое = '{NewName}'", template.Id, template.Name, expectedName); _logger.LogDebug("Шаблон {TemplateId} требует обновления имени: старое = '{OldName}', новое = '{NewName}'", template.Id, template.Name, expectedName);
var updateRequest = new TemplateUpdaterMessage var updateRequest = new TemplateUpdaterMessage
{ {
@@ -274,18 +260,18 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона
}; };
await templateMqPublisher.PublishUpdateAsync(updateRequest); await _templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
} }
} }
await UpdateMatchingStatusAsync(jobId, "Обновление завершено"); await UpdateMatchingStatusAsync(jobId, "Обновление завершено");
await matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job); await _matchingStatusService.DeleteMatchingStatusAsync(jobId, SyncTaskEntityTypeEnum.Job);
logger.LogInformation("Обновление шаблонов завершено для Job {JobId}.", jobId); _logger.LogInformation("Обновление шаблонов завершено для Job {JobId}.", jobId);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при обновлении Job {JobId}", jobId); _logger.LogError(ex, "Ошибка при обновлении Job {JobId}", jobId);
await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}"); await UpdateMatchingStatusAsync(jobId, $"Ошибка: {ex.Message}");
throw; throw;
} }
@@ -297,10 +283,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
// Проверка отмены в самом начале // Проверка отмены в самом начале
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var existingStatus = await matchingStatusService.GetStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job); var existingStatus = await _matchingStatusService.GetStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job);
if (existingStatus.DetailsJobs?.Any() == true) if (existingStatus.DetailsJobs?.Count > 0)
{ {
logger.LogWarning("Синхронизация для Job неиспользуемых шаблонов {JobId} уже запущена. Пропускаем.", unusedJobId); _logger.LogWarning("Синхронизация для Job неиспользуемых шаблонов {JobId} уже запущена. Пропускаем.", unusedJobId);
return; return;
} }
@@ -310,22 +296,24 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
Action = TemplateMatcherActionEnum.Sync, Action = TemplateMatcherActionEnum.Sync,
Comment = "Синхронизация неиспользуемых шаблонов" Comment = "Синхронизация неиспользуемых шаблонов"
}; };
await matchingStatusService.SetMatchingStatusAsync( await _matchingStatusService.SetMatchingStatusAsync(
unusedJobId, unusedJobId,
SyncTaskEntityTypeEnum.Job, SyncTaskEntityTypeEnum.Job,
new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) }, new MatchingStatusItem { Data = initialStatus, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) },
TimeSpan.FromMinutes(30) TimeSpan.FromMinutes(30)
); );
var totalSw = Stopwatch.StartNew();
try try
{ {
// 1. Находим ID нужных полей // 1. Находим ID нужных полей
var responsableAreaField = await unitFieldService.GetByAihitNameAsync(UnusedTemplateConstants.ResponsibilityAreaFieldName); var responsableAreaField = await _unitFieldService.GetByAihitNameAsync(UnusedTemplateConstants.ResponsibilityAreaFieldName, ct);
var tagField = await unitFieldService.GetByAihitNameAsync(UnusedTemplateConstants.ParrTagFieldName); var tagField = await _unitFieldService.GetByAihitNameAsync(UnusedTemplateConstants.ParrTagFieldName, ct);
if (responsableAreaField == null || tagField == null) if (responsableAreaField == null || tagField == null)
{ {
logger.LogError("Не найдены поля '{Field1}' или '{Field2}'. Синхронизация прервана.", UnusedTemplateConstants.ResponsibilityAreaFieldName, UnusedTemplateConstants.NotUsedTagValue); _logger.LogError("Не найдены поля '{Field1}' или '{Field2}'. Синхронизация прервана.", UnusedTemplateConstants.ResponsibilityAreaFieldName, UnusedTemplateConstants.NotUsedTagValue);
await UpdateMatchingStatusAsync(unusedJobId, "Ошибка конфигурации полей"); await UpdateMatchingStatusAsync(unusedJobId, "Ошибка конфигурации полей");
return; return;
} }
@@ -334,7 +322,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
var tagFieldId = tagField.Id; var tagFieldId = tagField.Id;
// 2. Находим ValueId для тега "ПАРР-НЕИСП" // 2. Находим ValueId для тега "ПАРР-НЕИСП"
var targetTagValueId = await unitInValueService.Get() var targetTagValueId = await _unitInValueService.Get()
.AsNoTracking() .AsNoTracking()
.Where(uiv => uiv.FieldId == tagFieldId && uiv.Value != null && uiv.Value.Value == UnusedTemplateConstants.NotUsedTagValue) .Where(uiv => uiv.FieldId == tagFieldId && uiv.Value != null && uiv.Value.Value == UnusedTemplateConstants.NotUsedTagValue)
.Select(uiv => uiv.ValueId) .Select(uiv => uiv.ValueId)
@@ -342,31 +330,31 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
if (targetTagValueId == Guid.Empty) if (targetTagValueId == Guid.Empty)
{ {
logger.LogWarning("Значение '{TagValue}' для поля '{FieldName}' не найдено в справочнике UnitFieldValue.", UnusedTemplateConstants.NotUsedTagValue, UnusedTemplateConstants.ParrTagFieldName); _logger.LogWarning("Значение '{TagValue}' для поля '{FieldName}' не найдено в справочнике UnitFieldValue.", UnusedTemplateConstants.NotUsedTagValue, UnusedTemplateConstants.ParrTagFieldName);
} }
var unusedJob = await jobService.Get().AsNoTracking() var unusedJob = await _jobService.Get().AsNoTracking()
.Include(t => t!.Group).ThenInclude(t => t!.GroupType) .Include(t => t!.Group).ThenInclude(t => t!.GroupType)
.Include(t => t!.Tnk) .Include(t => t!.Tnk)
.FirstOrDefaultAsync(j => j.Id == unusedJobId, ct); .FirstOrDefaultAsync(j => j.Id == unusedJobId, ct);
if (unusedJob == null) if (unusedJob == null)
{ {
logger.LogError("Job неиспользуемых шаблонов {JobId} не найден.", unusedJobId); _logger.LogError("Job неиспользуемых шаблонов {JobId} не найден.", unusedJobId);
await UpdateMatchingStatusAsync(unusedJobId, "Job не найден"); await UpdateMatchingStatusAsync(unusedJobId, "Job не найден");
return; return;
} }
var unusedTemplates = await templateService.Get() var unusedTemplates = await _templateService.Get()
.Include(t => t.Unit) .Include(t => t.Unit)
.Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Unused) .Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Unused)
.ToListAsync(ct); .ToListAsync(ct);
if (!unusedTemplates.Any()) if (!unusedTemplates.Any())
{ {
logger.LogInformation("Не найдено шаблонов со статусом Unused."); _logger.LogInformation("Не найдено шаблонов со статусом Unused.");
await UpdateMatchingStatusAsync(unusedJobId, "Нет шаблонов для обработки"); await UpdateMatchingStatusAsync(unusedJobId, "Нет шаблонов для обработки");
await matchingStatusService.DeleteMatchingStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job); await _matchingStatusService.DeleteMatchingStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job);
return; return;
} }
@@ -376,7 +364,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
var allTemplateUnitIds = unusedTemplates.Select(t => t.UnitId).Distinct().ToList(); var allTemplateUnitIds = unusedTemplates.Select(t => t.UnitId).Distinct().ToList();
// Получаем значения ЗОНА_ОТВЕТСТВЕННОСТИ для всех юнитов шаблонов // Получаем значения ЗОНА_ОТВЕТСТВЕННОСТИ для всех юнитов шаблонов
var unitResponsableAreaValues = await unitInValueService.GetByUnitIdsAndFieldIdsAsync(allTemplateUnitIds, new List<Guid> { responsableAreaFieldId }); var unitResponsableAreaValues = await _unitInValueService.GetByUnitIdsAndFieldIdsAsync(allTemplateUnitIds, new List<Guid> { responsableAreaFieldId }, ct);
var unitToResponsableAreaValueMap = unitResponsableAreaValues var unitToResponsableAreaValueMap = unitResponsableAreaValues
.Where(uiv => uiv.ValueId != Guid.Empty) .Where(uiv => uiv.ValueId != Guid.Empty)
.ToDictionary(uiv => uiv.UnitId, uiv => uiv.ValueId); .ToDictionary(uiv => uiv.UnitId, uiv => uiv.ValueId);
@@ -385,15 +373,16 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
var responsableAreaToTargetUnitMap = new Dictionary<Guid, Guid>(); var responsableAreaToTargetUnitMap = new Dictionary<Guid, Guid>();
var distinctResponsableAreaValues = unitToResponsableAreaValueMap.Values.Distinct().ToList(); var distinctResponsableAreaValues = unitToResponsableAreaValueMap.Values.Distinct().ToList();
if (targetTagValueId != Guid.Empty && distinctResponsableAreaValues.Any()) if (targetTagValueId != Guid.Empty &&
distinctResponsableAreaValues.Any())
{ {
logger.LogDebug("Пакетный поиск целевых юнитов с тегом 'ПАРР-НЕИСП' для {Count} уникальных значений ЗОНА_ОТВЕТСТВЕННОСТИ.", distinctResponsableAreaValues.Count); _logger.LogDebug("Пакетный поиск целевых юнитов с тегом 'ПАРР-НЕИСП' для {Count} уникальных значений ЗОНА_ОТВЕТСТВЕННОСТИ.", distinctResponsableAreaValues.Count);
// Передаем ct в ToListAsync // Передаем ct в ToListAsync
var matches = await unitInValueService.Get().AsNoTracking() var matches = await _unitInValueService.Get().AsNoTracking()
.Where(uiv => uiv.FieldId == responsableAreaFieldId && distinctResponsableAreaValues.Contains(uiv.ValueId)) .Where(uiv => uiv.FieldId == responsableAreaFieldId && distinctResponsableAreaValues.Contains(uiv.ValueId))
.Join( .Join(
unitInValueService.Get().AsNoTracking().Where(t => t.FieldId == tagFieldId && t.ValueId == targetTagValueId), _unitInValueService.Get().AsNoTracking().Where(t => t.FieldId == tagFieldId && t.ValueId == targetTagValueId),
responsableArea => responsableArea.UnitId, responsableArea => responsableArea.UnitId,
tag => tag.UnitId, tag => tag.UnitId,
(responsableArea, tag) => new { responsableArea.ValueId, responsableArea.UnitId } (responsableArea, tag) => new { responsableArea.ValueId, responsableArea.UnitId }
@@ -404,7 +393,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
.GroupBy(x => x.ValueId) .GroupBy(x => x.ValueId)
.ToDictionary(g => g.Key, g => g.First().UnitId); .ToDictionary(g => g.Key, g => g.First().UnitId);
logger.LogDebug("Сформирован кэш соответствий: найдено {Count} целевых юнитов.", responsableAreaToTargetUnitMap.Count); _logger.LogDebug("Сформирован кэш соответствий: найдено {Count} целевых юнитов.", responsableAreaToTargetUnitMap.Count);
} }
foreach (var template in unusedTemplates) foreach (var template in unusedTemplates)
@@ -416,7 +405,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
if (template.Unit == null) if (template.Unit == null)
{ {
logger.LogWarning("У шаблона {TemplateId} отсутствует Unit. Пропускаем.", template.Id); _logger.LogWarning("У шаблона {TemplateId} отсутствует Unit. Пропускаем.", template.Id);
processed++; processed++;
continue; continue;
} }
@@ -441,11 +430,11 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
if (targetUnitId.HasValue && targetUnitId.Value != template.UnitId) if (targetUnitId.HasValue && targetUnitId.Value != template.UnitId)
{ {
logger.LogInformation("Для шаблона {TemplateId} найден новый UnitId {NewUnitId} (был {OldUnitId}).", _logger.LogInformation("Для шаблона {TemplateId} найден новый UnitId {NewUnitId} (был {OldUnitId}).",
template.Id, targetUnitId.Value, template.UnitId); template.Id, targetUnitId.Value, template.UnitId);
// Передаем ct в запрос // Передаем ct в запрос
var newUnit = await unitRepository.Get().AsNoTracking() var newUnit = await _unitRepository.Get().AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == targetUnitId.Value, ct); .FirstOrDefaultAsync(u => u.Id == targetUnitId.Value, ct);
if (newUnit != null) if (newUnit != null)
@@ -454,13 +443,13 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
} }
else else
{ {
logger.LogWarning("Не удалось загрузить новый юнит {UnitId}. Используем старый.", targetUnitId.Value); _logger.LogWarning("Не удалось загрузить новый юнит {UnitId}. Используем старый.", targetUnitId.Value);
finalUnitId = template.UnitId; finalUnitId = template.UnitId;
} }
} }
else if (!hasResponsableArea) else if (!hasResponsableArea)
{ {
logger.LogDebug("У юнита {UnitId} шаблона {TemplateId} нет значения поля ЗОНА_ОТВЕТСТВЕННОСТИ. Оставляем текущий UnitId.", template.UnitId, template.Id); _logger.LogDebug("У юнита {UnitId} шаблона {TemplateId} нет значения поля ЗОНА_ОТВЕТСТВЕННОСТИ. Оставляем текущий UnitId.", template.UnitId, template.Id);
} }
// 4. Генерация целевого имени // 4. Генерация целевого имени
@@ -473,30 +462,43 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
if (!unitChanged && !jobChanged && !nameChanged) if (!unitChanged && !jobChanged && !nameChanged)
{ {
logger.LogDebug("Шаблон {TemplateId} уже актуален. Пропуск отправки в MQ.", template.Id); _logger.LogDebug("Шаблон {TemplateId} уже актуален. Пропуск отправки в MQ.", template.Id);
processed++; processed++;
continue; continue;
} }
await SendUpdateRequest(template, unusedJobId, expectedName, initiator, finalUnitId); await SendUpdateRequest(template, unusedJobId, expectedName, initiator, finalUnitId);
logger.LogDebug("Отправлен запрос на обновление шаблона {TemplateId}. Изменения: Unit={U}, Job={J}, Name={N}", _logger.LogDebug("Отправлен запрос на обновление шаблона {TemplateId}. Изменения: Unit={U}, Job={J}, Name={N}",
template.Id, unitChanged, jobChanged, nameChanged); template.Id, unitChanged, jobChanged, nameChanged);
processed++; processed++;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при обработке шаблона {TemplateId}", template.Id); _logger.LogError(ex, "Ошибка при обработке шаблона {TemplateId}", template.Id);
} }
} }
totalSw.Stop();
totalSw.Stop();
_logger.LogInformation("[Perf] Job '{JobName}' ({JobId}) | ИТОГО: {TotalMs} мс",
unusedJob.Name, unusedJob.Id, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(unusedJobId, "Синхронизация неиспользуемых шаблонов завершена"); await UpdateMatchingStatusAsync(unusedJobId, "Синхронизация неиспользуемых шаблонов завершена");
await matchingStatusService.DeleteMatchingStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job); await _matchingStatusService.DeleteMatchingStatusAsync(unusedJobId, SyncTaskEntityTypeEnum.Job);
logger.LogInformation("Синхронизация неиспользуемых шаблонов завершена. Обработано {Count} шаблонов.", unusedTemplates.Count); totalSw.Stop();
_logger.LogInformation(
"Синхронизация неиспользуемых шаблонов завершена. Обработано {Count} шаблонов",
unusedTemplates.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при синхронизации неиспользуемых шаблонов для Job {JobId}", unusedJobId); totalSw.Stop();
_logger.LogError(ex,
"Ошибка при синхронизации неиспользуемых шаблонов для Job {JobId} через {ElapsedMs} мс",
unusedJobId, totalSw.ElapsedMilliseconds);
await UpdateMatchingStatusAsync(unusedJobId, $"Ошибка: {ex.Message}"); await UpdateMatchingStatusAsync(unusedJobId, $"Ошибка: {ex.Message}");
throw; throw;
} }
@@ -520,7 +522,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() UnitsInTemplate = new List<UnitInTemplateMessage>()
}; };
await templateMqPublisher.PublishUpdateAsync(updateRequest); await _templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
@@ -534,7 +536,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
MinValueRelationships = unusedJob.MinValueRelationships, MinValueRelationships = unusedJob.MinValueRelationships,
MaxValueRelationships = unusedJob.MaxValueRelationships, MaxValueRelationships = unusedJob.MaxValueRelationships,
IsParentRelationships = unusedJob.IsParentRelationships, IsParentRelationships = unusedJob.IsParentRelationships,
TemplateNameMask = templateSettings.Value.UnusedTemplateNameMask, TemplateNameMask = _templateSettings.Value.UnusedTemplateNameMask,
WorkGroupMask = unusedJob.WorkGroupMask, WorkGroupMask = unusedJob.WorkGroupMask,
ResponseAreaMask = unusedJob.ResponseAreaMask, ResponseAreaMask = unusedJob.ResponseAreaMask,
TnkId = unusedJob.TnkId, TnkId = unusedJob.TnkId,
@@ -558,26 +560,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitsInTemplate>() UnitsInTemplate = new List<UnitsInTemplate>()
}; };
return await templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName); return await _templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName);
}
private async Task CreateSimpleTemplateAsync(Guid jobId, Guid unitId, HistoryInitiator initiator)
{
logger.LogInformation("Создание нового простого шаблона для Job {JobId}, UnitId {UnitId}.", jobId, unitId);
var mqRequest = new TemplateGeneratorMessage
{
JobId = jobId,
UnitId = unitId,
UnitsInTemplate = new List<UnitInTemplateMessage>(), // для простого шаблона
HistoryInitiator = initiator
};
var result = await mqService.SendAsync(mqSettings.TemplateGenerator, new List<object> { mqRequest });
if (!result.IsSuccess)
logger.LogError("Ошибка отправки команды создания простого шаблона для Job {JobId}, UnitId {UnitId}.", jobId, unitId);
} }
@@ -589,7 +572,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
Action = TemplateMatcherActionEnum.Sync, Action = TemplateMatcherActionEnum.Sync,
Comment = comment Comment = comment
}; };
await matchingStatusService.SetMatchingStatusAsync( await _matchingStatusService.SetMatchingStatusAsync(
jobId, jobId,
SyncTaskEntityTypeEnum.Job, SyncTaskEntityTypeEnum.Job,
new MatchingStatusItem { Data = status, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) }, new MatchingStatusItem { Data = status, Timestamp = DateTimeOffset.UtcNow, Source = nameof(SimpleTemplateSynchronizer) },

View File

@@ -5,7 +5,6 @@ using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Domain.Entities.Base.History; using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities; using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Enums; using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.GroupedSync;
using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Services.Interfaces;
namespace PARR.TemplateMatcher.Services.Implementations namespace PARR.TemplateMatcher.Services.Implementations

View File

@@ -9,6 +9,7 @@
<DockerServiceName>parr.api</DockerServiceName> <DockerServiceName>parr.api</DockerServiceName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="docker-compose.snapshots.yml" />
<None Include="docker-compose.task-reconciliation.yml" /> <None Include="docker-compose.task-reconciliation.yml" />
<None Include="docker-compose.template-matcher.yml" /> <None Include="docker-compose.template-matcher.yml" />
<None Include="docker-compose.template-generator.yml" /> <None Include="docker-compose.template-generator.yml" />

View File

@@ -40,7 +40,7 @@ services:
fluentd-buffer-limit: '52428800' fluentd-buffer-limit: '52428800'
tag: parr.next-run-rabbit.serilog tag: parr.next-run-rabbit.serilog
deploy: deploy:
replicas: 1 replicas: 4
networks: networks:
- parr-network - parr-network

View File

@@ -0,0 +1,26 @@
version: '3.9'
services:
parr-snapshots:
image: harbor.dvgd.rzd/parr/parr-snapshots:${tag:-latest}
environment:
- ASPNETCORE_ENVIRONMENT=Production
- TZ=Europe/Moscow
logging:
driver: fluentd
options:
fluentd-address: dvgd-efk-01.dvgd.oao.rzd:24224
fluentd-retry-wait: '10s'
fluentd-max-retries: '30'
fluentd-async: 'true'
fluentd-buffer-limit: '52428800'
tag: parr.snapshots.serilog
networks:
- parr-network
deploy:
replicas: 1
networks:
parr-network:
driver: overlay
external: true