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