merge to dev

This commit is contained in:
Mikhail Kuznetsov
2026-05-20 18:06:38 +10:00
26 changed files with 987 additions and 630 deletions

View File

@@ -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

View File

@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
@@ -15,8 +15,14 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="PARR.Core.Tests" />
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>

View File

@@ -1,4 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("PARR.DAL.Tests")]
[assembly: InternalsVisibleTo("PARR.DAL.IntegrationTests")]

View File

@@ -2,6 +2,6 @@
{
internal class UnitFilterServiceOptions
{
public int LoadBatchSize { get; set; } = 100;
public int LoadBatchSize { get; set; } = 1000;
}
}

View File

@@ -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;

View File

@@ -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<ILogger<ShortcodesService>> loggerMock = new();
private readonly Mock<SettingsFromDb> settingsMock = new();
private readonly Mock<IJobRepository> jobServiceMock = new();
private readonly Mock<IUnitRepository> unitServiceMock = new();
private readonly Mock<IUnitInValueRepository> unitInValueServiceMock = new();
private readonly Mock<IUnitFieldRepository> unitFieldServiceMock = new();
private readonly Mock<IUnitFilterService> unitFilterServiceMock = new();
[Fact]
public async Task ApplyShortcodesAsync_WithEc_ShouldReplaceEc()
{
}
}
}

View File

