Compare commits

...

3 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

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 IUnitRepository _unitRepository;
private readonly ILogger<RelationshipsSyncService> logger; private readonly SettingsFromDb _settingsFromDb;
private readonly IUnitRepository unitService;
private readonly WorkerSettings workerSettings;
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!;
}
} }
} }