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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateMatcherWorker", "PARR.TemplateMatcherWorker\PARR.TemplateMatcherWorker.csproj", "{DA6B2A17-6E64-4E2C-8D4C-E278BEE94E03}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateMatcherWorker", "PARR.TemplateMatcherWorker\PARR.TemplateMatcherWorker.csproj", "{DA6B2A17-6E64-4E2C-8D4C-E278BEE94E03}"
EndProject 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdaterWorker", "PARR.TemplateUpdaterWorker\PARR.TemplateUpdaterWorker.csproj", "{27263750-9C80-4F9D-A550-B9D9C473B386}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdaterWorker", "PARR.TemplateUpdaterWorker\PARR.TemplateUpdaterWorker.csproj", "{27263750-9C80-4F9D-A550-B9D9C473B386}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdater", "PARR.TemplateUpdater\PARR.TemplateUpdater.csproj", "{10905C04-A6E5-42BC-9804-8671D22C5D6E}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.TemplateUpdater", "PARR.TemplateUpdater\PARR.TemplateUpdater.csproj", "{10905C04-A6E5-42BC-9804-8671D22C5D6E}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.Core", "PARR.Core\PARR.Core.csproj", "{E93DFC30-9886-486A-9F67-F782ABE0D1E0}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PARR.Domain", "PARR.Domain\PARR.Domain.csproj", "{3740461A-7235-4C26-81F3-3977529D646D}" 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}.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.ActiveCfg = Release|Any CPU
{10905C04-A6E5-42BC-9804-8671D22C5D6E}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{E93DFC30-9886-486A-9F67-F782ABE0D1E0}.Debug|Any CPU.Build.0 = 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 {E93DFC30-9886-486A-9F67-F782ABE0D1E0}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" /> <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
@@ -15,8 +15,14 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" /> <ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="PARR.Core.Tests" />
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project> </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 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 internal class UnitFilterService : IUnitFilterService
{ {
#if DEBUG #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 #endif
private const int DebugMaxUnitsToLog = 10; 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.Core\PARR.Core.csproj" />
<ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" /> <ProjectReference Include="..\PARR.Domain\PARR.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Разрешаем тестовой сборке видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="PARR.Core.Tests" />
<!-- Разрешаем Castle DynamicProxy (Moq / NSubstitute) видеть internal классы PARR.Core -->
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project> </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 ILogger<GroupedTemplateProcessor> logger;
private readonly ITemplateRepository templateRepository; private readonly ITemplateRepository templateRepository;
private readonly IUnitRepository unitRepository; private readonly IUnitRepository unitRepository;
private readonly ITemplateReuser templateReuser;
private readonly ITemplateNameNormalizer templateNameNormalizer; private readonly ITemplateNameNormalizer templateNameNormalizer;
private readonly ITemplateUpdaterMqSender templateUpdaterMqSender; private readonly ITemplateAllocationService templateAllocationService;
private readonly MqSettings mqSettings; private readonly ITemplateMqPublisher templateMqPublisher;
private readonly IRabbitService mqService;
public GroupedTemplateProcessor( public GroupedTemplateProcessor(
ILogger<GroupedTemplateProcessor> logger, ILogger<GroupedTemplateProcessor> logger,
ITemplateRepository templateRepository, ITemplateRepository templateRepository,
IUnitRepository unitRepository, IUnitRepository unitRepository,
ITemplateReuser templateReuser,
ITemplateNameNormalizer templateNameNormalizer, ITemplateNameNormalizer templateNameNormalizer,
ITemplateUpdaterMqSender templateUpdaterMqSender, ITemplateAllocationService templateAllocationService,
ITemplateMqPublisher templateMqPublisher,
MqSettings mqSettings, MqSettings mqSettings,
IRabbitService mqService) IRabbitService mqService)
{ {
this.logger = logger; this.logger = logger;
this.templateRepository = templateRepository; this.templateRepository = templateRepository;
this.unitRepository = unitRepository; this.unitRepository = unitRepository;
this.templateReuser = templateReuser;
this.templateNameNormalizer = templateNameNormalizer; this.templateNameNormalizer = templateNameNormalizer;
this.templateUpdaterMqSender = templateUpdaterMqSender; this.templateAllocationService = templateAllocationService;
this.mqSettings = mqSettings; this.templateMqPublisher = templateMqPublisher;
this.mqService = mqService;
} }
public async Task<HashSet<(Guid JobId, Guid UnitId, int Index)>> ProcessAsync( public async Task<HashSet<(Guid JobId, Guid UnitId, int Index)>> ProcessAsync(
@@ -89,7 +85,7 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor
} }
else 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 UnitFieldValueId = t.UnitFieldValueId
}).ToList() }).ToList()
}; };
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); await templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
} }
else else
@@ -174,50 +170,33 @@ internal class GroupedTemplateProcessor : IGroupedTemplateProcessor
List<(Guid UnitId, Guid UnitFieldValueId)> unitsInTemplateSubGroup, List<(Guid UnitId, Guid UnitFieldValueId)> unitsInTemplateSubGroup,
Job targetJob, Job targetJob,
int globalIndex, 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) // Маппим кортежи в сообщения
{ var unitsInTemplateMsg = unitsInTemplateSubGroup
logger.LogInformation("Переиспользован шаблон {TemplateId}.", reusableTemplate.Id); .Select(e => new UnitInTemplateMessage
var tempTemplateForName = new Template
{ {
Id = reusableTemplate.Id, UnitId = e.UnitId,
Name = reusableTemplate.Name, UnitFieldValueId = e.UnitFieldValueId
JobId = targetJob.Id, })
UnitId = potentialUnitId, .ToList();
Index = globalIndex,
Job = targetJob,
Unit = reusableTemplate.Unit,
UnitsInTemplate = unitsInTemplateSubGroup.Select(e => new UnitsInTemplate { 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 await templateAllocationService.AllocateAsync(request, ct);
{
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);
}
} }
private async Task UpdateTemplateUnitsAsync( 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() 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) 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.Entities.Unit;
using PARR.Domain.Enums; using PARR.Domain.Enums;
using PARR.Domain.Settings; using PARR.Domain.Settings;
using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.Interfaces; using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Settings; using PARR.TemplateMatcher.Settings;
@@ -40,10 +41,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
private readonly IRabbitService mqService; private readonly IRabbitService mqService;
private readonly ITemplateRepository templateService; private readonly ITemplateRepository templateService;
private readonly IJobRepository jobService; private readonly IJobRepository jobService;
private readonly ITemplateReuser templateReuser;
private readonly ITemplateDeactivator templateDeactivator; private readonly ITemplateDeactivator templateDeactivator;
private readonly ITemplateNameNormalizer templateNameNormalizer; private readonly ITemplateNameNormalizer templateNameNormalizer;
private readonly ITemplateUpdaterMqSender templateUpdaterMqSender; private readonly ITemplateAllocationService templateAllocationService;
private readonly ITemplateMqPublisher templateMqPublisher;
private readonly IMatchingStatusService matchingStatusService; private readonly IMatchingStatusService matchingStatusService;
private readonly SettingsFromDb settingsFromDb; private readonly SettingsFromDb settingsFromDb;
private readonly IOptions<TemplateSettings> templateSettings; private readonly IOptions<TemplateSettings> templateSettings;
@@ -58,10 +59,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
IRabbitService mqService, IRabbitService mqService,
ITemplateRepository templateService, ITemplateRepository templateService,
IJobRepository jobService, IJobRepository jobService,
ITemplateReuser templateReuser,
ITemplateDeactivator templateDeactivator, ITemplateDeactivator templateDeactivator,
ITemplateNameNormalizer templateNameNormalizer, ITemplateNameNormalizer templateNameNormalizer,
ITemplateUpdaterMqSender templateUpdaterMqSender, ITemplateAllocationService templateAllocationService,
ITemplateMqPublisher templateMqPublisher,
IMatchingStatusService matchingStatusService, IMatchingStatusService matchingStatusService,
SettingsFromDb settingsFromDb, SettingsFromDb settingsFromDb,
IOptions<TemplateSettings> templateSettings, IOptions<TemplateSettings> templateSettings,
@@ -76,10 +77,10 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
this.mqService = mqService; this.mqService = mqService;
this.templateService = templateService; this.templateService = templateService;
this.jobService = jobService; this.jobService = jobService;
this.templateReuser = templateReuser;
this.templateDeactivator = templateDeactivator; this.templateDeactivator = templateDeactivator;
this.templateNameNormalizer = templateNameNormalizer; this.templateNameNormalizer = templateNameNormalizer;
this.templateUpdaterMqSender = templateUpdaterMqSender; this.templateAllocationService = templateAllocationService;
this.templateMqPublisher = templateMqPublisher;
this.matchingStatusService = matchingStatusService; this.matchingStatusService = matchingStatusService;
this.settingsFromDb = settingsFromDb; this.settingsFromDb = settingsFromDb;
this.templateSettings = templateSettings; this.templateSettings = templateSettings;
@@ -209,49 +210,21 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
foreach (var unitId in newUnitIds) foreach (var unitId in newUnitIds)
{ {
var reusableTemplate = await templateReuser.TryReuseOneUnusedTemplateAsync(jobId, unitId, initiator); // Вычисляем флаги активности из конфигурации автоконтрола
if (reusableTemplate != null) var isActiveTemplate = job.AutoControl?.InitUsedTemplateState ?? DefaultUsedTemplateState;
{ var isActiveSchedule = job.AutoControl?.InitUsedScheduleState ?? DefaultUsedScheduleState;
logger.LogInformation("Переиспользован шаблон {TemplateId} для Job {JobId}, UnitId {UnitId}.", reusableTemplate.Id, jobId, unitId);
// === Создаём временный Template для нормализации имени === var request = new TemplateAllocationRequest(
var tempTemplateForName = new Template TargetJob: job,
{ TargetUnitId: unitId,
Id = reusableTemplate.Id, TargetUnit: null, // Для простого шаблона не критично, нормализатор загрузит при необходимости
Name = reusableTemplate.Name, Index: null,
JobId = jobId, UnitsInTemplate: new List<UnitInTemplateMessage>(),
UnitId = unitId, IsActiveTemplate: isActiveTemplate,
Index = reusableTemplate.Index, IsActiveSchedule: isActiveSchedule,
Job = job, // загруженный job Initiator: initiator);
Unit = reusableTemplate.Unit, // может быть null — нормально
UnitsInTemplate = new List<UnitsInTemplate>() // для простого шаблона
};
var expectedName = await templateNameNormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName); await templateAllocationService.AllocateAsync(request);
//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);
}
} }
// === Обработка существующих шаблонов (проверка имени) === // === Обработка существующих шаблонов (проверка имени) ===
@@ -280,7 +253,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона
}; };
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); await templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
} }
} }
@@ -415,7 +388,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона UnitsInTemplate = new List<UnitInTemplateMessage>() // для простого шаблона
}; };
await templateUpdaterMqSender.SendTemplateUpdateMessageAsync(updateRequest); await templateMqPublisher.PublishUpdateAsync(updateRequest);
} }
} }
} }
@@ -662,7 +635,7 @@ internal class SimpleTemplateSynchronizer : ITemplateSynchronizer
UnitsInTemplate = new List<UnitInTemplateMessage>() 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 ITemplateRepository templateService;
private readonly IJobRepository jobService; private readonly IJobRepository jobService;
private readonly ITemplateNameNormalizer namenormalizer; private readonly ITemplateNameNormalizer namenormalizer;
private readonly ITemplateUpdaterMqSender sender; private readonly ITemplateMqPublisher templateMqPublisher;
private readonly SettingsFromDb settingsFromDb; private readonly SettingsFromDb settingsFromDb;
private readonly IOptions<TemplateSettings> templateSettings; private readonly IOptions<TemplateSettings> templateSettings;
@@ -32,7 +32,7 @@ internal class TemplateDeactivator : ITemplateDeactivator
ITemplateRepository templateService, ITemplateRepository templateService,
IJobRepository jobService, IJobRepository jobService,
ITemplateNameNormalizer namenormalizer, ITemplateNameNormalizer namenormalizer,
ITemplateUpdaterMqSender sender, ITemplateMqPublisher templateMqPublisher,
SettingsFromDb settingsFromDb, SettingsFromDb settingsFromDb,
IOptions<TemplateSettings> templateSettings IOptions<TemplateSettings> templateSettings
) )
@@ -41,7 +41,7 @@ internal class TemplateDeactivator : ITemplateDeactivator
this.templateService = templateService; this.templateService = templateService;
this.jobService = jobService; this.jobService = jobService;
this.namenormalizer = namenormalizer; this.namenormalizer = namenormalizer;
this.sender = sender; this.templateMqPublisher = templateMqPublisher;
this.settingsFromDb = settingsFromDb; this.settingsFromDb = settingsFromDb;
this.templateSettings = templateSettings; this.templateSettings = templateSettings;
} }
@@ -115,7 +115,7 @@ internal class TemplateDeactivator : ITemplateDeactivator
UnitsInTemplate = new List<UnitInTemplateMessage>() UnitsInTemplate = new List<UnitInTemplateMessage>()
}; };
await sender.SendTemplateUpdateMessageAsync(updateRequest); await templateMqPublisher.PublishUpdateAsync(updateRequest);
return true; 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.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Entities; using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History; 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; using PARR.TemplateMatcher.Services.Interfaces;
namespace PARR.TemplateMatcher.Services.Implementations; namespace PARR.TemplateMatcher.Services.Implementations;
@@ -10,71 +14,105 @@ namespace PARR.TemplateMatcher.Services.Implementations;
internal class TemplateReuser : ITemplateReuser internal class TemplateReuser : ITemplateReuser
{ {
private readonly ILogger<TemplateReuser> logger; private readonly ILogger<TemplateReuser> logger;
private readonly ITemplateRepository templateService; private readonly ITemplateRepository templateRepository;
private readonly ITemplateNameNormalizer nameNormalizer;
private readonly ITemplateMqPublisher mqPublisher;
public TemplateReuser( public TemplateReuser(
ILogger<TemplateReuser> logger, ILogger<TemplateReuser> logger,
ITemplateRepository templateService) ITemplateRepository templateRepository,
ITemplateNameNormalizer nameNormalizer,
ITemplateMqPublisher mqPublisher)
{ {
this.logger = logger; this.logger = logger;
this.templateService = templateService; this.templateRepository = templateRepository;
this.nameNormalizer = nameNormalizer;
this.mqPublisher = mqPublisher;
} }
public async Task<bool> TryReuseAsync(
public async Task<Template?> TryReuseOneUnusedTemplateAsync( Job targetJob,
Guid jobId, Guid targetUnitId,
Guid unitId, Unit? targetUnit,
int? index,
List<UnitInTemplateMessage> unitsInTemplate,
bool isActiveTemplate,
bool isActiveSchedule,
HistoryInitiator initiator, HistoryInitiator initiator,
int maxAttempts = 3) CancellationToken ct = default)
{ {
const int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) for (int attempt = 1; attempt <= maxAttempts; attempt++)
{ {
ct.ThrowIfCancellationRequested();
try try
{ {
// Атомарно резервируем один шаблон через DAL // 1. Атомарный захват шаблона
var templateId = await templateService.ReserveUnusedTemplateAsync(unitId, initiator); var templateId = await templateRepository.ReserveUnusedTemplateAsync(targetUnitId, initiator);
if (templateId == null) if (templateId == null)
{ {
logger.LogDebug("Нет доступных Unused-шаблонов для переиспользования (попытка {Attempt}).", attempt); logger.LogDebug("Нет доступных Unused-шаблонов (попытка {Attempt}).", attempt);
return null; return false; // Шаблоны закончились
} }
// Загружаем зарезервированный шаблон // Загружаем захваченный шаблон
var template = await templateService.Get() var template = await templateRepository.Get()
.AsNoTracking() .AsNoTracking()
.Include(t => t.Unit) .Include(t => t.Unit)
.Include(t => t.Job) .FirstOrDefaultAsync(t => t.Id == templateId, ct);
.ThenInclude(t => t!.Tnk)
.Include(t => t.Job)
.ThenInclude(t => t!.Group)
.ThenInclude(t => t!.GroupType)
.FirstOrDefaultAsync(t => t.Id == templateId);
if (template == null) if (template == null)
{ {
logger.LogWarning("Зарезервированный шаблон {TemplateId} не найден при загрузке.", templateId); logger.LogWarning("Зарезервированный шаблон {TemplateId} не найден.", templateId);
continue; continue; // Попробовать еще раз
} }
logger.LogInformation( logger.LogInformation("Шаблон {TemplateId} захвачен для переиспользования.", templateId);
"Успешно захвачен шаблон {TemplateId} (старый Job {OldJobId}) для нового Job {NewJobId}, Unit {UnitId} (попытка {Attempt}).",
template.Id, template.JobId, jobId, unitId, attempt);
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) catch (Exception ex)
{ {
logger.LogError(ex, "Ошибка при попытке захвата шаблона (попытка {Attempt}).", attempt); logger.LogError(ex, "Ошибка при переиспользовании (попытка {Attempt}).", attempt);
if (attempt == maxAttempts) return false; // После 3 попыток сдаемся
if (attempt == maxAttempts) await Task.Delay(Random.Shared.Next(10, 50), ct);
throw;
// Небольшая задержка перед повтором
await Task.Delay(Random.Shared.Next(10, 50));
} }
} }
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.Base.History;
using PARR.Domain.Entities.Job;
using PARR.Domain.Entities.Unit;
namespace PARR.TemplateMatcher.Services.Interfaces namespace PARR.TemplateMatcher.Services.Interfaces
{ {
/// <summary>
/// Отвечает за полный цикл переиспользования Unused-шаблона:
/// 1. Атомарный захват в БД (с retry).
/// 2. Нормализация имени.
/// 3. Отправка команды на обновление (активацию) в MQ.
/// </summary>
public interface ITemplateReuser public interface ITemplateReuser
{ {
/// <summary> /// <summary>
/// Пытается переиспользовать неиспользуемый шаблон для указанной работы и ЭК. /// Пытается переиспользовать шаблон. Возвращает true, если успешно захвачен и отправлена команда.
/// При резервировании шаблона для правильного расчета NextRun Id работы
/// и ЭК устанавливается при захвате.
/// </summary> /// </summary>
/// <param name="jobId">Id работы.</param> Task<bool> TryReuseAsync(
/// <param name="unitId">Id ЭК.</param> Job targetJob,
/// <param name="initiator">Данные инициатора операции.</param> Guid targetUnitId,
/// <param name="maxAttempts">Максимальное количество попыток (по умолчанию 3).</param> Unit? targetUnit,
/// <returns> int? index,
/// Зарезервированный шаблон или <see langword="null"/>, List<UnitInTemplateMessage> unitsInTemplate,
/// если не удалось получить шаблон после всех попыток. bool isActiveTemplate,
/// </returns> bool isActiveSchedule,
Task<Template?> TryReuseOneUnusedTemplateAsync(
Guid jobId,
Guid unitId,
HistoryInitiator initiator, 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); configuration.GetSection(nameof(TemplateSettings)).Bind(templateSettings);
services.AddSingleton(templateSettings); services.AddSingleton(templateSettings);
services.AddTransient<IMqTemplateMatcher, MqTemplateMatcher>(); // === 1. Singleton: Долгоживущие сервисы и обработчики очередей ===
services.AddTransient<IJobValidatorService, JobValidatorService>(); services.AddSingleton<IMqTemplateMatcher, MqTemplateMatcher>();
services.AddTransient<IJobGroupValidatorService, JobGroupValidatorService>();
services.AddTransient<ITemplateMatcher, TemplateMatcher>(); // === 2. Scoped: Бизнес-логика и работа с БД (DbContext) ===
services.AddTransient<ITemplateReuser, TemplateReuser>(); // Создаются заново для каждого сообщения из очереди (внутри 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<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) public static IConfigurationBuilder AddTemplateMatcherConfigurations(this IConfigurationBuilder builder, IServiceCollection services)