@@ -1,79 +0,0 @@
namespace PARR.DAL.Tests
{
public class UnitFilterServiceTests //: IDisposable
{
// private readonly DbContextOptions<DataContext> _options;
// private readonly DataContext _context;
// private readonly UnitFilterService _service;
// public UnitFilterServiceTests()
// {
// _options = new DbContextOptionsBuilder<DataContext>()
// .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
// .EnableSensitiveDataLogging()
// .Options;
// _context = new DataContext(_options);
// _context.Database.EnsureCreated();
// TestDataGenerator.Seed(_context);
// // Логгеры
// var logger = Mock.Of<ILogger<UnitFilterService>>();
// var jobServiceLogger = Mock.Of<ILogger<JobService>>();
// var unitServiceLogger = Mock.Of<ILogger<UnitService>>();
// var unitInUnitServiceLogger = Mock.Of<ILogger<UnitInUnitService>>();
// var unitInValueServiceLogger = Mock.Of<ILogger<UnitInValueService>>();
// // Реальные сервисы с правильными конструкторами
// 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-ДВС" });
// }
}
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.16" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="MockQueryable.Moq" Version="9.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PARR.Core\PARR.Core.csproj" />
<ProjectReference Include="..\PARR.DAL\PARR.DAL.csproj" />
</ItemGroup>
</Project>

View File

@@ -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
/// <summary>
/// Создаёт тестовую сущность Unit с заданным значением поля.
/// Используется для подготовки данных в тестах фильтрации.
/// </summary>
/// <param name="id">Уникальный идентификатор юнита</param>
/// <param name="name">Имя юнита для отладки</param>
/// <param name="fieldId">Идентификатор поля, к которому привязывается значение</param>
/// <param name="fieldValue">Значение поля</param>
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<UnitInValue>
{
new UnitInValue
{
UnitId = id,
FieldId = fieldId,
ValueId = fieldVal.Id,
Value = fieldVal
}
}
};
}
/// <summary>
/// Создаёт тестовый объект JobGroupType с кодом Umbrella.
/// Используется для тестирования логики групповых работ типа "зонтик".
/// </summary>
private static JobGroupType BuildTestJobGroupType() => new()
{
Id = Guid.NewGuid(),
Code = JobGroupTypesEnum.Group,
Name = "Grouped Group",
Description = "Test"
};
/// <summary>
/// Создаёт тестовый объект JobGroup с заполненными обязательными полями.
/// Используется для подготовки навигационных свойств в тестах.
/// </summary>
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()
};
/// <summary>
/// Создаёт тестовый объект JobAutoControl с отключённым автоконтролем.
/// Используется для заполнения обязательного свойства в сущности Job.
/// </summary>
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()
{
JobId = jobId,
IsEnable = false
};
/// <summary>
/// Контейнер для моков репозиториев.
/// Упрощает передачу зависимостей в метод создания сервиса.
/// </summary>
private class RepositoryMocks
{
public Mock<IUnitRepository> Unit { get; set; }
public Mock<IUnitInUnitRepository> UnitInUnit { get; set; }
public Mock<IUnitInValueRepository> UnitInValue { get; set; }
public Mock<IUnitFieldRepository> UnitField { get; set; }
public Mock<IJobRepository> Job { get; set; }
public Mock<IRedisCacheService> Cache { get; set; }
}
/// <summary>
/// Настраивает базовые моки репозиториев с использованием InMemory-контекста.
/// Возвращает объект с подготовленными моками для повторного использования в тестах.
/// </summary>
/// <param name="context">Экземпляр DataContext с тестовыми данными</param>
private static RepositoryMocks ArrangeRepositoryMocks(DataContext context)
{
var unitRepoMock = new Mock<IUnitRepository>();
unitRepoMock.Setup(r => r.Get()).Returns(context.Units.AsQueryable());
unitRepoMock.Setup(r => r.GetInitialUnitIds(It.IsAny<string>()))
.Returns(context.Units.Select(u => u.Id).AsQueryable());
var unitInUnitRepoMock = new Mock<IUnitInUnitRepository>();
unitInUnitRepoMock.Setup(r => r.Get()).Returns(context.UnitInUnits.AsQueryable());
var unitInValueRepoMock = new Mock<IUnitInValueRepository>();
// Единый подход: используем контекст для поддержки IAsyncQueryProvider
// Это гарантирует корректную работу .ToListAsync() внутри сервиса
unitInValueRepoMock.Setup(r => r.GetMatchingTargetIds(It.IsAny<Guid>(), It.IsAny<string>()))
.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<IUnitFieldRepository>();
unitFieldRepoMock.Setup(r => r.Get()).Returns(new List<UnitField>().AsQueryable());
var jobRepoMock = new Mock<IJobRepository>();
jobRepoMock.Setup(r => r.Get()).Returns(context.Jobs.AsQueryable());
var cacheMock = new Mock<IRedisCacheService>();
cacheMock.Setup(r => r.GetCachedDataAsync<UnitFilterIds>(It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync((UnitFilterIds)null!);
return new RepositoryMocks
{
Unit = unitRepoMock,
UnitInUnit = unitInUnitRepoMock,
UnitInValue = unitInValueRepoMock,
UnitField = unitFieldRepoMock,
Job = jobRepoMock,
Cache = cacheMock
};
}
/// <summary>
/// Создаёт экземпляр тестируемого сервиса (SUT — System Under Test).
/// Инкапсулирует логику конструктора для упрощения тестов.
/// </summary>
/// <param name="mocks">Подготовленные моки репозиториев</param>
/// <param name="logger">Экземпляр логгера для сервиса</param>
private static PARR.Core.Services.UnitFilterService.UnitFilterService CreateSut(
RepositoryMocks mocks,
ILogger<PARR.Core.Services.UnitFilterService.UnitFilterService> 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
/// <summary>
/// Проверяет, что юниты, не прошедшие фильтрацию по полю (FieldFilter)
/// или по связям (RelationshipFilter), корректно исключаются из результата.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_UnitsWithInvalidFieldOrRelationshipValue_AreExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.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<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>
{
new JobFieldFilter { Id = Guid.NewGuid(), FieldId = fieldId, ValueMask = "Accepted%", IsInverse = false }
},
RelationshipFilters = new List<JobRelationshipFilter>
{
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<IQueryable<Unit>>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<bool>()))
.Returns((IQueryable<Unit> 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<string>()))
.Returns(context.Units.Where(u => u.Id == validParentId).Select(u => u.Id).AsQueryable());
var logger = NullLoggerFactory.Instance.CreateLogger<PARR.Core.Services.UnitFilterService.UnitFilterService>();
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);
}
/// <summary>
/// Проверяет, что юниты исключаются, если их единственный родительский юнит
/// содержит запрещённое значение тега (сценарий с IsInverse = true).
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_ParentWithForbiddenTag_UnitIsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.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<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
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<string>()))
.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<PARR.Core.Services.UnitFilterService.UnitFilterService>();
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);
}
/// <summary>
/// Проверяет логику IsFullMatch = true: юнит исключается,
/// если хотя бы один из его родительских юнитов не соответствует фильтру.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsFullMatch_UnitWithAnyNonMatchingParent_IsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.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<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
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<string>()))
.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<PARR.Core.Services.UnitFilterService.UnitFilterService>();
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);
}
/// <summary>
/// Проверяет комбинацию флагов: IsParent=true, IsInverse=true, IsFullMatch=true.
/// Логика: юнит проходит, только если НИ ОДИН из его родителей не содержит запрещённое значение.
/// </summary>
[Fact]
public async Task GetUnitsByJobFilterAsync_IsParentAndInverseAndFullMatch_UnitWithAnyForbiddenParent_IsExcluded()
{
// === Arrange ===
var dbOptions = new DbContextOptionsBuilder<DataContext>()
.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<JobUnitFilter>
{
new JobUnitFilter
{
Id = Guid.NewGuid(),
JobId = jobId,
UnitFilter = "%",
FieldFilters = new List<JobFieldFilter>(),
RelationshipFilters = new List<JobRelationshipFilter>
{
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<string>()))
.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<PARR.Core.Services.UnitFilterService.UnitFilterService>();
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);
}
}

