diff --git a/PARR.API.sln b/PARR.API.sln index e8856eae..c96467c0 100644 --- a/PARR.API.sln +++ b/PARR.API.sln @@ -82,14 +82,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateMatcher", "PAR EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateMatcherWorker", "PARR.TemplateMatcherWorker\PARR.TemplateMatcherWorker.csproj", "{DA6B2A17-6E64-4E2C-8D4C-E278BEE94E03}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.DAL.Tests", "PARR.DAL.Tests\PARR.DAL.Tests.csproj", "{D61A7AB6-D785-477A-95D1-FBED049D6CE2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.Core.Tests", "PARR.DAL.Tests\PARR.Core.Tests.csproj", "{D61A7AB6-D785-477A-95D1-FBED049D6CE2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdaterWorker", "PARR.TemplateUpdaterWorker\PARR.TemplateUpdaterWorker.csproj", "{27263750-9C80-4F9D-A550-B9D9C473B386}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdater", "PARR.TemplateUpdater\PARR.TemplateUpdater.csproj", "{10905C04-A6E5-42BC-9804-8671D22C5D6E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.DAL.IntegrationTests", "PARR.DAL.IntegrationTests\PARR.DAL.IntegrationTests.csproj", "{D1C65DB8-77BC-00FD-5BA1-6C7303FD093C}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.Core", "PARR.Core\PARR.Core.csproj", "{E93DFC30-9886-486A-9F67-F782ABE0D1E0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.Domain", "PARR.Domain\PARR.Domain.csproj", "{3740461A-7235-4C26-81F3-3977529D646D}" @@ -256,10 +254,6 @@ Global {10905C04-A6E5-42BC-9804-8671D22C5D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU {10905C04-A6E5-42BC-9804-8671D22C5D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU {10905C04-A6E5-42BC-9804-8671D22C5D6E}.Release|Any CPU.Build.0 = Release|Any CPU - {D1C65DB8-77BC-00FD-5BA1-6C7303FD093C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D1C65DB8-77BC-00FD-5BA1-6C7303FD093C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1C65DB8-77BC-00FD-5BA1-6C7303FD093C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D1C65DB8-77BC-00FD-5BA1-6C7303FD093C}.Release|Any CPU.Build.0 = Release|Any CPU {E93DFC30-9886-486A-9F67-F782ABE0D1E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E93DFC30-9886-486A-9F67-F782ABE0D1E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {E93DFC30-9886-486A-9F67-F782ABE0D1E0}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/PARR.Core/PARR.Core.csproj b/PARR.Core/PARR.Core.csproj index a0e51996..a6ab4a72 100644 --- a/PARR.Core/PARR.Core.csproj +++ b/PARR.Core/PARR.Core.csproj @@ -1,22 +1,28 @@ - - net9.0 - enable - enable - + + net9.0 + enable + enable + - - - - - - - - + + + + + + + + - - - + + + + + + + + + diff --git a/PARR.Core/Properties/AssemblyInfo.cs b/PARR.Core/Properties/AssemblyInfo.cs deleted file mode 100644 index ec359717..00000000 --- a/PARR.Core/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("PARR.DAL.Tests")] -[assembly: InternalsVisibleTo("PARR.DAL.IntegrationTests")] diff --git a/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs b/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs index fe798dc8..217dba05 100644 --- a/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs +++ b/PARR.Core/Services/UnitFilterService/Models/UnitFilterServiceOptions.cs @@ -2,6 +2,6 @@ { internal class UnitFilterServiceOptions { - public int LoadBatchSize { get; set; } = 100; + public int LoadBatchSize { get; set; } = 1000; } } diff --git a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs index ee02368a..04a4d0af 100644 --- a/PARR.Core/Services/UnitFilterService/UnitFilterService.cs +++ b/PARR.Core/Services/UnitFilterService/UnitFilterService.cs @@ -16,7 +16,7 @@ namespace PARR.Core.Services.UnitFilterService; internal class UnitFilterService : IUnitFilterService { #if DEBUG - private readonly Guid debugTargetUnitId = Guid.Parse("3494fbec-adb9-4167-8619-b2a8b6b6e36a"); + private readonly Guid debugTargetUnitId = Guid.Parse("a3b5f3e2-928e-48b7-b481-df4556e1ed32"); #endif private const int DebugMaxUnitsToLog = 10; diff --git a/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs b/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs deleted file mode 100644 index c2811efe..00000000 --- a/PARR.DAL.Tests/DomainServices/Implementations/ShortcodesServiceTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.Extensions.Logging; -using Moq; -using PARR.Core.Repositories.Interfaces.Job; -using PARR.Core.Repositories.Interfaces.Unit; -using PARR.Core.Services.Shortcodes; -using PARR.Core.Services.UnitFilterService; -using PARR.Domain.Settings; - -namespace PARR.DAL.Tests.DomainServices.Implementations -{ - public class ShortcodesServiceTests - { - private readonly Mock> loggerMock = new(); - private readonly Mock settingsMock = new(); - private readonly Mock jobServiceMock = new(); - private readonly Mock unitServiceMock = new(); - private readonly Mock unitInValueServiceMock = new(); - private readonly Mock unitFieldServiceMock = new(); - private readonly Mock unitFilterServiceMock = new(); - - [Fact] - public async Task ApplyShortcodesAsync_WithEc_ShouldReplaceEc() - { - - } - } -} \ No newline at end of file diff --git a/PARR.DAL.Tests/DomainServices/Implementations/UnitFilterServiceTests.cs b/PARR.DAL.Tests/DomainServices/Implementations/UnitFilterServiceTests.cs deleted file mode 100644 index 51e71025..00000000 --- a/PARR.DAL.Tests/DomainServices/Implementations/UnitFilterServiceTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace PARR.DAL.Tests -{ - public class UnitFilterServiceTests //: IDisposable - { - // private readonly DbContextOptions _options; - // private readonly DataContext _context; - // private readonly UnitFilterService _service; - - // public UnitFilterServiceTests() - // { - // _options = new DbContextOptionsBuilder() - // .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}") - // .EnableSensitiveDataLogging() - // .Options; - - // _context = new DataContext(_options); - // _context.Database.EnsureCreated(); - // TestDataGenerator.Seed(_context); - - // // Логгеры - // var logger = Mock.Of>(); - // var jobServiceLogger = Mock.Of>(); - // var unitServiceLogger = Mock.Of>(); - // var unitInUnitServiceLogger = Mock.Of>(); - // var unitInValueServiceLogger = Mock.Of>(); - - // // Реальные сервисы с правильными конструкторами - // var jobService = new JobService(_context, jobServiceLogger); - // var unitService = new UnitService(_context, unitServiceLogger); - // var unitInUnitService = new UnitInUnitService(_context, unitInUnitServiceLogger); - // var unitInValueService = new UnitInValueService(unitInValueServiceLogger, _context); // ← logger first! - - // _service = new UnitFilterService( - // logger, - // jobService, - // unitService, - // unitInUnitService, - // unitInValueService - // ); - // } - - // public void Dispose() => _context.Dispose(); - - // [Fact] - // public async Task GetUnitsIdByJobFilterAsync_WithExistingJobId_ShouldReturnExpectedUnits() - // { - // // Arrange - // var jobId = _context.Jobs.First(j => j.Name == "TestJob_1").Id; - // var expectedUnitIds = new[] { TestDataGenerator.UnitAId, TestDataGenerator.UnitBId }; - - // // Act - // var result = await _service.GetUnitsIdByJobFilterAsync(jobId, takeCount: 10); - - // // Assert - // result.Should().NotBeNull() - // .And.HaveCount(2) - // .And.Contain(expectedUnitIds); - // } - - // [Fact] - // public async Task GetRelatedUnitNamesAsync_WithChildRelationshipFilter_ShouldReturnExpectedNames() - // { - // // Arrange - // var jobId = _context.Jobs.First(j => j.Name == "TestJob_2").Id; - // var unitId = TestDataGenerator.UnitWId; // Родитель - - // // rf.IsParent = false - // // Ищем юниты, у которых ParentUnitId = UnitWId (т.е. это дети UnitWId) - // // и у этих юнитов (детей) есть FieldId1 = "коммутатор" - // // Это UnitXId и UnitYId -> имена: "СХД-КМТ-CISCO-MDS9148-1-ДВС", "СХД-КМТ-CISCO-MDS9148-3-ДВС" - - // // Act - // var result = await _service.GetRelatedUnitNamesAsync(jobId, unitId); - - // // Assert - // result.Should().Contain(new[] { "СХД-КМТ-CISCO-MDS9148-1-ДВС", "СХД-КМТ-CISCO-MDS9148-3-ДВС" }); - // } - } -} \ No newline at end of file diff --git a/PARR.DAL.Tests/PARR.Core.Tests.csproj b/PARR.DAL.Tests/PARR.Core.Tests.csproj new file mode 100644 index 00000000..d793c578 --- /dev/null +++ b/PARR.DAL.Tests/PARR.Core.Tests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/PARR.DAL.Tests/Services/UnitFilterService/UnitFilterServiceTests.cs b/PARR.DAL.Tests/Services/UnitFilterService/UnitFilterServiceTests.cs new file mode 100644 index 00000000..3dbf3c27 --- /dev/null +++ b/PARR.DAL.Tests/Services/UnitFilterService/UnitFilterServiceTests.cs @@ -0,0 +1,616 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using PARR.Core.Common.Interfaces; +using PARR.Core.Repositories.Interfaces.Job; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.DAL.Context; +using PARR.Domain.Cache.Models; +using PARR.Domain.Entities.Job; +using PARR.Domain.Entities.Unit; +using PARR.Domain.Enums; + +namespace PARR.Core.Tests.Services.UnitFilterService; + +public class UnitFilterServiceTests +{ + #region Helpers + + /// + /// Создаёт тестовую сущность Unit с заданным значением поля. + /// Используется для подготовки данных в тестах фильтрации. + /// + /// Уникальный идентификатор юнита + /// Имя юнита для отладки + /// Идентификатор поля, к которому привязывается значение + /// Значение поля + private static Unit BuildTestUnit(Guid id, string name, Guid fieldId, string fieldValue) + { + var fieldVal = new UnitFieldValue { Id = Guid.NewGuid(), Value = fieldValue }; + return new Unit + { + Id = id, + Name = name, + UnitValues = new List + { + new UnitInValue + { + UnitId = id, + FieldId = fieldId, + ValueId = fieldVal.Id, + Value = fieldVal + } + } + }; + } + + /// + /// Создаёт тестовый объект JobGroupType с кодом Umbrella. + /// Используется для тестирования логики групповых работ типа "зонтик". + /// + private static JobGroupType BuildTestJobGroupType() => new() + { + Id = Guid.NewGuid(), + Code = JobGroupTypesEnum.Group, + Name = "Grouped Group", + Description = "Test" + }; + + /// + /// Создаёт тестовый объект JobGroup с заполненными обязательными полями. + /// Используется для подготовки навигационных свойств в тестах. + /// + private static JobGroup BuildTestJobGroup() => new() + { + Id = Guid.NewGuid(), + GroupName = "Test Group", + ShortDescription = "Short", + FullDescription = "Full", + Solution = "Sol", + TemplateDuration = "0:00:00", + ReferenceDate = DateTimeOffset.UtcNow, + GroupTypeId = Guid.NewGuid(), + ScheduleExcludeTypeId = Guid.NewGuid(), + GroupType = BuildTestJobGroupType() + }; + + /// + /// Создаёт тестовый объект JobAutoControl с отключённым автоконтролем. + /// Используется для заполнения обязательного свойства в сущности Job. + /// + private static JobAutoControl BuildTestAutoControl(Guid jobId) => new() + { + JobId = jobId, + IsEnable = false + }; + + /// + /// Контейнер для моков репозиториев. + /// Упрощает передачу зависимостей в метод создания сервиса. + /// + private class RepositoryMocks + { + public Mock Unit { get; set; } + public Mock UnitInUnit { get; set; } + public Mock UnitInValue { get; set; } + public Mock UnitField { get; set; } + public Mock Job { get; set; } + public Mock Cache { get; set; } + } + + /// + /// Настраивает базовые моки репозиториев с использованием InMemory-контекста. + /// Возвращает объект с подготовленными моками для повторного использования в тестах. + /// + /// Экземпляр DataContext с тестовыми данными + private static RepositoryMocks ArrangeRepositoryMocks(DataContext context) + { + var unitRepoMock = new Mock(); + unitRepoMock.Setup(r => r.Get()).Returns(context.Units.AsQueryable()); + unitRepoMock.Setup(r => r.GetInitialUnitIds(It.IsAny())) + .Returns(context.Units.Select(u => u.Id).AsQueryable()); + + var unitInUnitRepoMock = new Mock(); + unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable()); + + var unitInValueRepoMock = new Mock(); + // Единый подход: используем контекст для поддержки IAsyncQueryProvider + // Это гарантирует корректную работу .ToListAsync() внутри сервиса + unitInValueRepoMock.Setup(r => r.GetMatchingTargetIds(It.IsAny(), It.IsAny())) + .Returns((Guid fId, string mask) => + context.UnitInValues + .AsNoTracking() + .Where(uv => uv.FieldId == fId && uv.Value != null) + .Select(uv => uv.UnitId) + .Distinct() + .AsQueryable()); + + var unitFieldRepoMock = new Mock(); + unitFieldRepoMock.Setup(r => r.Get()).Returns(new List().AsQueryable()); + + var jobRepoMock = new Mock(); + jobRepoMock.Setup(r => r.Get()).Returns(context.Jobs.AsQueryable()); + + var cacheMock = new Mock(); + cacheMock.Setup(r => r.GetCachedDataAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((UnitFilterIds)null!); + + return new RepositoryMocks + { + Unit = unitRepoMock, + UnitInUnit = unitInUnitRepoMock, + UnitInValue = unitInValueRepoMock, + UnitField = unitFieldRepoMock, + Job = jobRepoMock, + Cache = cacheMock + }; + } + + /// + /// Создаёт экземпляр тестируемого сервиса (SUT — System Under Test). + /// Инкапсулирует логику конструктора для упрощения тестов. + /// + /// Подготовленные моки репозиториев + /// Экземпляр логгера для сервиса + private static PARR.Core.Services.UnitFilterService.UnitFilterService CreateSut( + RepositoryMocks mocks, + ILogger logger) + { + var serviceOptions = Options.Create(new PARR.Core.Services.UnitFilterService.Models.UnitFilterServiceOptions { LoadBatchSize = 100 }); + + return new PARR.Core.Services.UnitFilterService.UnitFilterService( + logger, + mocks.Job.Object, + mocks.Unit.Object, + mocks.UnitInUnit.Object, + mocks.UnitInValue.Object, + mocks.Cache.Object, + serviceOptions, + mocks.UnitField.Object + ); + } + + #endregion + + + /// + /// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter) + /// или по связям (RelationshipFilter), корректно исключаются из результата. + /// + [Fact] + public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded() + { + // === Arrange === + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + using var context = new DataContext(dbOptions); + + var jobId = Guid.NewGuid(); + var fieldId = Guid.NewGuid(); + var validParentId = Guid.NewGuid(); + var wrongParentId = Guid.NewGuid(); + + var unitPassesAll = Guid.NewGuid(); + var unitFailsFieldFilter = Guid.NewGuid(); + var unitFailsRelationshipFilter = Guid.NewGuid(); + + // Подготовка тестовых данных: юниты + context.Units.AddRange( + BuildTestUnit(unitPassesAll, "Unit_PassesAll", fieldId, "Accepted Value"), + BuildTestUnit(unitFailsFieldFilter, "Unit_FailsField", fieldId, "Rejected Value"), + BuildTestUnit(unitFailsRelationshipFilter, "Unit_FailsRel", fieldId, "Accepted Value"), + BuildTestUnit(validParentId, "Parent_Valid", fieldId, "Valid Parent"), + BuildTestUnit(wrongParentId, "Parent_Wrong", fieldId, "Wrong Parent") + ); + + // Подготовка тестовых данных: связи между юнитами + context.UnitInUnits.AddRange( + new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitPassesAll, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = wrongParentId, ChildUnitId = unitFailsRelationshipFilter, DateCreated = DateTimeOffset.UtcNow } + ); + + await context.SaveChangesAsync(); + + // Формируем тестовый объект Job с фильтрами + var job = new Job + { + Id = jobId, + Name = "Test Job", + WorkName = "Test Work", + TemplateNameMask = "TestMask", + WorkGroupMask = "TestGroup", + ResponseAreaMask = "TestArea", + GroupId = Guid.NewGuid(), + TnkId = Guid.NewGuid(), + MinValueRelationships = 1, + MaxValueRelationships = 10, + IsParentRelationships = true, + AutoControl = BuildTestAutoControl(jobId), + Group = BuildTestJobGroup(), + UnitFilters = new List + { + new JobUnitFilter + { + Id = Guid.NewGuid(), + JobId = jobId, + UnitFilter = "%", + FieldFilters = new List + { + new JobFieldFilter { Id = Guid.NewGuid(), FieldId = fieldId, ValueMask = "Accepted%", IsInverse = false } + }, + RelationshipFilters = new List + { + new JobRelationshipFilter { UnitFilterId = Guid.NewGuid(), FieldId = fieldId, ValueMask = "%", IsParent = true, IsInverse = false, IsFullMatch = false } + } + } + } + }; + + // Настройка моков репозиториев + var mocks = ArrangeRepositoryMocks(context); + + // Переопределяем мок фильтрации по полю: в реальном коде используется EF.Functions.ILike, + // в тесте заменяем на прямое сравнение строк для предсказуемости + mocks.Unit.Setup(r => r.GetUnitByFieldAndValue(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((IQueryable q, Guid fId, string mask, bool inv) => + q.Where(u => u.UnitValues != null && u.UnitValues.Any(v => v.FieldId == fId && v.Value != null && v.Value.Value == "Accepted Value"))); + + // ИСПРАВЛЕНО: используем запрос к контексту вместо массива. + // Массивный AsQueryable() не поддерживает IAsyncQueryProvider, что вызывает крах при вызове .ToListAsync() внутри сервиса. + // Запрос к InMemory DbSet гарантирует корректную асинхронную материализацию. + mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(fieldId, It.IsAny())) + .Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable()); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + var service = CreateSut(mocks, logger); + + // === Act === + var result = await service.GetUnitsByJobFilterAsync(job); + + // === Assert === + var resultIds = result.Select(u => u.Id).ToList(); + + Assert.Contains(unitPassesAll, resultIds); + Assert.DoesNotContain(unitFailsFieldFilter, resultIds); + Assert.DoesNotContain(unitFailsRelationshipFilter, resultIds); + + Assert.Single(result); + var dto = result.First(); + Assert.Equal("Accepted Value", dto.Values.FirstOrDefault()?.Value); + Assert.Single(dto.Parents); + Assert.Equal(validParentId, dto.Parents.First().UnitId); + } + + /// + /// Проверяет, что юниты исключаются, если их единственный родительский юнит + /// содержит запрещённое значение тега (сценарий с IsInverse = true). + /// + [Fact] + public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded() + { + // === Arrange === + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + using var context = new DataContext(dbOptions); + + var jobId = Guid.NewGuid(); + var tagFieldId = Guid.NewGuid(); + var validParentId = Guid.NewGuid(); + var invalidParentId = Guid.NewGuid(); + var unitWithValidParent = Guid.NewGuid(); + var unitWithInvalidParent = Guid.NewGuid(); + + // Подготовка тестовых данных: юниты + context.Units.AddRange( + BuildTestUnit(validParentId, "Parent_Valid", tagFieldId, "ОТВ.ЭК"), + BuildTestUnit(invalidParentId, "Parent_Invalid", tagFieldId, "ПАРР-РРПТК-ОТВ.ЭК"), + BuildTestUnit(unitWithValidParent, "Unit_Valid", Guid.NewGuid(), "Val"), + BuildTestUnit(unitWithInvalidParent, "Unit_Invalid", Guid.NewGuid(), "Val") + ); + + // Подготовка тестовых данных: связи между юнитами + context.UnitInUnits.AddRange( + new UnitInUnit { ParentUnitId = validParentId, ChildUnitId = unitWithValidParent, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = invalidParentId, ChildUnitId = unitWithInvalidParent, DateCreated = DateTimeOffset.UtcNow } + ); + + await context.SaveChangesAsync(); + + // Формируем тестовый объект Job с настройкой IsInverse = true + // IsInverse = true означает: исключить родителей, которые СОВПАДАЮТ с маской + var job = new Job + { + Id = jobId, + Name = "Test Job Inverse", + WorkName = "Test Work", + TemplateNameMask = "TestMask", + WorkGroupMask = "TestGroup", + ResponseAreaMask = "TestArea", + GroupId = Guid.NewGuid(), + TnkId = Guid.NewGuid(), + MinValueRelationships = 1, + MaxValueRelationships = 10, + IsParentRelationships = true, + AutoControl = BuildTestAutoControl(jobId), + Group = BuildTestJobGroup(), + UnitFilters = new List + { + new JobUnitFilter + { + Id = Guid.NewGuid(), + JobId = jobId, + UnitFilter = "%", + FieldFilters = new List(), + RelationshipFilters = new List + { + new JobRelationshipFilter + { + UnitFilterId = Guid.NewGuid(), + FieldId = tagFieldId, + ValueMask = "%ПАРР-РРПТК-ОТВ.ЭК%", + IsParent = true, + IsInverse = true, + IsFullMatch = false + } + } + } + } + }; + + // Настройка моков репозиториев + var mocks = ArrangeRepositoryMocks(context); + + // Переопределяем мок: возвращаем только родителей с запрещённым тегом. + // В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты. + // IsInverse = true в сервисе инвертирует результат, поэтому эти родители будут исключены. + mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny())) + .Returns((Guid fId, string mask) => + context.UnitInValues + .AsNoTracking() + .Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("ПАРР-РРПТК-ОТВ.ЭК", StringComparison.OrdinalIgnoreCase)) + .Select(uv => uv.UnitId) + .Distinct() + .AsQueryable()); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + var service = CreateSut(mocks, logger); + + // === Act === + var result = await service.GetUnitsByJobFilterAsync(job); + + // === Assert === + var resultIds = result.Select(u => u.Id).ToList(); + + Assert.Contains(unitWithValidParent, resultIds); + Assert.DoesNotContain(unitWithInvalidParent, resultIds); + } + + /// + /// Проверяет логику IsFullMatch = true: юнит исключается, + /// если хотя бы один из его родительских юнитов не соответствует фильтру. + /// + [Fact] + public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded() + { + // === Arrange === + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + using var context = new DataContext(dbOptions); + + var jobId = Guid.NewGuid(); + var checkFieldId = Guid.NewGuid(); + var parentGoodId = Guid.NewGuid(); + var parentBadId = Guid.NewGuid(); + var unitMixedId = Guid.NewGuid(); + var unitCleanId = Guid.NewGuid(); + + // Подготовка тестовых данных: юниты + context.Units.AddRange( + BuildTestUnit(parentGoodId, "Parent_Good", checkFieldId, "Good Parent"), + BuildTestUnit(parentBadId, "Parent_Bad", checkFieldId, "Bad Parent"), + BuildTestUnit(unitMixedId, "Unit_Mixed", Guid.NewGuid(), "Mixed"), + BuildTestUnit(unitCleanId, "Unit_Clean", Guid.NewGuid(), "Clean") + ); + + // Подготовка тестовых данных: связи между юнитами + context.UnitInUnits.AddRange( + new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = parentBadId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = parentGoodId, ChildUnitId = unitCleanId, DateCreated = DateTimeOffset.UtcNow } + ); + + await context.SaveChangesAsync(); + + // Формируем тестовый объект Job с настройкой IsFullMatch = true + // IsFullMatch = true означает: ВСЕ родители должны соответствовать фильтру + var job = new Job + { + Id = jobId, + Name = "Test Job FullMatch", + WorkName = "Test Work", + TemplateNameMask = "TestMask", + WorkGroupMask = "TestGroup", + ResponseAreaMask = "TestArea", + GroupId = Guid.NewGuid(), + TnkId = Guid.NewGuid(), + MinValueRelationships = 1, + MaxValueRelationships = 10, + IsParentRelationships = true, + AutoControl = BuildTestAutoControl(jobId), + Group = BuildTestJobGroup(), + UnitFilters = new List + { + new JobUnitFilter + { + Id = Guid.NewGuid(), + JobId = jobId, + UnitFilter = "%", + FieldFilters = new List(), + RelationshipFilters = new List + { + new JobRelationshipFilter + { + UnitFilterId = Guid.NewGuid(), + FieldId = checkFieldId, + ValueMask = "%Good%", + IsParent = true, + IsInverse = false, + IsFullMatch = true + } + } + } + } + }; + + // Настройка моков репозиториев + var mocks = ArrangeRepositoryMocks(context); + + // Переопределяем мок: возвращаем только родителей, соответствующих маске "Good". + // В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты. + mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(checkFieldId, It.IsAny())) + .Returns((Guid fId, string mask) => + context.UnitInValues + .AsNoTracking() + .Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("Good", StringComparison.OrdinalIgnoreCase)) + .Select(uv => uv.UnitId) + .Distinct() + .AsQueryable()); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + var service = CreateSut(mocks, logger); + + // === Act === + var result = await service.GetUnitsByJobFilterAsync(job); + + // === Assert === + var resultIds = result.Select(u => u.Id).ToList(); + + Assert.Contains(unitCleanId, resultIds); + Assert.DoesNotContain(unitMixedId, resultIds); + } + + + /// + /// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true. + /// Логика: юнит проходит, только если НИ ОДИН из его родителей не содержит запрещённое значение. + /// + [Fact] + public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded() + { + // === Arrange === + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + using var context = new DataContext(dbOptions); + + var jobId = Guid.NewGuid(); + var tagFieldId = Guid.NewGuid(); + var parentForbiddenId = Guid.NewGuid(); // Родитель с запрещённым тегом + var parentCleanId = Guid.NewGuid(); // Родитель без запрещённого тега + var unitMixedId = Guid.NewGuid(); // Юнит с одним "плохим" и одним "хорошим" родителем + var unitAllCleanId = Guid.NewGuid(); // Юнит только с "хорошими" родителями + + // Подготовка тестовых данных: юниты + context.Units.AddRange( + BuildTestUnit(parentForbiddenId, "Parent_Forbidden", tagFieldId, "FORBIDDEN-TAG"), + BuildTestUnit(parentCleanId, "Parent_Clean", tagFieldId, "CLEAN-TAG"), + BuildTestUnit(unitMixedId, "Unit_Mixed", Guid.NewGuid(), "Mixed"), + BuildTestUnit(unitAllCleanId, "Unit_AllClean", Guid.NewGuid(), "AllClean") + ); + + // Подготовка тестовых данных: связи + // unitMixed имеет обоих родителей — один с запрещённым тегом, один без + // unitAllClean имеет только "чистого" родителя + context.UnitInUnits.AddRange( + new UnitInUnit { ParentUnitId = parentForbiddenId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitMixedId, DateCreated = DateTimeOffset.UtcNow }, + new UnitInUnit { ParentUnitId = parentCleanId, ChildUnitId = unitAllCleanId, DateCreated = DateTimeOffset.UtcNow } + ); + + await context.SaveChangesAsync(); + + // Формируем тестовый объект Job с комбинацией флагов: + // IsParent = true: проверяем родительские связи + // IsInverse = true: исключаем родителей, которые СОВПАДАЮТ с маской + // IsFullMatch = true: ВСЕ родители должны пройти фильтр (ни один не должен совпасть с маской) + var job = new Job + { + Id = jobId, + Name = "Test Job Combined Flags", + WorkName = "Test Work", + TemplateNameMask = "TestMask", + WorkGroupMask = "TestGroup", + ResponseAreaMask = "TestArea", + GroupId = Guid.NewGuid(), + TnkId = Guid.NewGuid(), + MinValueRelationships = 1, + MaxValueRelationships = 10, + IsParentRelationships = true, + AutoControl = BuildTestAutoControl(jobId), + Group = BuildTestJobGroup(), + UnitFilters = new List + { + new JobUnitFilter + { + Id = Guid.NewGuid(), + JobId = jobId, + UnitFilter = "%", + FieldFilters = new List(), + RelationshipFilters = new List + { + new JobRelationshipFilter + { + UnitFilterId = Guid.NewGuid(), + FieldId = tagFieldId, + ValueMask = "%FORBIDDEN%", + IsParent = true, + IsInverse = true, + IsFullMatch = true + } + } + } + } + }; + + // Настройка моков репозиториев + var mocks = ArrangeRepositoryMocks(context); + + // Переопределяем мок: возвращаем только родителей, содержащих "FORBIDDEN". + // В реальном коде используется EF.Functions.ILike, в тесте — .Contains() для простоты. + // Из-за IsInverse=true эти родители будут исключены из "валидных". + // Из-за IsFullMatch=true юнит пройдёт, только если ВСЕ его родители валидны. + mocks.UnitInValue.Setup(r => r.GetMatchingTargetIds(tagFieldId, It.IsAny())) + .Returns((Guid fId, string mask) => + context.UnitInValues + .AsNoTracking() + .Where(uv => uv.FieldId == fId && uv.Value != null && uv.Value.Value.Contains("FORBIDDEN", StringComparison.OrdinalIgnoreCase)) + .Select(uv => uv.UnitId) + .Distinct() + .AsQueryable()); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + var service = CreateSut(mocks, logger); + + // === Act === + var result = await service.GetUnitsByJobFilterAsync(job); + + // === Assert === + var resultIds = result.Select(u => u.Id).ToList(); + + // unitAllCleanId должен остаться: у него один родитель, и он не содержит запрещённый тег + Assert.Contains(unitAllCleanId, resultIds); + + // unitMixedId должен быть исключён: у него есть родитель с запрещённым тегом. + // Логика: + // 1. GetMatchingTargetIds возвращает {parentForbiddenId} + // 2. IsInverse=true → валидные родители = все.Кроме({parentForbiddenId}) = {parentCleanId} + // 3. IsFullMatch=true → проверяем: все родители {parentForbiddenId, parentCleanId} входят в {parentCleanId}? Нет. + // 4. Юнит исключается. + Assert.DoesNotContain(unitMixedId, resultIds); + } +} \ No newline at end of file diff --git a/PARR.DAL.Tests/TestDataGenerator.cs b/PARR.DAL.Tests/TestDataGenerator.cs deleted file mode 100644 index fa97f333..00000000 --- a/PARR.DAL.Tests/TestDataGenerator.cs +++ /dev/null @@ -1,253 +0,0 @@ -using PARR.DAL.Context; -using PARR.Domain.Entities.Job; -using PARR.Domain.Entities.Unit; -using PARR.Domain.Enums; - -namespace PARR.DAL.Tests -{ - public static class TestDataGenerator - { - // --- Unit IDs --- - public static readonly Guid UnitAId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - public static readonly Guid UnitBId = Guid.Parse("00000000-0000-1111-1111-111111111111"); - public static readonly Guid UnitCId = Guid.Parse("22222222-2222-2222-2222-222222222222"); - public static readonly Guid UnitDId = Guid.Parse("33333333-3333-3333-3333-333333333333"); - public static readonly Guid UnitWId = Guid.Parse("44444444-4444-4444-4444-444444444444"); - public static readonly Guid UnitXId = Guid.Parse("55555555-5555-5555-5555-555555555555"); - public static readonly Guid UnitYId = Guid.Parse("66666666-6666-6666-6666-666666666666"); - public static readonly Guid UnitZId = Guid.Parse("77777777-7777-7777-7777-777777777777"); - - // --- Field IDs (для обхода составного ключа UnitFilterId+FieldId) --- - public static readonly Guid FieldId1 = Guid.Parse("08b42860-09a7-4a1f-aa4f-0290e86df883");//ТИП_ЭК - public static readonly Guid FieldId2 = Guid.Parse("5b2d3f5b-a9f3-4a6b-9142-70ef4357ac37"); //неуникальный - public static readonly Guid FieldId3 = Guid.Parse("5f3c7b8b-583d-411b-b9bd-1e045ddc5a8e");//ПОДКАТЕГОРИЯ_ЭК - public static readonly Guid FieldId4 = Guid.Parse("3e9ac5aa-531b-4ffc-8f93-694d00aa92a0");//активный - - // --- Field Value IDs --- - public static readonly Guid FieldValueId1 = Guid.Parse("fe18edcd-ab6a-4f3e-b7bc-e54aad9ecc0d"); // система хранения данных (схд) field3 - public static readonly Guid FieldValueId2 = Guid.Parse("f385b22c-42c3-4db9-bd41-b829603cc2d9"); // 1 field2+field4 - public static readonly Guid FieldValueId3 = Guid.Parse("2badc139-69cb-4d52-830a-1b3c6a9faf9d"); // 0 field2+field4 - public static readonly Guid FieldValueId4 = Guid.Parse("fde68ef7-a07e-4c10-872c-cd98c3d1c30b"); // коммутатор - public static readonly Guid FieldValueId5 = Guid.Parse("9f169862-3f78-49f2-b967-13b5b2f56231"); // серверное оборудование - public static readonly Guid FieldValueId6 = Guid.Parse("075b6fad-d24f-40f4-aa0e-83a4cfc46bbf"); // стойка цвк - public static readonly Guid FieldValueId7 = Guid.Parse("9679afd8-8004-4ce2-bd6f-9005b037ef96"); // схд - - internal static void Seed(DataContext context) - { - if (context.Units.Any()) return; - - var now = DateTimeOffset.UtcNow; - - // 1. Значения полей (остаются те же) - var fieldValues = new[] - { - new UnitFieldValue { Id = FieldValueId1, Value = "система хранения данных (схд)", DateCreated = now }, - new UnitFieldValue { Id = FieldValueId2, Value = "1", DateCreated = now }, - new UnitFieldValue { Id = FieldValueId3, Value = "0", DateCreated = now }, - new UnitFieldValue { Id = FieldValueId4, Value = "коммутатор", DateCreated = now }, - }; - context.UnitFieldValues.AddRange(fieldValues); - - // 2. Unit'ы - var units = new[] - { - new Unit { Id = UnitAId, Name = "СХД-КМТ-DVGD-MDS9148-1-ДВС", DateCreated = now }, - new Unit { Id = UnitBId, Name = "СХД-КМТ-DVGD-MDS9148-2-ДВС", DateCreated = now }, - new Unit { Id = UnitCId, Name = "СРВ-СТОЙКА-СТОЙКА-ХИВЦ-4.0-ДВС", DateCreated = now }, - new Unit { Id = UnitDId, Name = "СХД-VSP-E590-01-ДВС", DateCreated = now }, - new Unit { Id = UnitWId, Name = "СХД-КМТ-SAN1_FAB_1-ДВС", DateCreated = now }, - new Unit { Id = UnitXId, Name = "СХД-КМТ-CISCO-MDS9148-1-ДВС", DateCreated = now }, - new Unit { Id = UnitYId, Name = "СХД-КМТ-CISCO-MDS9148-3-ДВС", DateCreated = now }, - }; - context.Units.AddRange(units); - - // 3. UnitInUnit (связи) - var unitInUnits = new[] - { - new UnitInUnit { ParentUnitId = UnitAId, ChildUnitId = UnitCId, DateCreated = now }, - new UnitInUnit { ParentUnitId = UnitBId, ChildUnitId = UnitCId, DateCreated = now }, - new UnitInUnit { ParentUnitId = UnitDId, ChildUnitId = UnitAId, DateCreated = now }, - new UnitInUnit { ParentUnitId = UnitDId, ChildUnitId = UnitBId, DateCreated = now }, - new UnitInUnit { ParentUnitId = UnitWId, ChildUnitId = UnitXId, DateCreated = now }, - new UnitInUnit { ParentUnitId = UnitWId, ChildUnitId = UnitYId, DateCreated = now } - }; - context.UnitInUnits.AddRange(unitInUnits); - - // 4. UnitInValue - var unitInValues = new List - { - // Основные (FieldId) - new UnitInValue { UnitId = UnitAId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now }, - new UnitInValue { UnitId = UnitAId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitAId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitAId, FieldId = FieldId2, ValueId = FieldValueId3, DateCreated = now }, - new UnitInValue { UnitId = UnitBId, FieldId = FieldId2, ValueId = FieldValueId3, DateCreated = now }, - new UnitInValue { UnitId = UnitBId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now }, - new UnitInValue { UnitId = UnitBId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitBId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitCId, FieldId = FieldId3, ValueId = FieldValueId5, DateCreated = now }, - new UnitInValue { UnitId = UnitCId, FieldId = FieldId1, ValueId = FieldValueId6, DateCreated = now }, - new UnitInValue { UnitId = UnitCId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitDId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitDId, FieldId = FieldId1, ValueId = FieldValueId7, DateCreated = now }, - new UnitInValue { UnitId = UnitDId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitWId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitWId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now }, - new UnitInValue { UnitId = UnitWId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitWId, FieldId = FieldId2, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitXId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitXId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now }, - new UnitInValue { UnitId = UnitXId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - new UnitInValue { UnitId = UnitYId, FieldId = FieldId3, ValueId = FieldValueId1, DateCreated = now }, - new UnitInValue { UnitId = UnitYId, FieldId = FieldId1, ValueId = FieldValueId4, DateCreated = now }, - new UnitInValue { UnitId = UnitYId, FieldId = FieldId4, ValueId = FieldValueId2, DateCreated = now }, - - }; - context.UnitInValues.AddRange(unitInValues); - - // 5. JobGroupType и Group - var groupType = context.JobGroupTypes - .FirstOrDefault(t => t.Code == JobGroupTypesEnum.Simple) - ?? throw new InvalidOperationException("JobGroupType 'Simple' не найден в контексте."); - - var group = new JobGroup - { - Id = Guid.NewGuid(), - GroupTypeId = groupType.Id, - GroupType = groupType, - GroupName = "Группа", - ShortDescription = "%ТНК-КРАТКО% %ЭК%", - FullDescription = "Подробно:\n%СВЯЗИ%", - Solution = "Решение", - TemplateDuration = "540 00:00:00", - ReferenceDate = now, - IsAutoDistributionEnabled = false, - IsAgent = false, - DateCreated = now - }; - context.JobGroups.Add(group); - - // 6. Jobs - var jobs = new List(); - - // --- TestJob --- - var job1 = new Job - { - Id = Guid.NewGuid(), - Name = "TestJob_1", - WorkName = "Работа", - TemplateNameMask = "%ЭК%", - WorkGroupMask = "%ГРУППА_РАБОТ%", - GroupId = group.Id, - TnkId = Guid.NewGuid(), - Group = group, - DateCreated = now, - ResponseAreaMask = "%ЗО_РГ%" - }; - var unitFilter1Id = Guid.NewGuid(); - job1.UnitFilters = new List - { - new JobUnitFilter - { - Id = unitFilter1Id, - UnitFilter = "СХД-%", - JobId = job1.Id, - Job = job1, - DateCreated = now, - FieldFilters = new List - { - new JobFieldFilter - { - UnitFilterId = unitFilter1Id, - FieldId = FieldId1, - ValueMask="коммутатор" - }, - new JobFieldFilter - { - UnitFilterId = unitFilter1Id, - FieldId = FieldId4, - ValueMask="1" - }, - new JobFieldFilter - { - UnitFilterId = unitFilter1Id, - FieldId = FieldId2, - ValueMask="0" - }, - }, - RelationshipFilters = new List - { - new JobRelationshipFilter - { - IsParent = true, - IsInverse = true, - IsFullMatch = true, - FieldId = FieldId1, - ValueMask = "коммутатор", - UnitFilterId = unitFilter1Id - } - } - } - }; - jobs.Add(job1); - - // --- TestJob_2 (с IsParent = false для дочерних связей) --- - var job2 = new Job - { - Id = Guid.NewGuid(), - Name = "TestJob_2", - WorkName = "Работа2", - TemplateNameMask = "%ЭК%", - WorkGroupMask = "%ГРУППА_РАБОТ%", - GroupId = group.Id, - TnkId = Guid.NewGuid(), - Group = group, - DateCreated = now, - ResponseAreaMask = "%ЗО_РГ%" - }; - var unitFilter2Id = Guid.NewGuid(); - job2.UnitFilters = new List - { - new JobUnitFilter - { - Id = unitFilter2Id, - UnitFilter = "СХД-%", - JobId = job2.Id, - Job = job2, - DateCreated = now, - FieldFilters = new List - { - new JobFieldFilter - { - UnitFilterId = unitFilter2Id, - FieldId = FieldId1, - ValueMask="коммутатор" - }, - new JobFieldFilter - { - UnitFilterId = unitFilter2Id, - FieldId = FieldId4, - ValueMask="1" - }, - }, - RelationshipFilters = new List - { - new JobRelationshipFilter - { - IsParent = false, // <-- Новый фильтр: ищем дочерние юниты - IsInverse = false, // <-- Простой фильтр - IsFullMatch = false, // <-- Простой фильтр - FieldId = FieldId1, - ValueMask = "коммутатор", - UnitFilterId = unitFilter2Id - } - } - } - }; - jobs.Add(job2); - - context.Jobs.AddRange(jobs); - context.SaveChanges(); - } - } -} \ No newline at end of file diff --git a/PARR.DAL/PARR.DAL.csproj b/PARR.DAL/PARR.DAL.csproj index c2efc3ca..83140405 100644 --- a/PARR.DAL/PARR.DAL.csproj +++ b/PARR.DAL/PARR.DAL.csproj @@ -22,4 +22,11 @@ + + + + + + + diff --git a/PARR.DAL/Properties/AssemblyInfo.cs b/PARR.DAL/Properties/AssemblyInfo.cs deleted file mode 100644 index 32a4a814..00000000 --- a/PARR.DAL/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,5 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("PARR.DAL.Tests")] -[assembly: InternalsVisibleTo("PARR.DAL.IntegrationTests")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] \ No newline at end of file diff --git a/PARR.TemplateMatcher/IMqTemplateTaskGenerator.cs b/PARR.TemplateMatcher/IMqTemplateMatcher.cs similarity index 100% rename from PARR.TemplateMatcher/IMqTemplateTaskGenerator.cs rename to PARR.TemplateMatcher/IMqTemplateMatcher.cs diff --git a/PARR.TemplateMatcher/Models/TemplateAllocationRequest.cs b/PARR.TemplateMatcher/Models/TemplateAllocationRequest.cs new file mode 100644 index 00000000..5be8606f --- /dev/null +++ b/PARR.TemplateMatcher/Models/TemplateAllocationRequest.cs @@ -0,0 +1,17 @@ +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; +using PARR.Domain.Entities.Base.History; +using PARR.Domain.Entities.Job; +using PARR.Domain.Entities.Unit; + +namespace PARR.TemplateMatcher.Models +{ + public record TemplateAllocationRequest( + Job TargetJob, + Guid TargetUnitId, + Unit? TargetUnit, + int? Index, + List UnitsInTemplate, + bool IsActiveTemplate, + bool IsActiveSchedule, + HistoryInitiator Initiator); +} diff --git a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateProcessor.cs b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateProcessor.cs index f7e83b64..b5bcb655 100644 --- a/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateProcessor.cs +++ b/PARR.TemplateMatcher/Services/Implementations/GroupedTemplateProcessor.cs @@ -19,30 +19,26 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor private readonly ILogger logger; private readonly ITemplateRepository templateRepository; private readonly IUnitRepository unitRepository; - private readonly ITemplateReuser templateReuser; private readonly ITemplateNameNormalizer templateNameNormalizer; - private readonly ITemplateUpdaterMqSender templateUpdaterMqSender; - private readonly MqSettings mqSettings; - private readonly IRabbitService mqService; + private readonly ITemplateAllocationService templateAllocationService; + private readonly ITemplateMqPublisher templateMqPublisher; public GroupedTemplateProcessor( ILogger logger, ITemplateRepository templateRepository, IUnitRepository unitRepository, - ITemplateReuser templateReuser, ITemplateNameNormalizer templateNameNormalizer, - ITemplateUpdaterMqSender templateUpdaterMqSender, + ITemplateAllocationService templateAllocationService, + ITemplateMqPublisher templateMqPublisher, MqSettings mqSettings, IRabbitService mqService) { this.logger = logger; this.templateRepository = templateRepository; this.unitRepository = unitRepository; - this.templateReuser = templateReuser; this.templateNameNormalizer = templateNameNormalizer; - this.templateUpdaterMqSender = templateUpdaterMqSender; - this.mqSettings = mqSettings; - this.mqService = mqService; + this.templateAllocationService = templateAllocationService; + this.templateMqPublisher = templateMqPublisher; } public async Task> ProcessAsync( @@ -89,7 +85,7 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor } else { - await HandleNewOrReusableTemplateAsync(potentialUnitId, unitsInTemplateSubGroup, targetJob, globalIndex, initiator); + await HandleNewOrReusableTemplateAsync(potentialUnitId, unitsInTemplateSubGroup, targetJob, globalIndex, initiator, ct); } } } @@ -159,7 +155,7 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor UnitFieldValueId = t.UnitFieldValueId }).ToList() }; - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); } } else @@ -174,50 +170,33 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor List<(Guid UnitId, Guid UnitFieldValueId)> unitsInTemplateSubGroup, Job targetJob, int globalIndex, - HistoryInitiator initiator) + HistoryInitiator initiator, + CancellationToken ct) { - var reusableTemplate = await templateReuser.TryReuseOneUnusedTemplateAsync(targetJob.Id, potentialUnitId, initiator); + // Вычисляем флаги активности + var isActiveTemplate = targetJob.AutoControl?.InitUsedTemplateState ?? false; + var isActiveSchedule = targetJob.AutoControl?.InitUsedScheduleState ?? false; - if (reusableTemplate != null) - { - logger.LogInformation("Переиспользован шаблон {TemplateId}.", reusableTemplate.Id); - - var tempTemplateForName = new Template + // Маппим кортежи в сообщения + var unitsInTemplateMsg = unitsInTemplateSubGroup + .Select(e => new UnitInTemplateMessage { - Id = reusableTemplate.Id, - Name = reusableTemplate.Name, - JobId = targetJob.Id, - UnitId = potentialUnitId, - Index = globalIndex, - Job = targetJob, - Unit = reusableTemplate.Unit, - UnitsInTemplate = unitsInTemplateSubGroup.Select(e => new UnitsInTemplate { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList() - }; + UnitId = e.UnitId, + UnitFieldValueId = e.UnitFieldValueId + }) + .ToList(); - var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName); + var request = new TemplateAllocationRequest( + TargetJob: targetJob, + TargetUnitId: potentialUnitId, + TargetUnit: null, + Index: globalIndex, + UnitsInTemplate: unitsInTemplateMsg, + IsActiveTemplate: isActiveTemplate, + IsActiveSchedule: isActiveSchedule, + Initiator: initiator); - var updateRequest = new TemplateUpdaterMessage - { - TemplateId = reusableTemplate.Id, - JobId = targetJob.Id, - UnitId = potentialUnitId, - Name = expectedName, - IsActiveTemplate = targetJob.AutoControl?.InitUsedTemplateState ?? false, - IsActiveSchedule = targetJob.AutoControl?.InitUsedScheduleState ?? false, - StatusTypeId = TemplateStatusTypeEnum.Used, - Initiator = initiator, - IsNew = true, - Index = globalIndex, - UnitsInTemplate = unitsInTemplateSubGroup.Select(e => new UnitInTemplateMessage { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList() - }; - - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); - } - else - { - logger.LogDebug("Создание нового шаблона."); - await CreateGroupedTemplateAsync(targetJob.Id, potentialUnitId, unitsInTemplateSubGroup, globalIndex, initiator); - } + await templateAllocationService.AllocateAsync(request, ct); } private async Task UpdateTemplateUnitsAsync( @@ -270,32 +249,9 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor UnitsInTemplate = newUnitEntries.Select(e => new UnitInTemplateMessage { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList() }; - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); } - private async Task CreateGroupedTemplateAsync( - Guid jobId, - Guid relationshipUnitId, - List<(Guid UnitId, Guid UnitFieldValueId)> unitsInTemplate, - int index, - HistoryInitiator initiator) - { - logger.LogInformation("Создание нового группового шаблона."); - - var mqRequest = new TemplateGeneratorMessage - { - JobId = jobId, - UnitId = relationshipUnitId, - UnitsInTemplate = unitsInTemplate.Select(e => new UnitInTemplateMessage { UnitId = e.UnitId, UnitFieldValueId = e.UnitFieldValueId }).ToList(), - Index = index, - HistoryInitiator = initiator - }; - - var result = await mqService.SendAsync(mqSettings.TemplateGenerator, new List { mqRequest }); - - if (!result.IsSuccess) - logger.LogError("Ошибка отправки команды создания шаблона."); - } private static Job SelectTargetJob(List jobsInGroup, int subGroupSize, Job maxJob) { diff --git a/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs b/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs index ad7c4a10..185ebc5d 100644 --- a/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs +++ b/PARR.TemplateMatcher/Services/Implementations/SimpleTemplateSynchronizer.cs @@ -15,6 +15,7 @@ using PARR.Domain.Entities.Job; using PARR.Domain.Entities.Unit; using PARR.Domain.Enums; using PARR.Domain.Settings; +using PARR.TemplateMatcher.Models; using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Settings; @@ -40,10 +41,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer private readonly IRabbitService mqService; private readonly ITemplateRepository templateService; private readonly IJobRepository jobService; - private readonly ITemplateReuser templateReuser; private readonly ITemplateDeactivator templateDeactivator; private readonly ITemplateNameNormalizer templateNameNormalizer; - private readonly ITemplateUpdaterMqSender templateUpdaterMqSender; + private readonly ITemplateAllocationService templateAllocationService; + private readonly ITemplateMqPublisher templateMqPublisher; private readonly IMatchingStatusService matchingStatusService; private readonly SettingsFromDb settingsFromDb; private readonly IOptions templateSettings; @@ -58,10 +59,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer IRabbitService mqService, ITemplateRepository templateService, IJobRepository jobService, - ITemplateReuser templateReuser, ITemplateDeactivator templateDeactivator, ITemplateNameNormalizer templateNameNormalizer, - ITemplateUpdaterMqSender templateUpdaterMqSender, + ITemplateAllocationService templateAllocationService, + ITemplateMqPublisher templateMqPublisher, IMatchingStatusService matchingStatusService, SettingsFromDb settingsFromDb, IOptions templateSettings, @@ -76,10 +77,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer this.mqService = mqService; this.templateService = templateService; this.jobService = jobService; - this.templateReuser = templateReuser; this.templateDeactivator = templateDeactivator; this.templateNameNormalizer = templateNameNormalizer; - this.templateUpdaterMqSender = templateUpdaterMqSender; + this.templateAllocationService = templateAllocationService; + this.templateMqPublisher = templateMqPublisher; this.matchingStatusService = matchingStatusService; this.settingsFromDb = settingsFromDb; this.templateSettings = templateSettings; @@ -209,49 +210,21 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer foreach (var unitId in newUnitIds) { - var reusableTemplate = await templateReuser.TryReuseOneUnusedTemplateAsync(jobId, unitId, initiator); - if (reusableTemplate != null) - { - logger.LogInformation("Переиспользован шаблон {TemplateId} для Job {JobId}, UnitId {UnitId}.", reusableTemplate.Id, jobId, unitId); + // Вычисляем флаги активности из конфигурации автоконтрола + var isActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState; + var isActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState; - // === Создаём временный Template для нормализации имени === - var tempTemplateForName = new Template - { - Id = reusableTemplate.Id, - Name = reusableTemplate.Name, - JobId = jobId, - UnitId = unitId, - Index = reusableTemplate.Index, - Job = job, // загруженный job - Unit = reusableTemplate.Unit, // может быть null — нормально - UnitsInTemplate = new List() // для простого шаблона - }; + var request = new TemplateAllocationRequest( + TargetJob: job, + TargetUnitId: unitId, + TargetUnit: null, // Для простого шаблона не критично, нормализатор загрузит при необходимости + Index: null, + UnitsInTemplate: new List(), + IsActiveTemplate: isActiveTemplate, + IsActiveSchedule: isActiveSchedule, + Initiator: initiator); - var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName); - //var nextRun = await nextRunService.GetNextRunForTemplateAsync(reusableTemplate.Id, true); - - var updateRequest = new TemplateUpdaterMessage - { - TemplateId = reusableTemplate.Id, - JobId = jobId, - UnitId = unitId, - Name = expectedName, - IsActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState, - IsActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState, - StatusTypeId = TemplateStatusTypeEnum.Used, - Initiator = initiator, - //NextRun = nextRun, - IsNew = true, - UnitsInTemplate = new List() // для простого шаблона - }; - - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); - } - else - { - logger.LogDebug("Создание нового шаблона для Job {JobId}, UnitId {UnitId}.", jobId, unitId); - await CreateSimpleTemplateAsync(jobId, unitId, initiator); - } + await templateAllocationService.AllocateAsync(request); } // === Обработка существующих шаблонов (проверка имени) === @@ -280,7 +253,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer UnitsInTemplate = new List() // для простого шаблона }; - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); } } } @@ -415,7 +388,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer UnitsInTemplate = new List() // для простого шаблона }; - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); } } } @@ -662,7 +635,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer UnitsInTemplate = new List() }; - await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); } diff --git a/PARR.TemplateMatcher/Services/Implementations/TemplateAllocationService.cs b/PARR.TemplateMatcher/Services/Implementations/TemplateAllocationService.cs new file mode 100644 index 00000000..2327e1f7 --- /dev/null +++ b/PARR.TemplateMatcher/Services/Implementations/TemplateAllocationService.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; +using PARR.TemplateMatcher.Models; +using PARR.TemplateMatcher.Services.Interfaces; + +namespace PARR.TemplateMatcher.Services.Implementations +{ + internal class TemplateAllocationService : ITemplateAllocationService + { + private readonly ILogger logger; + private readonly ITemplateReuser reuser; + private readonly ITemplateMqPublisher publisher; + + public TemplateAllocationService( + ILogger logger, + ITemplateReuser reuser, + ITemplateMqPublisher publisher) + { + this.logger = logger; + this.reuser = reuser; + this.publisher = publisher; + } + + public async Task AllocateAsync(TemplateAllocationRequest request, CancellationToken ct = default) + { + // Пробуем переиспользовать + bool reused = await reuser.TryReuseAsync( + request.TargetJob, + request.TargetUnitId, + request.TargetUnit, + request.Index, + request.UnitsInTemplate, + request.IsActiveTemplate, + request.IsActiveSchedule, + request.Initiator, + ct); + + if (!reused) + { + logger.LogDebug("Переиспользование не удалось. Запрос на создание шаблона для Job {JobId}, Unit {UnitId}, Index {Index}.", + request.TargetJob.Id, request.TargetUnitId, request.Index); + + var createMessage = new TemplateGeneratorMessage + { + JobId = request.TargetJob.Id, + UnitId = request.TargetUnitId, + Index = request.Index, + UnitsInTemplate = request.UnitsInTemplate ?? new List(), + IsActiveTemplate = request.IsActiveTemplate, + IsActiveSchedule = request.IsActiveSchedule, + HistoryInitiator = request.Initiator + }; + + await publisher.PublishCreateAsync(createMessage, ct); + } + } + } +} diff --git a/PARR.TemplateMatcher/Services/Implementations/TemplateDeactivator.cs b/PARR.TemplateMatcher/Services/Implementations/TemplateDeactivator.cs index a0efa2bc..a0851e2a 100644 --- a/PARR.TemplateMatcher/Services/Implementations/TemplateDeactivator.cs +++ b/PARR.TemplateMatcher/Services/Implementations/TemplateDeactivator.cs @@ -23,7 +23,7 @@ internal class TemplateDeactivator : ITemplateDeactivator private readonly ITemplateRepository templateService; private readonly IJobRepository jobService; private readonly ITemplateNameNormalizer namenormalizer; - private readonly ITemplateUpdaterMqSender sender; + private readonly ITemplateMqPublisher templateMqPublisher; private readonly SettingsFromDb settingsFromDb; private readonly IOptions templateSettings; @@ -32,7 +32,7 @@ internal class TemplateDeactivator : ITemplateDeactivator ITemplateRepository templateService, IJobRepository jobService, ITemplateNameNormalizer namenormalizer, - ITemplateUpdaterMqSender sender, + ITemplateMqPublisher templateMqPublisher, SettingsFromDb settingsFromDb, IOptions templateSettings ) @@ -41,7 +41,7 @@ internal class TemplateDeactivator : ITemplateDeactivator this.templateService = templateService; this.jobService = jobService; this.namenormalizer = namenormalizer; - this.sender = sender; + this.templateMqPublisher = templateMqPublisher; this.settingsFromDb = settingsFromDb; this.templateSettings = templateSettings; } @@ -115,7 +115,7 @@ internal class TemplateDeactivator : ITemplateDeactivator UnitsInTemplate = new List() }; - await sender.SendTemplateUpdateMessageAsync(updateRequest); + await templateMqPublisher.PublishUpdateAsync(updateRequest); return true; } } \ No newline at end of file diff --git a/PARR.TemplateMatcher/Services/Implementations/TemplateMqPublisher.cs b/PARR.TemplateMatcher/Services/Implementations/TemplateMqPublisher.cs new file mode 100644 index 00000000..97c3eb1c --- /dev/null +++ b/PARR.TemplateMatcher/Services/Implementations/TemplateMqPublisher.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Common.Interfaces.RabbitServices; +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; +using PARR.TemplateMatcher.Services.Interfaces; +using PARR.TemplateMatcher.Settings; + +namespace PARR.TemplateMatcher.Services.Implementations +{ + internal class TemplateMqPublisher : ITemplateMqPublisher + { + private readonly ILogger logger; + private readonly IRabbitService mqService; + private readonly MqSettings mqSettings; + + public TemplateMqPublisher(ILogger logger, IRabbitService mqService, MqSettings mqSettings) + { + this.logger = logger; + this.mqService = mqService; + this.mqSettings = mqSettings; + } + + public async Task PublishUpdateAsync(TemplateUpdaterMessage message, CancellationToken ct = default) + { + var result = await mqService.SendAsync(mqSettings.TemplateUpdater, new List { message }); + if (!result.IsSuccess) + logger.LogError("Ошибка публикации команды обновления шаблона {TemplateId}.", message.TemplateId); + } + + public async Task PublishCreateAsync(TemplateGeneratorMessage message, CancellationToken ct = default) + { + var result = await mqService.SendAsync(mqSettings.TemplateGenerator, new List { message }); + if (!result.IsSuccess) + logger.LogError("Ошибка публикации команды создания шаблона для Job {JobId}, Unit {UnitId}.", message.JobId, message.UnitId); + } + } +} diff --git a/PARR.TemplateMatcher/Services/Implementations/TemplateReuser.cs b/PARR.TemplateMatcher/Services/Implementations/TemplateReuser.cs index 92812e1c..cbf59631 100644 --- a/PARR.TemplateMatcher/Services/Implementations/TemplateReuser.cs +++ b/PARR.TemplateMatcher/Services/Implementations/TemplateReuser.cs @@ -1,8 +1,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.Core.Repositories.Interfaces; +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; using PARR.Domain.Entities; using PARR.Domain.Entities.Base.History; +using PARR.Domain.Entities.Job; +using PARR.Domain.Entities.Unit; +using PARR.Domain.Enums; using PARR.TemplateMatcher.Services.Interfaces; namespace PARR.TemplateMatcher.Services.Implementations; @@ -10,71 +14,105 @@ namespace PARR.TemplateMatcher.Services.Implementations; internal class TemplateReuser : ITemplateReuser { private readonly ILogger logger; - private readonly ITemplateRepository templateService; + private readonly ITemplateRepository templateRepository; + private readonly ITemplateNameNormalizer nameNormalizer; + private readonly ITemplateMqPublisher mqPublisher; public TemplateReuser( ILogger logger, - ITemplateRepository templateService) + ITemplateRepository templateRepository, + ITemplateNameNormalizer nameNormalizer, + ITemplateMqPublisher mqPublisher) { this.logger = logger; - this.templateService = templateService; + this.templateRepository = templateRepository; + this.nameNormalizer = nameNormalizer; + this.mqPublisher = mqPublisher; } - - public async Task TryReuseOneUnusedTemplateAsync( - Guid jobId, - Guid unitId, + public async Task TryReuseAsync( + Job targetJob, + Guid targetUnitId, + Unit? targetUnit, + int? index, + List unitsInTemplate, + bool isActiveTemplate, + bool isActiveSchedule, HistoryInitiator initiator, - int maxAttempts = 3) + CancellationToken ct = default) { + const int maxAttempts = 3; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + ct.ThrowIfCancellationRequested(); + try { - // Атомарно резервируем один шаблон через DAL - var templateId = await templateService.ReserveUnusedTemplateAsync(unitId, initiator); - + // 1. Атомарный захват шаблона + var templateId = await templateRepository.ReserveUnusedTemplateAsync(targetUnitId, initiator); if (templateId == null) { - logger.LogDebug("Нет доступных Unused-шаблонов для переиспользования (попытка {Attempt}).", attempt); - return null; + logger.LogDebug("Нет доступных Unused-шаблонов (попытка {Attempt}).", attempt); + return false; // Шаблоны закончились } - // Загружаем зарезервированный шаблон - var template = await templateService.Get() + // Загружаем захваченный шаблон + var template = await templateRepository.Get() .AsNoTracking() .Include(t => t.Unit) - .Include(t => t.Job) - .ThenInclude(t => t!.Tnk) - .Include(t => t.Job) - .ThenInclude(t => t!.Group) - .ThenInclude(t => t!.GroupType) - .FirstOrDefaultAsync(t => t.Id == templateId); + .FirstOrDefaultAsync(t => t.Id == templateId, ct); if (template == null) { - logger.LogWarning("Зарезервированный шаблон {TemplateId} не найден при загрузке.", templateId); - continue; + logger.LogWarning("Зарезервированный шаблон {TemplateId} не найден.", templateId); + continue; // Попробовать еще раз } - logger.LogInformation( - "Успешно захвачен шаблон {TemplateId} (старый Job {OldJobId}) для нового Job {NewJobId}, Unit {UnitId} (попытка {Attempt}).", - template.Id, template.JobId, jobId, unitId, attempt); + logger.LogInformation("Шаблон {TemplateId} захвачен для переиспользования.", templateId); - return template; + // 2. Нормализация имени + var templateForName = new Template + { + Id = template.Id, + Name = template.Name, + JobId = targetJob.Id, + UnitId = targetUnitId, + Index = index, + Job = targetJob, + Unit = targetUnit, + UnitsInTemplate = unitsInTemplate.Select(m => new UnitsInTemplate { UnitId = m.UnitId, UnitFieldValueId = m.UnitFieldValueId }).ToList() + }; + + var expectedName = await nameNormalizer.GetNormalizedTemplateNameAsync(templateForName); + + // 3. Отправка команды в MQ + var message = new TemplateUpdaterMessage + { + TemplateId = template.Id, + JobId = targetJob.Id, + UnitId = targetUnitId, + Name = expectedName, + IsActiveTemplate = isActiveTemplate, + IsActiveSchedule = isActiveSchedule, + StatusTypeId = TemplateStatusTypeEnum.Used, + Initiator = initiator, + IsNew = true, + Index = index ?? template.Index, + UnitsInTemplate = unitsInTemplate + }; + + await mqPublisher.PublishUpdateAsync(message, ct); + return true; // Успех } catch (Exception ex) { - logger.LogError(ex, "Ошибка при попытке захвата шаблона (попытка {Attempt}).", attempt); - - if (attempt == maxAttempts) - throw; - - // Небольшая задержка перед повтором - await Task.Delay(Random.Shared.Next(10, 50)); + logger.LogError(ex, "Ошибка при переиспользовании (попытка {Attempt}).", attempt); + if (attempt == maxAttempts) return false; // После 3 попыток сдаемся + await Task.Delay(Random.Shared.Next(10, 50), ct); } } - return null; + return false; } } \ No newline at end of file diff --git a/PARR.TemplateMatcher/Services/Implementations/TemplateUpdaterMqSender.cs b/PARR.TemplateMatcher/Services/Implementations/TemplateUpdaterMqSender.cs deleted file mode 100644 index ba825d69..00000000 --- a/PARR.TemplateMatcher/Services/Implementations/TemplateUpdaterMqSender.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.Extensions.Logging; -using PARR.Core.Common.Interfaces.RabbitServices; -using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; -using PARR.TemplateMatcher.Services.Interfaces; -using PARR.TemplateMatcher.Settings; - -namespace PARR.TemplateMatcher.Services.Implementations; - -internal class TemplateUpdaterMqSender : ITemplateUpdaterMqSender -{ - private readonly ILogger logger; - private readonly IRabbitService mqService; - private readonly MqSettings mqSettings; - - public TemplateUpdaterMqSender( - ILogger logger, - IRabbitService mqService, - MqSettings mqSettings - ) - { - this.logger = logger; - this.mqService = mqService; - this.mqSettings = mqSettings; - } - - public async Task SendTemplateUpdateMessageAsync(TemplateUpdaterMessage updateRequest) - { - logger.LogDebug("Отправка сообщения в очередь '{Queue}' для шаблона {TemplateId}", - mqSettings.TemplateUpdater.QueueName, updateRequest.TemplateId); - - var result = await mqService.SendAsync(mqSettings.TemplateUpdater, new List { updateRequest }); - - if (result.IsSuccess) - { - logger.LogInformation("Отправлен запрос на обновление шаблона {TemplateId}", updateRequest.TemplateId); - } - else - { - logger.LogError("Ошибка при отправке запроса на обновление шаблона {TemplateId} в очередь '{Queue}'.", - updateRequest.TemplateId, mqSettings.TemplateUpdater.QueueName); - } - } -} \ No newline at end of file diff --git a/PARR.TemplateMatcher/Services/Interfaces/ITemplateAllocationService.cs b/PARR.TemplateMatcher/Services/Interfaces/ITemplateAllocationService.cs new file mode 100644 index 00000000..b6b21b80 --- /dev/null +++ b/PARR.TemplateMatcher/Services/Interfaces/ITemplateAllocationService.cs @@ -0,0 +1,12 @@ +using PARR.TemplateMatcher.Models; + +namespace PARR.TemplateMatcher.Services.Interfaces +{ + /// + /// Оркестрирует выделение шаблона: попытка переиспользования или создание нового. + /// + public interface ITemplateAllocationService + { + Task AllocateAsync(TemplateAllocationRequest request, CancellationToken ct = default); + } +} diff --git a/PARR.TemplateMatcher/Services/Interfaces/ITemplateMqPublisher.cs b/PARR.TemplateMatcher/Services/Interfaces/ITemplateMqPublisher.cs new file mode 100644 index 00000000..b3d95ea5 --- /dev/null +++ b/PARR.TemplateMatcher/Services/Interfaces/ITemplateMqPublisher.cs @@ -0,0 +1,13 @@ +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; + +namespace PARR.TemplateMatcher.Services.Interfaces +{ + /// + /// Публикует команды создания и обновления шаблонов в RabbitMQ. + /// + public interface ITemplateMqPublisher + { + Task PublishUpdateAsync(TemplateUpdaterMessage message, CancellationToken ct = default); + Task PublishCreateAsync(TemplateGeneratorMessage message, CancellationToken ct = default); + } +} diff --git a/PARR.TemplateMatcher/Services/Interfaces/ITemplateReuser.cs b/PARR.TemplateMatcher/Services/Interfaces/ITemplateReuser.cs index b25bccce..39a6e13a 100644 --- a/PARR.TemplateMatcher/Services/Interfaces/ITemplateReuser.cs +++ b/PARR.TemplateMatcher/Services/Interfaces/ITemplateReuser.cs @@ -1,27 +1,31 @@ -using PARR.Domain.Entities; +using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; using PARR.Domain.Entities.Base.History; +using PARR.Domain.Entities.Job; +using PARR.Domain.Entities.Unit; namespace PARR.TemplateMatcher.Services.Interfaces { + + /// + /// Отвечает за полный цикл переиспользования Unused-шаблона: + /// 1. Атомарный захват в БД (с retry). + /// 2. Нормализация имени. + /// 3. Отправка команды на обновление (активацию) в MQ. + /// public interface ITemplateReuser { /// - /// Пытается переиспользовать неиспользуемый шаблон для указанной работы и ЭК. - /// При резервировании шаблона для правильного расчета NextRun Id работы - /// и ЭК устанавливается при захвате. + /// Пытается переиспользовать шаблон. Возвращает true, если успешно захвачен и отправлена команда. /// - /// Id работы. - /// Id ЭК. - /// Данные инициатора операции. - /// Максимальное количество попыток (по умолчанию 3). - /// - /// Зарезервированный шаблон или , - /// если не удалось получить шаблон после всех попыток. - /// - Task TryReuseOneUnusedTemplateAsync( - Guid jobId, - Guid unitId, + Task TryReuseAsync( + Job targetJob, + Guid targetUnitId, + Unit? targetUnit, + int? index, + List unitsInTemplate, + bool isActiveTemplate, + bool isActiveSchedule, HistoryInitiator initiator, - int maxAttempts = 3); + CancellationToken ct = default); } } diff --git a/PARR.TemplateMatcher/Services/Interfaces/ITemplateUpdaterMqSender.cs b/PARR.TemplateMatcher/Services/Interfaces/ITemplateUpdaterMqSender.cs deleted file mode 100644 index 479c305e..00000000 --- a/PARR.TemplateMatcher/Services/Interfaces/ITemplateUpdaterMqSender.cs +++ /dev/null @@ -1,9 +0,0 @@ -using PARR.Domain.Common.Rabbit.Messages.TemplateMatching; - -namespace PARR.TemplateMatcher.Services.Interfaces -{ - public interface ITemplateUpdaterMqSender - { - Task SendTemplateUpdateMessageAsync(TemplateUpdaterMessage updateRequest); - } -} diff --git a/PARR.TemplateMatcher/TemplateMatcherInstaller.cs b/PARR.TemplateMatcher/TemplateMatcherInstaller.cs index 128dd6bb..90a3d384 100644 --- a/PARR.TemplateMatcher/TemplateMatcherInstaller.cs +++ b/PARR.TemplateMatcher/TemplateMatcherInstaller.cs @@ -26,20 +26,34 @@ namespace PARR.TemplateMatcher configuration.GetSection(nameof(TemplateSettings)).Bind(templateSettings); services.AddSingleton(templateSettings); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + // === 1. Singleton: Долгоживущие сервисы и обработчики очередей === + services.AddSingleton(); + + // === 2. Scoped: Бизнес-логика и работа с БД (DbContext) === + // Создаются заново для каждого сообщения из очереди (внутри CreateAsyncScope) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Пайплайн аллокации шаблонов + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Пайплайн групповых шаблонов + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Валидаторы + services.AddScoped(); + services.AddScoped(); + + // === 3. Transient: Stateless утилиты и инфраструктура === + // Легковесные сервисы без состояния, создаются по требованию + services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); } public static IConfigurationBuilder AddTemplateMatcherConfigurations(this IConfigurationBuilder builder, IServiceCollection services)