View File

@@ -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<UnitInValue>
{
// Основные (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<Job>();
// --- 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<JobUnitFilter>
{
new JobUnitFilter
{
Id = unitFilter1Id,
UnitFilter = "СХД-%",
JobId = job1.Id,
Job = job1,
DateCreated = now,
FieldFilters = new List<JobFieldFilter>
{
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<JobRelationshipFilter>
{
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<JobUnitFilter>
{
new JobUnitFilter
{
Id = unitFilter2Id,
UnitFilter = "СХД-%",
JobId = job2.Id,
Job = job2,
DateCreated = now,
FieldFilters = new List<JobFieldFilter>
{
new JobFieldFilter
{
UnitFilterId = unitFilter2Id,
FieldId = FieldId1,
ValueMask="коммутатор"
},
new JobFieldFilter
{
UnitFilterId = unitFilter2Id,
FieldId = FieldId4,
ValueMask="1"
},
},
RelationshipFilters = new List<JobRelationshipFilter>
{
new JobRelationshipFilter
{
IsParent = false, // <-- Новый фильтр: ищем дочерние юниты
IsInverse = false, // <-- Простой фильтр
IsFullMatch = false, // <-- Простой фильтр
FieldId = FieldId1,
ValueMask = "коммутатор",
UnitFilterId = unitFilter2Id
}
}
}
};
jobs.Add(job2);
context.Jobs.AddRange(jobs);
context.SaveChanges();
}
}
}

View File

@@ -22,4 +22,11 @@
<ProjectReference Include="..\PARR.Core\PARR.Core.csproj" />
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="PARR.Core.Tests" />
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>

View File

@@ -1,5 +0,0 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("PARR.DAL.Tests")]
[assembly: InternalsVisibleTo("PARR.DAL.IntegrationTests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]

View File

@@ -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<UnitInTemplateMessage> UnitsInTemplate,
bool IsActiveTemplate,
bool IsActiveSchedule,
HistoryInitiator Initiator);
}

View File

@@ -19,30 +19,26 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor
private readonly ILogger<GroupedTemplateProcessor> 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<GroupedTemplateProcessor> 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<HashSet<(Guid JobId, Guid UnitId, int Index)>> 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<object> { mqRequest });
if (!result.IsSuccess)
logger.LogError("Ошибка отправки команды создания шаблона.");
}
private static Job SelectTargetJob(List<Job> jobsInGroup, int subGroupSize, Job maxJob)
{

View File

@@ -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> 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> 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<UnitsInTemplate>() // для простого шаблона
};
var request = new TemplateAllocationRequest(
TargetJob: job,
TargetUnitId: unitId,
TargetUnit: null, // Для простого шаблона не критично, нормализатор загрузит при необходимости
Index: null,
UnitsInTemplate: new List<UnitInTemplateMessage>(),
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<UnitInTemplateMessage>() // для простого шаблона
};
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<UnitInTemplateMessage>() // для простого шаблона
};
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest);
await templateMqPublisher.PublishUpdateAsync(updateRequest);
}
}
}
@@ -415,7 +388,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона
};
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest);
await templateMqPublisher.PublishUpdateAsync(updateRequest);
}
}
}
@@ -662,7 +635,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>()
};
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest);
await templateMqPublisher.PublishUpdateAsync(updateRequest);
}

View File

@@ -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<TemplateAllocationService> logger;
private readonly ITemplateReuser reuser;
private readonly ITemplateMqPublisher publisher;
public TemplateAllocationService(
ILogger<TemplateAllocationService> 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<UnitInTemplateMessage>(),
IsActiveTemplate = request.IsActiveTemplate,
IsActiveSchedule = request.IsActiveSchedule,
HistoryInitiator = request.Initiator
};
await publisher.PublishCreateAsync(createMessage, ct);
}
}
}
}

View File

@@ -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> templateSettings;
@@ -32,7 +32,7 @@ internal class TemplateDeactivator : ITemplateDeactivator
ITemplateRepository templateService,
IJobRepository jobService,
ITemplateNameNormalizer namenormalizer,
ITemplateUpdaterMqSender sender,
ITemplateMqPublisher templateMqPublisher,
SettingsFromDb settingsFromDb,
IOptions<TemplateSettings> 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<UnitInTemplateMessage>()
};
await sender.SendTemplateUpdateMessageAsync(updateRequest);
await templateMqPublisher.PublishUpdateAsync(updateRequest);
return true;
}
}

View File

@@ -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<TemplateMqPublisher> logger;
private readonly IRabbitService mqService;
private readonly MqSettings mqSettings;
public TemplateMqPublisher(ILogger<TemplateMqPublisher> 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<object> { 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<object> { message });
if (!result.IsSuccess)
logger.LogError("Ошибка публикации команды создания шаблона для Job {JobId}, Unit {UnitId}.", message.JobId, message.UnitId);
}
}
}

View File

@@ -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<TemplateReuser> logger;
private readonly ITemplateRepository templateService;
private readonly ITemplateRepository templateRepository;
private readonly ITemplateNameNormalizer nameNormalizer;
private readonly ITemplateMqPublisher mqPublisher;
public TemplateReuser(
ILogger<TemplateReuser> 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<Template?> TryReuseOneUnusedTemplateAsync(
Guid jobId,
Guid unitId,
public async Task<bool> TryReuseAsync(
Job targetJob,
Guid targetUnitId,
Unit? targetUnit,
int? index,
List<UnitInTemplateMessage> 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;
}
}

View File

@@ -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<TemplateUpdaterMqSender> logger;
private readonly IRabbitService mqService;
private readonly MqSettings mqSettings;
public TemplateUpdaterMqSender(
ILogger<TemplateUpdaterMqSender> 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<object> { updateRequest });
if (result.IsSuccess)
{
logger.LogInformation("Отправлен запрос на обновление шаблона {TemplateId}", updateRequest.TemplateId);
}
else
{
logger.LogError("Ошибка при отправке запроса на обновление шаблона {TemplateId} в очередь '{Queue}'.",
updateRequest.TemplateId, mqSettings.TemplateUpdater.QueueName);
}
}
}

View File

@@ -0,0 +1,12 @@
using PARR.TemplateMatcher.Models;
namespace PARR.TemplateMatcher.Services.Interfaces
{
/// <summary>
/// Оркестрирует выделение шаблона: попытка переиспользования или создание нового.
/// </summary>
public interface ITemplateAllocationService
{
Task AllocateAsync(TemplateAllocationRequest request, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,13 @@
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
namespace PARR.TemplateMatcher.Services.Interfaces
{
/// <summary>
/// Публикует команды создания и обновления шаблонов в RabbitMQ.
/// </summary>
public interface ITemplateMqPublisher
{
Task PublishUpdateAsync(TemplateUpdaterMessage message, CancellationToken ct = default);
Task PublishCreateAsync(TemplateGeneratorMessage message, CancellationToken ct = default);
}
}

View File

@@ -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
{
/// <summary>
/// Отвечает за полный цикл переиспользования Unused-шаблона:
/// 1. Атомарный захват в БД (с retry).
/// 2. Нормализация имени.
/// 3. Отправка команды на обновление (активацию) в MQ.
/// </summary>
public interface ITemplateReuser
{
/// <summary>
/// Пытается переиспользовать неиспользуемый шаблон для указанной работы и ЭК.
/// При резервировании шаблона для правильного расчета NextRun Id работы
/// и ЭК устанавливается при захвате.
/// Пытается переиспользовать шаблон. Возвращает true, если успешно захвачен и отправлена команда.
/// </summary>
/// <param name="jobId">Id работы.</param>
/// <param name="unitId">Id ЭК.</param>
/// <param name="initiator">Данные инициатора операции.</param>
/// <param name="maxAttempts">Максимальное количество попыток (по умолчанию 3).</param>
/// <returns>
/// Зарезервированный шаблон или <see langword="null"/>,
/// если не удалось получить шаблон после всех попыток.
/// </returns>
Task<Template?> TryReuseOneUnusedTemplateAsync(
Guid jobId,
Guid unitId,
Task<bool> TryReuseAsync(
Job targetJob,
Guid targetUnitId,
Unit? targetUnit,
int? index,
List<UnitInTemplateMessage> unitsInTemplate,
bool isActiveTemplate,
bool isActiveSchedule,
HistoryInitiator initiator,
int maxAttempts = 3);
CancellationToken ct = default);
}
}

View File

@@ -1,9 +0,0 @@
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
namespace PARR.TemplateMatcher.Services.Interfaces
{
public interface ITemplateUpdaterMqSender
{
Task SendTemplateUpdateMessageAsync(TemplateUpdaterMessage updateRequest);
}
}

View File

@@ -26,20 +26,34 @@ namespace PARR.TemplateMatcher
configuration.GetSection(nameof(TemplateSettings)).Bind(templateSettings);
services.AddSingleton(templateSettings);
services.AddTransient<IMqTemplateMatcher, MqTemplateMatcher>();
services.AddTransient<IJobValidatorService, JobValidatorService>();
services.AddTransient<IJobGroupValidatorService, JobGroupValidatorService>();
services.AddTransient<ITemplateMatcher, TemplateMatcher>();
services.AddTransient<ITemplateReuser, TemplateReuser>();
// === 1. Singleton: Долгоживущие сервисы и обработчики очередей ===
services.AddSingleton<IMqTemplateMatcher, MqTemplateMatcher>();
// === 2. Scoped: Бизнес-логика и работа с БД (DbContext) ===
// Создаются заново для каждого сообщения из очереди (внутри CreateAsyncScope)
services.AddScoped<ITemplateMatcher, TemplateMatcher>();
services.AddScoped<ITemplateSynchronizer, SimpleTemplateSynchronizer>();
services.AddScoped<ITemplateSynchronizer, GroupedTemplateSynchronizer>();
// Пайплайн аллокации шаблонов
services.AddScoped<ITemplateAllocationService, TemplateAllocationService>();
services.AddScoped<ITemplateReuser, TemplateReuser>();
services.AddScoped<ITemplateDeactivator, TemplateDeactivator>();
// Пайплайн групповых шаблонов
services.AddScoped<IGroupedTemplateUnitFilter, GroupedTemplateUnitFilter>();
services.AddScoped<IUnitInTemplateConflictMapper, UnitInTemplateConflictMapper>();
services.AddScoped<IGroupedTemplateBuilder, GroupedTemplateBuilder>();
services.AddScoped<IGroupedTemplateProcessor, GroupedTemplateProcessor>();
// Валидаторы
services.AddScoped<IJobValidatorService, JobValidatorService>();
services.AddScoped<IJobGroupValidatorService, JobGroupValidatorService>();
// === 3. Transient: Stateless утилиты и инфраструктура ===
// Легковесные сервисы без состояния, создаются по требованию
services.AddTransient<ITemplateMqPublisher, TemplateMqPublisher>();
services.AddTransient<ITemplateNameNormalizer, TemplateNameNormalizer>();
services.AddTransient<ITemplateUpdaterMqSender, TemplateUpdaterMqSender>();
services.AddTransient<ITemplateDeactivator, TemplateDeactivator>();
services.AddTransient<IUnitInTemplateConflictMapper, UnitInTemplateConflictMapper>();
services.AddTransient<IGroupedTemplateUnitFilter, GroupedTemplateUnitFilter>();
services.AddTransient<IGroupedTemplateBuilder, GroupedTemplateBuilder>();
services.AddTransient<IGroupedTemplateProcessor, GroupedTemplateProcessor>();
services.AddTransient<ITemplateSynchronizer, SimpleTemplateSynchronizer>();
services.AddTransient<ITemplateSynchronizer, GroupedTemplateSynchronizer>();
}
public static IConfigurationBuilder AddTemplateMatcherConfigurations(this IConfigurationBuilder builder, IServiceCollection services)