13 Commits

Author SHA1 Message Date
Mikhail Kuznetsov
8c7d041a8b feat(shortcodesService): шорт-код поиска родительского ЭК по атрибутам теперь работает по множеству условий 2026-10-28 10:56:07 +10:00
Mikhail Trubnikov
236ddee5ef feat(api,core,dal,domain,esppSync): В группу работ добавлено поле EkMask, обновлена логика синхронизации шаблонов и выдачи задания роботу. Обновлен реквест и респонс для группы работ и шаблонов. 2026-10-21 11:21:36 +10:00
Mikhail Kuznetsov
fdfd0f9363 feat(shortcodesService): обновлена информация о доступных шорт-кодах для API 2026-10-15 15:15:19 +10:00
Mikhail Kuznetsov
393f043286 feat(shortcodesService): в шорт-код "РОД_ЭК_ПЕРВЫЙ" реализована возможность поиска по имени и маске атрибута 2026-10-15 14:51:50 +10:00
Mikhail Kuznetsov
5c3b273346 fix(shortcodesService): Исправлена сортировка родителей для тега %РОД_ЭК_ПЕРВЫЙ% 2026-10-15 11:33:40 +10:00
Mikhail Kuznetsov
0fcbe870b9 feat(shortcodesService): добавлен шорткод %РОД_ЭК_ПЕРВЫЙ:ОУ-*:КРАТКОЕ_НАИМЕНОВАНИЕ%. - Позволяет получать значения атрибутов родительских ЭК.
- Родитель определяется по маске, выбирается первый найденный, отсторитрованный по алфавиту
- Имя атрибута определяется по полю AihitName
2026-10-15 10:39:33 +10:00
Mikhail Kuznetsov
f9f234d119 refactor(templateMatcher):
- унификация деактивации шаблонов между разными синхронизаторами
- поиск ЭК выделен в отдельную сущность
2026-10-14 14:28:58 +10:00
Mikhail Trubnikov
394fc059ba feat(api): HelpDocuments - ссылка на редактирование документации 2026-10-13 08:52:33 +10:00
Mikhail Trubnikov
1b02bf63bf feat(api): HelpDocument - ссылки вперед и назад 2026-10-12 16:34:48 +10:00
Mikhail Trubnikov
09d8244b03 feat(api,core,domain): HelpDocument - ответ заглушка, если нет еще такой документации 2026-10-12 15:13:14 +10:00
Mikhail Trubnikov
4e81ee72f4 fix(api): HelpDocumentController - исправлен хук удаления кэша 2026-10-12 14:38:03 +10:00
Mikhail Trubnikov
04d9843cfb feat(api,core,domain): HelpDocumentations - методы по получению и кэшированию документации из 2026-10-12 14:14:30 +10:00
Mikhail Kuznetsov
9f5bae3f32 fix(templateMatcher): возвращена логика деактивации всего если фильтры вернули пустой результат. 2026-10-09 14:20:27 +10:00
65 changed files with 6120 additions and 246 deletions

View File

@@ -615,6 +615,17 @@ namespace PARR.API.Contracts.V1
#endregion
#region HelpDocument
public static class HelpDocument
{
public const string GetDocument = Base + "/help-documents/get-document";
public const string GitLabWebhook = Base + "/help-documents/modified";
}
#endregion
}
}

View File

@@ -36,6 +36,14 @@
public Guid? ScheduleExcludeTypeCalendarId { get; init; }
/// <summary>
/// Маска ЭК
/// </summary>
public required string EkMask { get; init; }
/// <summary>
/// Автораспределение включено
/// </summary>
public bool IsAutoDistributionEnabled { get; init; }
/// <summary>

View File

@@ -0,0 +1,33 @@
namespace PARR.API.Contracts.V1.Responses
{
public record HelpDocumentResponse
{
/// <summary>
/// Уникальный ID (например: "ui/billing/invoice-create/vat-calc")
/// </summary>
public string HelpId { get; init; } = string.Empty;
/// <summary>
/// Заголовок из метаданных файла (title)
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// // Чистый Markdown-текст (все, что идет после метаданных)
/// </summary>
public string Content { get; init; } = string.Empty;
/// <summary>
/// ИД для перехода назад
/// </summary>
public string? PrevId { get; init; }
/// <summary>
/// ИД для перехода вперед
/// </summary>
public string? NextId { get; init; }
public string? EditUrl { get; init; }
}
}

View File

@@ -55,6 +55,11 @@
/// Юнит фильтры
/// </summary>
public List<UnitFilterResponse>? UnitFilters { get; set; }
/// <summary>
/// Маска ЭК
/// </summary>
public required string EkMask { get; set; }
}
public class JobGroupResponse : JobGroupWithDistributionConfigResponse

View File

@@ -33,6 +33,14 @@
/// </summary>
public string? ResponseArea { get; set; }
/// <summary>
/// ЭК согласно настроек маски группы работ, подставляется в шаблон ЕСПП
/// </summary>
public required string EsppEk { get; set; }
/// <summary>
/// Юнит привязанный к шаблону
/// </summary>
public UnitWithAttributesResponse? Unit { get; set; }
public TemplateStatusTypeResponse? StatusType { get; set; }

View File

@@ -0,0 +1,173 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Exceptions;
using PARR.Domain.Settings;
using System.Text.Json.Nodes;
namespace PARR.API.Controllers.V1
{
/// <summary>
/// Контроллер по работе со справочной документацией
/// </summary>
public class HelpDocumentController : BaseApiController
{
private readonly IHelpDocumentService _helpDocumentService;
private readonly IMapper _mapper;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<HelpDocumentController> _logger;
public HelpDocumentController(
IHelpDocumentService helpDocumentService,
IMapper mapper,
HelpDocumentSettings helpDocumentSettings,
ILogger<HelpDocumentController> logger
)
{
_helpDocumentService = helpDocumentService;
_mapper = mapper;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
}
/// <summary>
/// Получить документ справки
/// </summary>
/// <returns></returns>
/// <param name="helpId">Путь к справке (например: ui/billing/invoice-create/vat-calc)</param>
[Authorize(Roles = ParrRoles.Administrator.Role)]
[HttpGet(ApiRoutes.HelpDocument.GetDocument)]
public async Task<IActionResult> GetHelpDocument([FromQuery] string helpId)
{
if (string.IsNullOrWhiteSpace(helpId))
throw new ArgumentNullException(nameof(helpId), "Значение не может быть пустым");
var documnet = await _helpDocumentService.GetHelpDocumentAsync(helpId);
if (documnet == null)
return NotFound();
var response = _mapper.Map<HelpDocumentResponse>(documnet);
return Ok(new Response<HelpDocumentResponse>(response, true));
}
/// <summary>
/// Вебхук от GitLab для автоматического сброса кэша при пуше изменений
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpPost(ApiRoutes.HelpDocument.GitLabWebhook)]
public async Task<IActionResult> GitLabWebhook([FromBody] JsonNode payload)
{
_logger.LogInformation("Получен Webhook запрос от GitLab.");
#region Проверка безопасности
// GitLab передает токен в заголовке X-Gitlab-Token
if (!Request.Headers.TryGetValue("X-Gitlab-Token", out var receivedToken))
{
_logger.LogWarning("Запрос вебхука отклонен. Отсутствует заголовок X-Gitlab-Token.");
throw new UnauthorizedException("Отсутствует авторизационный токен вебхука.");
}
if (receivedToken.ToString() != _helpDocumentSettings.WebhookToken)
{
_logger.LogWarning("Запрос вебхука отклонен. Передан невалидный Secret Token.");
throw new UnauthorizedException("Неверный токен вебхука.");
}
#endregion
_logger.LogDebug("Проверка Secret Token пройдена успешно. Начинается обработка изменений.");
try
{
// Проверяем, что это событие push
var objectKind = payload["object_kind"]?.ToString();
if (objectKind != "push")
{
_logger.LogDebug("Игнорируем вебхук: тип события '{ObjectKind}' не является 'push'.", objectKind);
return Ok(new { message = "Ignored non-push event" });
}
// Извлекаем массив коммитов
var commits = payload["commits"]?.AsArray();
if (commits == null || commits.Count == 0)
{
_logger.LogDebug("В вебхуке отсутствуют коммиты.");
return Ok(new { message = "No commits found" });
}
var filesToInvalidate = new HashSet<string>();
// Пробегаемся по коммитам и собираем измененные/добавленные файлы
foreach (var commit in commits)
{
var modifiedFiles = commit?["modified"]?.AsArray();
if (modifiedFiles != null)
{
foreach (var file in modifiedFiles)
{
var filePath = file?.ToString();
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
filesToInvalidate.Add(filePath);
}
}
}
var addedFiles = commit?["added"]?.AsArray();
if (addedFiles != null)
{
foreach (var file in addedFiles)
{
var filePath = file?.ToString();
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
filesToInvalidate.Add(filePath);
}
}
}
}
if (filesToInvalidate.Count == 0)
{
_logger.LogDebug("Среди измененных файлов в коммитах нет документов .md. Кэш не изменен.");
return Ok(new { message = "No .md files modified" });
}
_logger.LogInformation("Вебхук зафиксировал изменения в {Count} файлах справок. Начинается сброс кэша...", filesToInvalidate.Count);
// Инвалидируем кэш для каждого измененного файла
foreach (var file in filesToInvalidate)
{
// Превращаем путь "ui/billing/vat-calc.md" обратно в helpId "ui/billing/vat-calc"
var helpId = file.Substring(0, file.Length - 3);
_logger.LogDebug("Сброс кэша по вебхуку для HelpId: {HelpId}", helpId);
await _helpDocumentService.ClearCacheAsync(helpId);
}
_logger.LogInformation("Сброс кэша по вебхуку GitLab успешно завершен.");
return Ok(new { clearedFilesCount = filesToInvalidate.Count });
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при обработке Webhook от GitLab");
// Возвращаем 200 или 500 в зависимости от политики.
// Обычно возвращают BadRequest/InternalServerError, чтобы в GitLab в истории вебхуков была видна ошибка отладки.
throw new ExternalServiceException("Внутренняя ошибка обработки вебхука");
}
}
}
}

View File

@@ -184,7 +184,8 @@ namespace PARR.API.Controllers.V1
IsAutoDistributionEnabled = request.IsAutoDistributionEnabled,
IsResponseAreaTimezone = request.IsWorkGroupTimezone,
IsGroupByResponsible = request.IsGroupByResponsible,
UnitFilters = new List<JobGroupUnitFilter>()
UnitFilters = new List<JobGroupUnitFilter>(),
EkMask = request.EkMask,
//IsAgent = request.IsAgent,
//AgentName = request.AgentName,
//AgentTimeOutSec = request.AgentTimeOutSec,
@@ -319,6 +320,7 @@ namespace PARR.API.Controllers.V1
orig.IsResponseAreaTimezone = request.IsWorkGroupTimezone;
// IsGroupByResponsible - может быть значение, только если тип ГРУППОВОЙ
orig.IsGroupByResponsible = await GetIsGroupByResponsibleValue(request);
orig.EkMask = request.EkMask;
//orig.IsAgent = request.IsAgent;
//orig.AgentName = request.AgentName;
//orig.AgentTimeOutSec = request.AgentTimeOutSec;

View File

@@ -12,7 +12,6 @@ using PARR.API.Extensions;
using PARR.API.Services.Interfaces;
using PARR.Core.Common.Helpers;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Core.Services.Shortcodes;
using PARR.Domain.Common.Pagination;
using PARR.Domain.Common.Roles;
@@ -292,6 +291,7 @@ namespace PARR.API.Controllers.V1
{
response.WorkGroup = await _shortcodesService.ApplyShortcodesAsync(template.Job!.WorkGroupMask, template);
response.ResponseArea = await _shortcodesService.ApplyShortcodesAsync(template.Job!.ResponseAreaMask, template);
response.EsppEk = await _shortcodesService.ApplyShortcodesAsync(template.Job!.Group!.EkMask, template);
}

View File

@@ -1,4 +1,5 @@
using PARR.API.Settings;
using PARR.Domain.Settings;
namespace PARR.API.Installers
{
@@ -27,6 +28,10 @@ namespace PARR.API.Installers
configuration.GetSection(nameof(CommonSettings)).Bind(commonSettings);
services.AddSingleton(commonSettings);
var helpDocumentSettings = new HelpDocumentSettings();
configuration.GetSection(nameof(HelpDocumentSettings)).Bind(helpDocumentSettings);
services.AddSingleton(helpDocumentSettings);
//TODO: add other
}
}

View File

@@ -5,6 +5,7 @@ using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Statistics;
using PARR.API.MappingProfiles.Resolvers;
using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.DTOs.Matching;
using PARR.Domain.DTOs.RobotMetrics;
using PARR.Domain.DTOs.RobotSnapshotDTO;
@@ -390,7 +391,7 @@ namespace PARR.API.MappingProfiles
.ForMember(d => d.GroupingUnitField, o => o.MapFrom(s => s.GroupingUnitField))
.ForMember(d => d.IsWorkGroupTimezone, o => o.MapFrom(s => s.IsResponseAreaTimezone))
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl))
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t=>t.UnitFilter)));
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t => t.UnitFilter)));
CreateMap<JobGroup, JobGroupBaseResponse>()
.ForMember(d => d.Name, o => o.MapFrom(s => s.GroupName))
@@ -565,6 +566,12 @@ namespace PARR.API.MappingProfiles
#endregion
#region HelpDocumentResponse
CreateMap<HelpDocumentResult, HelpDocumentResponse>();
#endregion
}
}

View File

@@ -153,6 +153,9 @@ namespace PARR.API.Validators
return false;
})
.WithMessage("Некорректные параметры автоконтроля");
RuleFor(t => t.EkMask)
.NotNull().NotEmpty();
}
}
}

View File

@@ -118,5 +118,17 @@
},
"CommonSettings": {
"ScheduleCooldownDuration": "03:00:00"
},
"HelpDocumentSettings": {
"GitLabSettings": {
"BaseUrl": "http://gitlab.dvgd.oao.rzd/api/v4/",
"ProjectId": "1343",
"Token": "glpat-2PBp1c9hryQHUJnoZn3S",
"DefaultBranch": "main",
"RepositoryPath": "http://gitlab.dvgd.oao.rzd/devptk/parr/parr_help_docs"
},
"CacheTTL": "23:59:00",
"ShortTTL": "00:10:00",
"WebhookToken": "ParrSecret2026"
}
}

View File

@@ -117,7 +117,8 @@ public class JobGroupCloneHelper
ScheduleExcludeTypeCalendarId = source.ScheduleExcludeTypeCalendarId,
DateCreated = DateTimeOffset.UtcNow,
Jobs = new HashSet<Job>(),
EsppSchValues = new HashSet<EsppSchValue>()
EsppSchValues = new HashSet<EsppSchValue>(),
EkMask = source.EkMask
};
}

View File

@@ -5,6 +5,8 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using PARR.Core.Common.Helpers;
using PARR.Core.Common.Implementations;
using PARR.Core.Common.Interfaces;
using PARR.Core.Services.HelpDocument.Implementations;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Core.Services.MatchingStatusService;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.NextRunServices.Subservices;
@@ -107,6 +109,8 @@ namespace PARR.Core
services.AddScoped<IShortcodeHandler, FieldShortcodeHandler>();
services.AddScoped<IShortcodeHandler, TagShortcodeHandler>();
services.AddScoped<IShortcodeHandler, RelatedUnitTagShortcodeHandler>();
services.AddScoped<IShortcodeHandler, ParentUnitByNameShortcodeHandler>();
services.AddScoped<IShortcodeHandler, ParentUnitByFieldValueShortcodeHandler>();
services.AddScoped<IShortcodesService, ShortcodesService>();
#endregion
@@ -174,6 +178,16 @@ namespace PARR.Core
#endregion
#region
// Добавляем дефолтные настройки, при необходимости переопределить в воркерах (API)
services.TryAddSingleton<HelpDocumentSettings>();
services.AddHttpClient<IHelpDocumentProvider, GitLabHelpProvider>();
services.AddScoped<IHelpDocumentService, HelpDocumentService>();
#endregion
return services;
}

View File

@@ -32,7 +32,9 @@ namespace PARR.Core.Infrastructure.Mapping.RobotTask
//.ForMember(d => d.Initiator, o => o.MapFrom<RobotTaskTemplateInitiatorResolver>())
//.ForMember(d => d.Category, o => o.MapFrom<RobotTaskTemplateCategoryResolver>())
.ForMember(d => d.WorkGroup, o => o.MapFrom(s => s.Template!.Job!.WorkGroupMask))
.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Unit!.Name))
//.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Unit!.Name))
// Берем ЭК на у юнита, а в группе работ, маску ЭК (шорткод)
.ForMember(d => d.Ek, o => o.MapFrom(s => s.Template!.Job!.Group!.EkMask))
.ForMember(d => d.Name, o => o.MapFrom(s => s.Template!.Name))
.ForMember(d => d.ProcessName, o => o.MapFrom(s => s.Template!.Job!.Tnk!.Subprocess!.Process!.Name))
.ForMember(d => d.ProcessEsppId, o => o.MapFrom(s => s.Template!.Job!.Tnk!.Subprocess!.Process!.EsppId))

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.16" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.16" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.16" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.Settings;
using System.Web;
namespace PARR.Core.Services.HelpDocument.Implementations
{
/// <summary>
/// Получение справочной документации из GitLab
/// </summary>
internal class GitLabHelpProvider : IHelpDocumentProvider
{
private readonly HttpClient _httpClient;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<GitLabHelpProvider> _logger;
public GitLabHelpProvider(HttpClient httpClient, HelpDocumentSettings helpDocumentSettings, ILogger<GitLabHelpProvider> logger)
{
_httpClient = httpClient;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
// Базовая настройка http
_httpClient = httpClient;
if (_helpDocumentSettings.GitLabSettings == null)
{
_logger.LogCritical("Инициализация провайдера справки провалена: не задана секция GitLabSettings");
throw new ArgumentNullException(nameof(_helpDocumentSettings.GitLabSettings), "Не заданы настройки подключения к GitLab");
}
_httpClient.BaseAddress = new Uri(_helpDocumentSettings.GitLabSettings.BaseUrl);
_httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", _helpDocumentSettings.GitLabSettings.Token);
}
public async Task<HelpDocumentResult?> GetDocumentAsync(string helpId)
{
//1. Формируем путь к файлу
var filePath = $"{helpId}.md";
// 2. GitLab API требует, чтобы относительный путь к файлу в URL был закодирован (экранирован)
var urlEncodedPath = HttpUtility.UrlEncode(filePath);
// 3. Собираем эндпоинт для получения RAW контента файла
string relativeRequestUrl = $"projects/{_helpDocumentSettings.GitLabSettings!.ProjectId}/repository/files/{urlEncodedPath}/raw?ref={_helpDocumentSettings.GitLabSettings.DefaultBranch}";
try
{
// Создаем URI строго с флагом UriKind.Relative. Это заставит HttpClient отправить %2F в сыром виде!
var relativeUri = new Uri(relativeRequestUrl, UriKind.Relative);
_logger.LogDebug("Отправка HTTP GET запроса к GitLab API. Относительный URI: {RelativeUri}", relativeRequestUrl);
var response = await _httpClient.GetAsync(relativeUri);
// Если файла нет (404) или токен не подошел (401/403)
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Документ справки не найден или недоступен в GitLab. HelpId: {HelpId}, StatusCode: {StatusCode}",
helpId, response.StatusCode);
return null;
}
_logger.LogDebug("Файл справки успешно получен из GitLab. Размер ответа: {ContentLength} байт",
response.Content.Headers.ContentLength);
// Получаем весь текст файла (и шапку, и контент)
string rawFileContent = await response.Content.ReadAsStringAsync();
// 4. Парсим файл
var result = ParseMarkdownFile(helpId, rawFileContent);
_logger.LogDebug("Документ справки успешно загружен и распарсен. HelpId: {HelpId}, Title: '{Title}'",
helpId, result.Title);
var editUrl = GenerateEditUrl(helpId);
return result with
{
EditUrl = editUrl
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при запросе справки {HelpId} из GitLab", helpId);
return null;
}
}
private string? GenerateEditUrl(string helpId)
{
if (_helpDocumentSettings.GitLabSettings == null)
return null;
var repoPath = _helpDocumentSettings.GitLabSettings.RepositoryPath;
var branch = _helpDocumentSettings.GitLabSettings.DefaultBranch ?? "main";
return $"{repoPath}/-/edit/{branch}/{helpId}.md";
}
private HelpDocumentResult ParseMarkdownFile(string helpId, string rawContent)
{
_logger.LogDebug("Начало парсинга структуры Markdown для HelpId: {HelpId}", helpId);
if (string.IsNullOrWhiteSpace(rawContent))
{
_logger.LogWarning("Получен пустой контент для HelpId: {HelpId}", helpId);
return new HelpDocumentResult { HelpId = helpId };
}
// Стандартное разделение строк с поддержкой разных типов окончаний (Windows/Linux)
var lines = rawContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
var title = string.Empty;
string? prevId = null;
string? nextId = null;
var contentStartIndex = 0;
var isInsideFrontMatter = false;
var frontMatterClosed = false;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// Ищем маркеры начала и конца метаданных "---"
if (line == "---")
{
if (!isInsideFrontMatter && !frontMatterClosed)
{
isInsideFrontMatter = true;
_logger.LogDebug("Обнаружено начало блока метаданных (Front Matter) на строке {LineIndex}", i);
continue;
}
else if (isInsideFrontMatter)
{
isInsideFrontMatter = false;
frontMatterClosed = true;
contentStartIndex = i + 1; // Контент начинается строго со следующей строки
_logger.LogDebug("Обнаружен конец блока метаданных на строке {LineIndex}", i);
break;
}
}
// Читаем свойства внутри блока метаданных
if (isInsideFrontMatter)
{
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
{
title = line.Substring("title:".Length).Trim();
_logger.LogDebug("В метаданных найден Title: '{Title}'", title);
}
// Парсим Предыдущий шаг
else if (line.StartsWith("prev_id:", StringComparison.OrdinalIgnoreCase))
{
prevId = line.Substring("prev_id:".Length).Trim();
_logger.LogDebug("В метаданных найден PrevId: '{PrevId}'", prevId);
}
// Парсим Следующий шаг
else if (line.StartsWith("next_id:", StringComparison.OrdinalIgnoreCase))
{
nextId = line.Substring("next_id:".Length).Trim();
_logger.LogDebug("В метаданных найден NextId: '{NextId}'", nextId);
}
// Тут в будущем можно парсить другие поля, например: icon, min_role и т.д.
}
}
string cleanContent;
if (frontMatterClosed)
{
// Собираем обратно чистый контент, исключая строки с шапкой
cleanContent = string.Join(Environment.NewLine, lines.Skip(contentStartIndex));
}
else
{
// Если шапки "---" вообще не было в файле, берем весь текст как есть
_logger.LogDebug("Блок метаданных '---' не найден в файле. Весь текст перенесен в Content.");
cleanContent = rawContent;
title = Path.GetFileNameWithoutExtension(helpId); // Дефолтный заголовок из имени файла
}
return new HelpDocumentResult
{
HelpId = helpId,
Title = title,
Content = cleanContent,
PrevId = prevId,
NextId = nextId
};
}
}
}

View File

@@ -0,0 +1,104 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.Cache;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.Settings;
namespace PARR.Core.Services.HelpDocument.Implementations
{
internal class HelpDocumentService : IHelpDocumentService
{
private readonly IHelpDocumentProvider _helpProvider;
private readonly IRedisCacheService _redisCacheService;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<HelpDocumentService> _logger;
public HelpDocumentService(
IHelpDocumentProvider helpProvider,
IRedisCacheService redisCacheService,
HelpDocumentSettings helpDocumentSettings,
ILogger<HelpDocumentService> logger
)
{
_helpProvider = helpProvider;
_redisCacheService = redisCacheService;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
}
public async Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId)
{
if (string.IsNullOrEmpty(helpId))
{
_logger.LogWarning("Значение helpId: '{helpId}' пустое.", helpId);
return null;
}
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
_logger.LogDebug("Запрос справки. Проверка наличия в Redis по ключу: {CacheKey}", cacheKey);
// 1. Попытка получить данные из Redis
var cachedData = await _redisCacheService.GetCachedDataAsync<HelpDocumentResult?>(cacheKey, useCompression: true);
if (cachedData != null)
{
_logger.LogInformation("Документ справки успешно извлечен из кэша Redis. HelpId: {HelpId}", helpId);
return cachedData;
}
_logger.LogDebug("Кэш пуст или недоступен. Запрос документа напрямую через провайдер для HelpId: {HelpId}", helpId);
// 2. Если в Redis пусто, идем в GitLab через наш провайдер
var remoteDocument = await _helpProvider.GetDocumentAsync(helpId);
if (remoteDocument == null)
{
_logger.LogWarning("Документ не найден ни в Redis, ни в GitLab. HelpId: {HelpId}", helpId);
// Создаем понятный для пользователя текст-заглушку если нет документации
remoteDocument = new HelpDocumentResult
{
HelpId = helpId,
Title = "Раздел в разработке",
Content = "### 📚 Отсутствует документация по данному разделу.\n\n" +
"Если у вас возникли срочные вопросы, свяжитесь с теми, кто знает на них ответы :)."
};
// Кэшируем заглушку на короткий срок (например, на 5-10 минут), а не на 24 часа.
// Это нужно, чтобы как только аналитик создаст файл и прилетит хук, кэш обновился,
// но при этом частые клики пользователей по сломанному вопросику не спамили GitLab запросами 404.
await _redisCacheService.SetCachedDataAsync(cacheKey, remoteDocument, _helpDocumentSettings.ShortTTL, useCompression: true);
return remoteDocument;
}
// 3. Сохраняем полученный документ в Redis для последующих запросов
await _redisCacheService.SetCachedDataAsync(cacheKey, remoteDocument, _helpDocumentSettings.CacheTTL, useCompression: true);
_logger.LogDebug("Вернул документацию из провайдера для HelpId: {HelpId}", helpId);
return remoteDocument;
}
public async Task ClearCacheAsync(string helpId)
{
if (string.IsNullOrEmpty(helpId))
{
_logger.LogWarning("Попытка очистки кэша: передан пустой helpId.");
return;
}
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
_logger.LogInformation("Удаление документа справки из кэша Redis по вебхуку. Ключ: {CacheKey}", cacheKey);
await _redisCacheService.DeleteCachedDataAsync(cacheKey);
}
}
}

View File

@@ -0,0 +1,14 @@
using PARR.Domain.DTOs.HelpDocument;
namespace PARR.Core.Services.HelpDocument.Interfaces
{
internal interface IHelpDocumentProvider
{
/// <summary>
/// Запрашивает сырой файл .md из источника (например gitlab)
/// </summary>
/// <param name="helpId"></param>
/// <returns></returns>
Task<HelpDocumentResult?> GetDocumentAsync(string helpId);
}
}

View File

@@ -0,0 +1,24 @@
using PARR.Domain.DTOs.HelpDocument;
namespace PARR.Core.Services.HelpDocument.Interfaces
{
/// <summary>
/// Сервис загрузки справочной документации
/// </summary>
public interface IHelpDocumentService
{
/// <summary>
/// Получить документ справки по его уникальному идентификатору (с учетом кэширования в Redis)
/// </summary>
/// <param name="helpId">Уникальный путь справке</param>
/// <returns></returns>
Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId);
/// <summary>
/// Сбрасывает кэш в Redis для конкретного документа справки (вызывается вебхуком)
/// </summary>
/// <param name="helpId"></param>
/// <returns></returns>
Task ClearCacheAsync(string helpId);
}
}

View File

@@ -70,6 +70,7 @@ namespace PARR.Core.Services.RobotTask.Implementations
task = task with { WorkName = await _shortcodesService.ApplyShortcodesAsync(task.WorkName, templateTask.Template!) };
task = task with { WorkGroup = await _shortcodesService.ApplyShortcodesAsync(task.WorkGroup, templateTask.Template!) };
task = task with { ResponseArea = await _shortcodesService.ApplyShortcodesAsync(task.ResponseArea, templateTask.Template!) };
task = task with { Ek = await _shortcodesService.ApplyShortcodesAsync(task.Ek, templateTask.Template!) };
task = task with { ClosingCode = _settingsFromDb.ClosingCode };
task = task with { Initiator = _settingsFromDb.Initiator };

View File

@@ -11,6 +11,7 @@
UnitName = 16,
UnitsInTemplate = 32,
UnitTags = 64,
UnitsInTemplateTags = 128
UnitsInTemplateTags = 128,
ParentUnits = 256
}
}

View File

@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.Shortcodes.Enums;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
/// <summary>
/// Шорткод %РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:АТРИБУТ1:МАСКА1&АТРИБУТ2:МАСКА2>>АТРИБУТ_РЕЗУЛЬТАТА%
/// Возвращает значение атрибута первого ближайшего родителя, удовлетворяющего всем условиям фильтрации.
/// При неверном синтаксисе выбрасывает InvalidOperationException.
/// </summary>
internal class ParentUnitByFieldValueShortcodeHandler : IShortcodeHandler
{
private readonly ILogger<ParentUnitByFieldValueShortcodeHandler> _logger;
public int Order => 56;
// Паттерн: захватываем все содержимое между ключевым словом и конечным %
private static readonly Regex _pattern = new(
@"%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:(?<payload>[^%]+)%",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Regex Pattern => _pattern;
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.ParentUnits;
public ParentUnitByFieldValueShortcodeHandler(ILogger<ParentUnitByFieldValueShortcodeHandler> logger)
{
_logger = logger;
}
public Task<string> ResolveAsync(string input, TemplateForShortcode template, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return Task.FromResult(input);
var uniquePayloads = matches.Cast<Match>()
.Select(m => m.Groups["payload"].Value.Trim())
.Distinct()
.ToList();
var resolvedValues = new Dictionary<string, string>();
foreach (var payload in uniquePayloads)
{
// Здесь может быть выброшено исключение при неверном синтаксисе
var result = ProcessPayload(template, payload, caller);
resolvedValues[payload] = result;
}
var output = input;
foreach (Match m in matches)
{
var payload = m.Groups["payload"].Value.Trim();
if (resolvedValues.TryGetValue(payload, out var val))
{
output = output.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
}
}
return Task.FromResult(output);
}
private string ProcessPayload(TemplateForShortcode template, string payload, string caller)
{
// 1. Проверка наличия разделителя '>>'
var separatorIndex = payload.IndexOf(">>", StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= payload.Length - 2)
{
throw new InvalidOperationException(
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
$"Отсутствует или расположен неправильно разделитель '>>' в выражении: '{payload}'. " +
$"Ожидаемый формат: АТРИБУТ:МАСКА>>АТРИБУТ_РЕЗУЛЬТАТА");
}
string conditionsStr = payload.Substring(0, separatorIndex).Trim();
string resultFieldName = payload.Substring(separatorIndex + 2).Trim();
// 2. Валидация имени результирующего поля
if (string.IsNullOrEmpty(resultFieldName))
{
throw new InvalidOperationException(
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
$"Имя результирующего атрибута после '>>' не может быть пустым. Выражение: '{payload}'");
}
// 3. Парсинг условий
var conditions = ParseConditions(conditionsStr);
if (conditions.Count == 0)
{
throw new InvalidOperationException(
$"Неверный синтаксис шорткода '%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ...'. " +
$"Не найдено ни одного валидного условия фильтрации перед '>>'. Выражение: '{payload}'");
}
// 4. Поиск подходящего родителя (Бизнес-логика: если не нашли - это НЕ ошибка синтаксиса, а отсутствие данных)
if (template.ParentUnits == null || template.ParentUnits.Count == 0)
{
_logger.LogDebug("[{Caller}] Список родителей пуст для шаблона.", caller);
return "Нет данных";
}
var matchingParent = template.ParentUnits.FirstOrDefault(p =>
{
foreach (var cond in conditions)
{
if (!p.FieldValues.TryGetValue(cond.AttributeName, out var currentValue))
return false;
if (!MatchesMask(currentValue, cond.Mask))
return false;
}
return true;
});
if (matchingParent == null)
{
_logger.LogDebug("[{Caller}] Не найден родитель, подходящий под условия: {Payload}", caller, payload);
return "Нет данных";
}
// 5. Извлечение значения
if (matchingParent.FieldValues.TryGetValue(resultFieldName, out var finalValue))
{
return finalValue;
}
_logger.LogDebug("[{Caller}] Родитель найден, но атрибут '{Field}' отсутствует у него.", caller, resultFieldName);
return "Нет данных";
}
private List<(string AttributeName, string Mask)> ParseConditions(string raw)
{
var list = new List<(string, string)>();
if (string.IsNullOrEmpty(raw)) return list;
var pairs = raw.Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var pair in pairs)
{
var parts = pair.Split(':', 2);
// Строгая проверка формата пары "Ключ:Значение"
if (parts.Length != 2 ||
string.IsNullOrWhiteSpace(parts[0]) ||
string.IsNullOrWhiteSpace(parts[1]))
{
// Если условие битое, считаем весь запрос синтаксически неверным
throw new InvalidOperationException(
$"Неверный формат условия фильтрации: '{pair}'. Ожидается 'АТРИБУТ:МАСКА'.");
}
list.Add((parts[0].Trim(), parts[1].Trim()));
}
return list;
}
private static bool MatchesMask(string value, string mask)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(mask))
return false;
if (mask.Contains('*'))
{
var regexPattern = "^" + Regex.Escape(mask).Replace("\\*", ".*") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
return string.Equals(value, mask, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.Shortcodes.Enums;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
/// <summary>
/// Шорткод %РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:МАСКА_ИМЕНИ:ИМЯ_АТРИБУТА%
/// Возвращает значение атрибута первого ближайшего родителя, чье имя подходит под маску
/// </summary>
internal class ParentUnitByNameShortcodeHandler : IShortcodeHandler
{
private readonly ILogger<ParentUnitByNameShortcodeHandler> logger;
public int Order => 55;
private static readonly Regex _pattern = new(
@"%РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:([^:]+):([^%]+)%",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Regex Pattern => _pattern;
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.ParentUnits;
public ParentUnitByNameShortcodeHandler(ILogger<ParentUnitByNameShortcodeHandler> logger)
{
this.logger = logger;
}
public Task<string> ResolveAsync(string input, TemplateForShortcode template, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return Task.FromResult(input);
var uniqueRequests = matches.Cast<Match>()
.Select(m => new
{
FullMatch = m.Value,
Mask = m.Groups[1].Value.Trim(),
FieldName = m.Groups[2].Value.Trim()
})
.GroupBy(x => new { x.Mask, x.FieldName })
.Select(g => g.First())
.ToList();
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var request in uniqueRequests)
{
resolved[request.FullMatch] = GetFieldValue(template, request.Mask, request.FieldName, caller);
}
var result = input;
foreach (Match m in matches)
{
if (resolved.TryGetValue(m.Value, out var val))
result = result.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
}
return Task.FromResult(result);
}
private string GetFieldValue(TemplateForShortcode template, string mask, string fieldName, string caller)
{
if (template.ParentUnits == null || template.ParentUnits.Count == 0)
{
logger.LogDebug("[{Caller}] Родителей нет. Возвращаю 'Нет данных'.", caller);
return "Нет данных";
}
var matchingParent = template.ParentUnits
.FirstOrDefault(p => MatchesMask(p.UnitName, mask));
if (matchingParent == null)
{
logger.LogDebug("[{Caller}] Не найден родитель по маске '{Mask}'. Возвращаю 'Нет данных'.", caller, mask);
return "Нет данных";
}
if (matchingParent.FieldValues.TryGetValue(fieldName, out var value))
{
return value;
}
logger.LogDebug("[{Caller}] Родитель найден, но атрибут '{Field}' отсутствует. Возвращаю 'Нет данных'.", caller, fieldName);
return "Нет данных";
}
private static bool MatchesMask(string value, string mask)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(mask))
return false;
if (mask.Contains('*'))
{
var regexPattern = "^" + Regex.Escape(mask).Replace("\\*", ".*") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
return string.Equals(value, mask, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,11 @@
namespace PARR.Core.Services.Shortcodes.Models
{
/// <summary>
/// Данные о родительском ЭК для использования в шорткодах
/// </summary>
public record ParentUnitForShortcode(
Guid UnitId,
string UnitName,
Dictionary<string, string> FieldValues
);
}

View File

@@ -9,6 +9,7 @@
JobForShortcode? Job,
List<UnitInTemplateForShortcode> UnitsInTemplate,
List<string> UnitTags,
Dictionary<Guid, List<string>> RelatedUnitTags
Dictionary<Guid, List<string>> RelatedUnitTags,
List<ParentUnitForShortcode> ParentUnits
);
}

View File

@@ -25,34 +25,40 @@ internal class ShortcodesService : IShortcodesService
private const int MaxIterations = 3;
private readonly ILogger<ShortcodesService> logger;
private readonly IEnumerable<IShortcodeHandler> handlers;
private readonly IRedisCacheService cacheService;
private readonly SettingsFromDb settingsFromDb;
private readonly IJobRepository jobService;
private readonly IUnitRepository unitService;
private readonly IUnitFieldRepository unitFieldRepo;
private readonly ITemplateRepository templateRepo;
private readonly ILogger<ShortcodesService> _logger;
private readonly IEnumerable<IShortcodeHandler> _handlers;
private readonly IRedisCacheService _cacheService;
private readonly SettingsFromDb _settingsFromDb;
private readonly IJobRepository _jobService;
private readonly IUnitRepository _unitRepo;
private readonly IUnitFieldRepository _unitFieldRepo;
private readonly IUnitInUnitRepository _unitInUnitRepo;
private readonly IUnitInValueRepository _unitInValueRepo;
private readonly ITemplateRepository _templateRepo;
public ShortcodesService(
ILogger<ShortcodesService> logger,
IEnumerable<IShortcodeHandler> handlers,
IRedisCacheService cacheService,
SettingsFromDb settingsFromDb,
IJobRepository jobService,
IUnitRepository unitService,
IUnitFieldRepository unitFieldService,
IJobRepository jobRepo,
IUnitRepository unitRepo,
IUnitFieldRepository unitFieldRepo,
IUnitInUnitRepository unitInUnitRepo,
IUnitInValueRepository unitInValueRepo,
ITemplateRepository templateRepo
)
{
this.logger = logger;
this.handlers = handlers.OrderBy(h => h.Order).ToList();
this.cacheService = cacheService;
this.settingsFromDb = settingsFromDb;
this.jobService = jobService;
this.unitService = unitService;
this.unitFieldRepo = unitFieldService;
this.templateRepo = templateRepo;
this._logger = logger;
this._handlers = handlers.OrderBy(h => h.Order).ToList();
this._cacheService = cacheService;
this._settingsFromDb = settingsFromDb;
this._jobService = jobRepo;
this._unitRepo = unitRepo;
this._unitFieldRepo = unitFieldRepo;
this._unitInUnitRepo = unitInUnitRepo;
_unitInValueRepo = unitInValueRepo;
this._templateRepo = templateRepo;
}
public async Task<string> ApplyShortcodesAsync(string str, Template template, [CallerMemberName] string? caller = null)
@@ -61,8 +67,8 @@ internal class ShortcodesService : IShortcodesService
var callerName = caller ?? "Unknown";
// Кэш результата (проверяем один раз на входе)
var cacheKey = cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
var cachedResult = await cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
var cacheKey = _cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
var cachedResult = await _cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
if (cachedResult != null) return cachedResult.Data.Result;
// Начальный контекст
@@ -83,7 +89,8 @@ internal class ShortcodesService : IShortcodesService
Job: initialJob,
UnitsInTemplate: initialUnitsInTemplate,
UnitTags: new List<string>(),
RelatedUnitTags: new Dictionary<Guid, List<string>>()
RelatedUnitTags: new Dictionary<Guid, List<string>>(),
ParentUnits: new List<ParentUnitForShortcode>()
);
var result = str;
@@ -92,7 +99,7 @@ internal class ShortcodesService : IShortcodesService
while (iteration < MaxIterations)
{
// 1. Находим хендлеры, которые сработают для ТЕКУЩЕЙ версии строки
var matchingHandlers = handlers
var matchingHandlers = _handlers
.Where(h => h.Pattern.IsMatch(result))
.ToList();
@@ -121,13 +128,13 @@ internal class ShortcodesService : IShortcodesService
if (iteration == MaxIterations && result != str)
{
logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
_logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
}
// Сохранение в кэш
if (!string.IsNullOrEmpty(result) && result != str && template.Id != Guid.Empty)
{
await cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
await _cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
{
Data = new ShortcodesResultDto { Result = result },
Timestamp = DateTimeOffset.UtcNow,
@@ -149,8 +156,8 @@ internal class ShortcodesService : IShortcodesService
if (string.IsNullOrEmpty(unitName) && requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitName))
{
// Логирование и загрузка только если нужно и еще не загружено
logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
_logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
var unit = await _unitRepo.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
unitName = unit?.Name ?? throw new InvalidOperationException($"Unit {template.UnitId} не найден.");
}
@@ -181,7 +188,7 @@ internal class ShortcodesService : IShortcodesService
if (requirements.HasFlag(ShortcodeDataRequirementsEnum.JobTnk)) missing.Add("Tnk");
// Логируем только если реально пошли в БД
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует догрузки данных ({Missing}). Данные догружены.",
caller, template.Id, string.Join(", ", missing));
@@ -193,11 +200,11 @@ internal class ShortcodesService : IShortcodesService
if ((unitsInTemplate == null || unitsInTemplate.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplate))
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} передан без UnitsInTemplate. Данные догружены автоматически.",
caller, template.Id);
var fullTemplate = await templateRepo.Get()
var fullTemplate = await _templateRepo.Get()
.AsNoTracking()
.Include(t => t.UnitsInTemplate)
.FirstOrDefaultAsync(t => t.Id == template.Id)
@@ -214,11 +221,11 @@ internal class ShortcodesService : IShortcodesService
if ((unitTags == null || unitTags.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitTags))
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует теги (UnitTags). Данные догружены автоматически.",
caller, template.Id);
unitTags = await unitService.Get()
unitTags = await _unitRepo.Get()
.AsNoTracking()
.Where(u => u.Id == template.UnitId)
.SelectMany(u => u.UnitValues
@@ -252,11 +259,11 @@ internal class ShortcodesService : IShortcodesService
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplateTags) &&
relatedUnitIds.Count > 0)
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует теги связанных ЭК (UnitsInTemplateTags). Данные догружены автоматически.",
caller, template.Id);
var rawTags = await unitService.Get()
var rawTags = await _unitRepo.Get()
.AsNoTracking()
.Where(u => relatedUnitIds.Contains(u.Id))
.SelectMany(u => u.UnitValues
@@ -273,21 +280,34 @@ internal class ShortcodesService : IShortcodesService
.ToDictionary(g => g.Key, g => g.Select(x => x.Tag).ToList());
}
// 6. Возвращаем обновленный рекорд
// 6. Проверка ParentUnits (непосредственные родители)
var parentUnits = currentData.ParentUnits;
if ((parentUnits == null || parentUnits.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.ParentUnits))
{
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует данные о родителях (ParentUnits). Данные догружены автоматически.",
caller, template.Id);
parentUnits = await LoadParentUnitsAsync(template.UnitId, caller).ConfigureAwait(false);
}
// 7. Возвращаем обновленный рекорд
return currentData with
{
UnitName = unitName ?? string.Empty,
Job = jobData,
UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate ?? new List<UnitInTemplateForShortcode>(),
UnitTags = unitTags ?? currentData.UnitTags,
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags
UnitTags = unitTags ?? currentData.UnitTags ?? new List<string>(),
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags ?? new Dictionary<Guid, List<string>>(),
ParentUnits = parentUnits ?? currentData.ParentUnits ?? new List<ParentUnitForShortcode>()
};
}
private async Task<JobForShortcode> LoadFullJobAsync(Guid jobId, string caller)
{
var job = await jobService.Get().AsNoTracking().AsSingleQuery()
var job = await _jobService.Get().AsNoTracking().AsSingleQuery()
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
.Include(j => j.Tnk)
.FirstOrDefaultAsync(j => j.Id == jobId)
@@ -323,7 +343,7 @@ internal class ShortcodesService : IShortcodesService
{
var result = new List<ShortcodeInfoDto>();
foreach (var constant in settingsFromDb.TemplateNameConstantPartsList)
foreach (var constant in _settingsFromDb.TemplateNameConstantPartsList)
{
result.Add(new ShortcodeInfoDto
{
@@ -367,7 +387,23 @@ internal class ShortcodesService : IShortcodesService
new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией", Type = ShortcodeTypeEnum.Relationship }
});
var fieldNames = await unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
result.AddRange(new[]
{
new ShortcodeInfoDto
{
Shortcode = "%РОД_ЭК_ПЕРВЫЙ_ПО_ИМЕНИ:МАСКА_ИМЕНИ:ИМЯ_АТРИБУТА%",
Description = "Значение атрибута первого ближайшего родителя, чье имя подходит под маску (wildcard *)",
Type = ShortcodeTypeEnum.Relationship
},
new ShortcodeInfoDto
{
Shortcode = "%РОД_ЭК_ПЕРВЫЙ_ПО_ЗНАЧЕНИЮ:ИМЯ_АТРИБУТА_ФИЛЬТРА:МАСКА_ЗНАЧЕНИЯ:ИМЯ_АТРИБУТА_РЕЗУЛЬТАТА%",
Description = "Значение атрибута первого ближайшего родителя, у которого значение атрибута-фильтра подходит под маску (wildcard *)",
Type = ShortcodeTypeEnum.Relationship
}
});
var fieldNames = await _unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
foreach (var fieldName in fieldNames.OrderBy(n => n.AihitName))
{
result.Add(new ShortcodeInfoDto
@@ -380,4 +416,76 @@ internal class ShortcodesService : IShortcodesService
return result;
}
private async Task<List<ParentUnitForShortcode>> LoadParentUnitsAsync(Guid unitId, string caller)
{
// Шаг 1: Получаем ID непосредственных родителей
var parentIds = await _unitInUnitRepo.Get()
.AsNoTracking()
.Where(link => link.ChildUnitId == unitId)
.Select(link => link.ParentUnitId)
.ToListAsync()
.ConfigureAwait(false);
if (parentIds.Count == 0)
return new List<ParentUnitForShortcode>();
// Шаг 2: Получаем имена родителей с детерминированной сортировкой
var baseParents = await _unitRepo.Get()
.AsNoTracking()
.Where(u => parentIds.Contains(u.Id))
.OrderBy(u => u.Name)
.Select(u => new { u.Id, u.Name })
.ToListAsync()
.ConfigureAwait(false);
if (baseParents.Count == 0)
return new List<ParentUnitForShortcode>();
// Шаг 3: Получаем все значения атрибутов родителей одним запросом
var parentIdList = baseParents.Select(p => p.Id).ToList();
var fieldValuesRaw = await _unitInValueRepo.Get()
.AsNoTracking()
.Include(uv => uv.Field)
.Include(uv => uv.Value)
.Where(uv => parentIdList.Contains(uv.UnitId) &&
uv.Field != null &&
uv.Value != null &&
!string.IsNullOrEmpty(uv.Value.Value))
.Select(uv => new
{
UnitId = uv.UnitId,
FieldName = uv.Field!.AihitName,
Value = uv.Value!.Value
})
.ToListAsync()
.ConfigureAwait(false);
// Фильтрация пустых значений для гарантии non-nullable type
var validFieldValues = fieldValuesRaw
.Where(x => !string.IsNullOrEmpty(x.FieldName) &&
!string.IsNullOrEmpty(x.Value))
.ToList();
// Шаг 4: Группируем значения по UnitId в словари
// Теперь x.Value точно не null, поэтому компилятор удовлетворится
var valuesDict = validFieldValues
.GroupBy(x => x.UnitId)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(
x => x.FieldName!,
x => x.Value!,
StringComparer.OrdinalIgnoreCase));
// Шаг 5: Формируем результат
return baseParents.Select(bp => new ParentUnitForShortcode(
UnitId: bp.Id,
UnitName: bp.Name,
FieldValues: valuesDict.TryGetValue(bp.Id, out var dict)
? dict
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
)).ToList();
}
}

View File

@@ -60,7 +60,8 @@ public class UnitFilterServiceTests
ReferenceDate = DateTimeOffset.UtcNow,
GroupTypeId = Guid.NewGuid(),
ScheduleExcludeTypeId = Guid.NewGuid(),
GroupType = BuildTestJobGroupType()
GroupType = BuildTestJobGroupType(),
EkMask = "%ЭК%"
};
private static JobAutoControl BuildTestAutoControl(Guid jobId) => new()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PARR.DAL.Migrations
{
/// <inheritdoc />
public partial class tblJobGroupsAddEkMask : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EkMask",
schema: "jobGroup",
table: "Groups",
type: "text",
nullable: false,
defaultValue: "%ЭК%",
comment: "Маска ЭК, допустимы шорткоды. По умолчанию %ЭК%");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EkMask",
schema: "jobGroup",
table: "Groups");
}
}
}

View File

@@ -394,6 +394,11 @@ namespace PARR.DAL.Migrations
b.Property<DateTimeOffset?>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("EkMask")
.IsRequired()
.HasColumnType("text")
.HasComment("Маска ЭК, допустимы шорткоды. По умолчанию %ЭК%");
b.Property<string>("FullDescription")
.IsRequired()
.HasColumnType("text");
@@ -3518,7 +3523,7 @@ namespace PARR.DAL.Migrations
modelBuilder.Entity("PARR.Domain.Entities.JobGroupEntities.JobGroupUnitFilter", b =>
{
b.HasOne("PARR.Domain.Entities.JobGroupEntities.JobGroup", "JobGroup")
.WithMany()
.WithMany("UnitFilters")
.HasForeignKey("JobGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -3942,6 +3947,8 @@ namespace PARR.DAL.Migrations
b.Navigation("EsppSchValues");
b.Navigation("Jobs");
b.Navigation("UnitFilters");
});
modelBuilder.Entity("PARR.Domain.Entities.JobGroupEntities.JobGroupType", b =>

View File

@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Base;
using PARR.DAL.Context;

View File

@@ -102,6 +102,14 @@
public static string[] UnitItem(Guid unitId) => [unitId.ToString()];
}
/// <summary>
/// Справочная документация
/// </summary>
public static class HelpDocument
{
public static string[] HelpDocumentItem(string helpId) => ["help-document", helpId.ToLowerInvariant()];
}
#region Helpers

View File

@@ -0,0 +1,28 @@
namespace PARR.Domain.DTOs.HelpDocument
{
/// <summary>
/// Модель справки, берется из .md
/// </summary>
public record HelpDocumentResult
{
/// <summary>
/// Уникальный ID (например: "ui/billing/invoice-create/vat-calc")
/// </summary>
public string HelpId { get; init; } = string.Empty;
/// <summary>
/// Заголовок из метаданных файла (title)
/// </summary>
public string Title { get; init; } = string.Empty;
public string? PrevId { get; init; }
public string? NextId { get; init; }
/// <summary>
/// // Чистый Markdown-текст (все, что идет после метаданных)
/// </summary>
public string Content { get; init; } = string.Empty;
public string? EditUrl { get; init; }
}
}

View File

@@ -29,6 +29,9 @@
public required string WorkGroup { get; init; }
/// <summary>
/// ЭК
/// </summary>
public required string Ek { get; init; }
public required string Name { get; init; }

View File

@@ -170,6 +170,11 @@ namespace PARR.Domain.Entities.JobGroupEntities
/// </summary>
public Guid? ScheduleExcludeTypeCalendarId { get; set; }
/// <summary>
/// Маска ЭК, допустимы шорткоды.
/// </summary>
[Comment("Маска ЭК, допустимы шорткоды. По умолчанию %ЭК%")]
public required string EkMask { get; set; } = "%ЭК%";
/// <summary>

View File

@@ -0,0 +1,38 @@
namespace PARR.Domain.Settings
{
/// <summary>
/// Настройки получения справочной документации
/// </summary>
public record HelpDocumentSettings
{
public GitLabSettings? GitLabSettings { get; init; }
/// <summary>
/// Время хранения кэша, по умолчанию 2 часа.
/// </summary>
public TimeSpan CacheTTL { get; init; } = TimeSpan.FromHours(1);
/// <summary>
/// Коротки TTL для страниц где не найдена документация. По умолчанию 10 минут.
/// </summary>
public TimeSpan ShortTTL { get; init; } = TimeSpan.FromMinutes(10);
public string WebhookToken { get; set; } = string.Empty;
}
/// <summary>
/// Настройки подключения к GitLab
/// </summary>
public record GitLabSettings
{
public string BaseUrl { get; init; } = string.Empty;
public string ProjectId { get; init; } = string.Empty;
public string Token { get; init; } = string.Empty;
public string DefaultBranch { get; init; } = string.Empty;
/// <summary>
/// Путь к репозиторию, для формирования ссылки для редактирования документа
/// </summary>
public string RepositoryPath { get; init; } = string.Empty;
}
}

View File

@@ -234,7 +234,7 @@ namespace PARR.EsppSync
/// <summary>
/// Применяем шорткоды
/// Применяем шорткоды ко всем полям
/// </summary>
/// <param name="dbObjectInEsppObject"></param>
/// <param name="template"></param>

View File

@@ -83,7 +83,9 @@ namespace PARR.EsppTemplateSync
//ResponseArea = template.Unit!.BaseFields!.ResponseArea!,
ResponseArea = template.Job.ResponseAreaMask,
Duration = template.Job.Group.TemplateDuration,
EK = template.Unit!.Name,
//EK = template.Unit!.Name,
// ЭК берем из группы работ, возможна маска или шорткод
EK = template.Job!.Group!.EkMask,
FullDescription = template.Job.Group.FullDescription,
Solution = template.Job.Group.Solution,
Process = template.Job.Tnk!.Subprocess!.Process!.Name,

View File

@@ -21,5 +21,15 @@
/// Значение тега, обозначающее неиспользуемый юнит.
/// </summary>
public const string NotUsedTagValue = "ПАРР-НЕИСП";
/// <summary>
/// Состояние шаблонов по-умолчанию
/// </summary>
public const bool DefaultUnusedTemplateState = false;
/// <summary>
/// Состояние расписаний по-умолчанию
/// </summary>
public const bool DefaultUnusedScheduleState = false;
}
}

View File

@@ -1,7 +1,6 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class BuildGroupsStage : IGroupedSyncStage
{

View File

@@ -2,15 +2,15 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.GroupedSync;
using PARR.TemplateMatcher.Services.Interfaces;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class DeactivateTemplatesStage : IGroupedSyncWriteStage
{
private readonly ITemplateRepository _templateRepository;
private readonly ITemplateDeactivator _deactivator;
private readonly IUnusedTemplateTargetUnitResolver _targetUnitResolver;
private readonly ILogger<DeactivateTemplatesStage> _logger;
public string StageName => "Деактивация шаблонов";
@@ -18,16 +18,22 @@ internal class DeactivateTemplatesStage : IGroupedSyncWriteStage
public DeactivateTemplatesStage(
ITemplateRepository templateRepository,
ITemplateDeactivator deactivator,
IUnusedTemplateTargetUnitResolver targetUnitResolver,
ILogger<DeactivateTemplatesStage> logger)
{
_templateRepository = templateRepository;
_deactivator = deactivator;
_targetUnitResolver = targetUnitResolver;
_logger = logger;
}
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
public async Task<GroupedSyncContext> ExecuteAsync(
GroupedSyncContext context,
CancellationToken ct = default)
{
var allJobIds = context.JobsInGroup.Select(j => j.Id).ToHashSet();
var allJobIds = context.JobsInGroup
.Select(j => j.Id)
.ToHashSet();
var existingTemplates = await _templateRepository.Get()
.AsNoTracking()
@@ -39,32 +45,76 @@ internal class DeactivateTemplatesStage : IGroupedSyncWriteStage
&& t.Job!.GroupId == context.JobGroupId)
.ToListAsync(ct);
int deactivated = 0;
foreach (var template in existingTemplates)
var staleTemplates = existingTemplates
.Where(t => !context.ExpectedTemplateKeys.Contains(
(t.JobId, t.UnitId, t.Index ?? -1)))
.ToList();
if (staleTemplates.Count == 0)
{
var key = (template.JobId, template.UnitId, template.Index ?? -1);
if (!context.ExpectedTemplateKeys.Contains(key))
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): шаблонов для деактивации нет.",
context.JobGroupName,
context.JobGroupId);
return context;
}
var targetUnits = await _targetUnitResolver.ResolveAsync(staleTemplates, ct);
int deactivated = 0;
foreach (var template in staleTemplates)
{
var unitLogName = context.UnitNames.TryGetValue(template.UnitId, out var unitName)
? $"'{unitName}' ({template.UnitId})"
: template.Unit != null
? $"'{template.Unit.Name}' ({template.UnitId})"
: $"({template.UnitId})";
_logger.LogInformation(
"JobGroup '{JobGroupName}' ({JobGroupId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Job '{JobName}' ({JobId}), Unit {Unit}",
context.JobGroupName,
context.JobGroupId,
template.Name,
template.Id,
template.Job?.Name ?? string.Empty,
template.JobId,
unitLogName);
if (!targetUnits.TryGetValue(template.Id, out var targetUnit))
{
var unitLogName = context.UnitNames.TryGetValue(template.UnitId, out var unitName)
? $"'{unitName}' ({template.UnitId})"
: template.Unit != null
? $"'{template.Unit.Name}' ({template.UnitId})"
: $"({template.UnitId})";
_logger.LogError(
"Не удалось определить целевой Unit для деактивации шаблона {TemplateId}.",
template.Id);
_logger.LogInformation(
"JobGroup '{JobGroupName}' ({JobGroupId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Job '{JobName}' ({JobId}), Unit {Unit}",
context.JobGroupName, context.JobGroupId,
template.Name, template.Id,
template.Job?.Name ?? string.Empty, template.JobId,
unitLogName);
continue;
}
await _deactivator.DeactivateTemplateAsync(template, context.Initiator);
var success = await _deactivator.DeactivateTemplateAsync(
template,
targetUnit,
context.Initiator,
ct);
if (success)
{
deactivated++;
}
else
{
_logger.LogError(
"Не удалось деактивировать шаблон {TemplateId}.",
template.Id);
}
}
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): деактивировано {Count} шаблонов из {Total}",
context.JobGroupName, context.JobGroupId, deactivated, existingTemplates.Count);
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): деактивировано {Count} шаблонов из {Total}",
context.JobGroupName,
context.JobGroupId,
deactivated,
staleTemplates.Count);
return context;
}

View File

@@ -1,9 +1,7 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.UnitFilterService;
using PARR.TemplateMatcher.Exceptions;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class FilterUnitsStage : IGroupedSyncStage
{
@@ -12,24 +10,46 @@ internal class FilterUnitsStage : IGroupedSyncStage
public string StageName => "Фильтрация юнитов";
public FilterUnitsStage(IUnitFilterService filterService, ILogger<FilterUnitsStage> logger)
public FilterUnitsStage(
IUnitFilterService filterService,
ILogger<FilterUnitsStage> logger)
{
_filterService = filterService;
_logger = logger;
}
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
public async Task<GroupedSyncContext> ExecuteAsync(
GroupedSyncContext context,
CancellationToken ct = default)
{
var result = await _filterService.GetUnitsByJobFilterAsync(context.MaxJob.Id, null, ct);
var result = await _filterService.GetUnitsByJobFilterAsync(
context.MaxJob.Id,
null,
ct);
if (result == null || !result.Any())
throw new SyncEarlyExitException("Фильтры не дали Unit'ов с подходящими связями");
var filteredUnits = result?.ToList() ?? new();
context.FilteredUnits = result.ToList();
context.UnitNames = result.ToDictionary(u => u.Id, u => u.Name);
context.FilteredUnits = filteredUnits;
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): отфильтровано {Count} юнитов",
context.JobGroupName, context.JobGroupId, context.FilteredUnits.Count);
context.UnitNames = filteredUnits.ToDictionary(
u => u.Id,
u => u.Name);
if (filteredUnits.Count == 0)
{
_logger.LogWarning(
"JobGroup '{JobGroupName}' ({JobGroupId}): фильтры не вернули юнитов. Продолжаем синхронизацию для деактивации неактуальных шаблонов.",
context.JobGroupName,
context.JobGroupId);
return context;
}
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): отфильтровано {Count} юнитов",
context.JobGroupName,
context.JobGroupId,
context.FilteredUnits.Count);
return context;
}

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Exceptions;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class GroupFilterStage : IGroupedSyncStage
{
@@ -11,23 +9,51 @@ internal class GroupFilterStage : IGroupedSyncStage
public string StageName => "Групповая фильтрация";
public GroupFilterStage(IGroupedTemplateUnitFilter groupedFilter, ILogger<GroupFilterStage> logger)
public GroupFilterStage(
IGroupedTemplateUnitFilter groupedFilter,
ILogger<GroupFilterStage> logger)
{
_groupedFilter = groupedFilter;
_logger = logger;
}
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
public async Task<GroupedSyncContext> ExecuteAsync(
GroupedSyncContext context,
CancellationToken ct = default)
{
var finalFiltered = await _groupedFilter.FilterAsync(context.FilteredUnits, context.JobGroup);
if (context.FilteredUnits.Count == 0)
{
_logger.LogWarning(
"JobGroup '{JobGroupName}' ({JobGroupId}): нет юнитов после первичной фильтрации. Продолжаем синхронизацию для деактивации неактуальных шаблонов.",
context.JobGroupName,
context.JobGroupId);
if (!finalFiltered.Any())
throw new SyncEarlyExitException("Нет юнитов после групповой фильтрации");
return context;
}
context.FilteredUnits = finalFiltered;
var finalFiltered = await _groupedFilter.FilterAsync(
context.FilteredUnits,
context.JobGroup);
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): после групповой фильтрации {Count} юнитов",
context.JobGroupName, context.JobGroupId, finalFiltered.Count);
var filteredUnits = finalFiltered?.ToList() ?? new();
context.FilteredUnits = filteredUnits;
if (filteredUnits.Count == 0)
{
_logger.LogWarning(
"JobGroup '{JobGroupName}' ({JobGroupId}): нет юнитов после групповой фильтрации. Продолжаем синхронизацию для деактивации неактуальных шаблонов.",
context.JobGroupName,
context.JobGroupId);
return context;
}
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): после групповой фильтрации {Count} юнитов",
context.JobGroupName,
context.JobGroupId,
filteredUnits.Count);
return context;
}

View File

@@ -4,7 +4,7 @@ using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.JobGroupEntities;
using PARR.TemplateMatcher.Models;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
/// <summary>
/// Контекст групповой синхронизации. Передаётся между этапами.

View File

@@ -1,6 +1,4 @@
using PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync
namespace PARR.TemplateMatcher.Services.GroupedSync
{
/// <summary>
/// Этап групповой синхронизации, который только читает данные.

View File

@@ -2,9 +2,8 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.JobGroupRepositories;
using PARR.TemplateMatcher.Exceptions;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class LoadJobGroupStage : IGroupedSyncStage
{
@@ -25,6 +24,8 @@ internal class LoadJobGroupStage : IGroupedSyncStage
.AsNoTracking()
.AsSingleQuery()
.Include(jg => jg.GroupType)
.Include(jg => jg.UnitFilters).ThenInclude(uf => uf.FieldFilters).ThenInclude(ff => ff.UnitField)
.Include(jg => jg.UnitFilters).ThenInclude(uf => uf.RelationshipFilters).ThenInclude(rf => rf.UnitField)
.Include(jg => jg.AutoControl)
.Include(jg => jg.Jobs).ThenInclude(j => j.AutoControl)
.Include(jg => jg.Jobs).ThenInclude(j => j.UnitFilters)

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class ProcessGroupsStage : IGroupedSyncWriteStage
{
@@ -11,26 +9,32 @@ internal class ProcessGroupsStage : IGroupedSyncWriteStage
public string StageName => "Обработка групп";
public ProcessGroupsStage(IGroupedTemplateProcessor processor, ILogger<ProcessGroupsStage> logger)
public ProcessGroupsStage(
IGroupedTemplateProcessor processor,
ILogger<ProcessGroupsStage> logger)
{
_processor = processor;
_logger = logger;
}
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
public async Task<GroupedSyncContext> ExecuteAsync(
GroupedSyncContext context,
CancellationToken ct = default)
{
// Приведение типа обратно из object — пайплайн хранит как object для универсальности контекста
var typedGroups = context.TemplateGroups
.Cast<GroupedTemplateGroup>()
.ToList();
var expectedKeys = await _processor.ProcessAsync(
typedGroups, context.JobsInGroup, context.MaxJob, context.Initiator);
context.TemplateGroups,
context.JobsInGroup,
context.MaxJob,
context.Initiator,
ct);
context.ExpectedTemplateKeys = expectedKeys;
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): обработано групп, ожидаемых ключей={Count}",
context.JobGroupName, context.JobGroupId, expectedKeys.Count);
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): обработано групп, ожидаемых ключей={Count}",
context.JobGroupName,
context.JobGroupId,
expectedKeys.Count);
return context;
}

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Exceptions;
using PARR.TemplateMatcher.Services.GroupedSync;
namespace PARR.TemplateMatcher.Services.Implementations.GroupedSync;
namespace PARR.TemplateMatcher.Services.GroupedSync;
internal class ResolveConflictsStage : IGroupedSyncStage
{
@@ -11,23 +9,52 @@ internal class ResolveConflictsStage : IGroupedSyncStage
public string StageName => "Разрешение конфликтов";
public ResolveConflictsStage(IUnitInTemplateConflictMapper conflictMapper, ILogger<ResolveConflictsStage> logger)
public ResolveConflictsStage(
IUnitInTemplateConflictMapper conflictMapper,
ILogger<ResolveConflictsStage> logger)
{
_conflictMapper = conflictMapper;
_logger = logger;
}
public async Task<GroupedSyncContext> ExecuteAsync(GroupedSyncContext context, CancellationToken ct = default)
public async Task<GroupedSyncContext> ExecuteAsync(
GroupedSyncContext context,
CancellationToken ct = default)
{
var mapping = await _conflictMapper.BuildMappingAsync(context.FilteredUnits, context.MaxJob, ct);
if (context.FilteredUnits.Count == 0)
{
context.ReverseMapping.Clear();
if (!mapping.Any())
throw new SyncEarlyExitException("Нет связей после разрешения конфликтов");
_logger.LogWarning(
"JobGroup '{JobGroupName}' ({JobGroupId}): нет юнитов для разрешения конфликтов. Продолжаем синхронизацию для деактивации неактуальных шаблонов.",
context.JobGroupName,
context.JobGroupId);
context.ReverseMapping = mapping;
return context;
}
_logger.LogDebug("JobGroup '{JobGroupName}' ({JobGroupId}): разрешено конфликтов, связей={Count}",
context.JobGroupName, context.JobGroupId, mapping.Count);
var mapping = await _conflictMapper.BuildMappingAsync(
context.FilteredUnits,
context.MaxJob,
ct);
context.ReverseMapping = mapping ?? new();
if (context.ReverseMapping.Count == 0)
{
_logger.LogWarning(
"JobGroup '{JobGroupName}' ({JobGroupId}): нет связей после разрешения конфликтов. Продолжаем синхронизацию для деактивации неактуальных шаблонов.",
context.JobGroupName,
context.JobGroupId);
return context;
}
_logger.LogDebug(
"JobGroup '{JobGroupName}' ({JobGroupId}): разрешено конфликтов, связей={Count}",
context.JobGroupName,
context.JobGroupId,
context.ReverseMapping.Count);
return context;
}

View File

@@ -5,7 +5,6 @@ using PARR.Domain.Entities.Base.History;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Exceptions;
using PARR.TemplateMatcher.Services.GroupedSync;
using PARR.TemplateMatcher.Services.Implementations.GroupedSync;
using PARR.TemplateMatcher.Services.Interfaces;
using System.Diagnostics;
@@ -61,7 +60,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
foreach (var stage in _readStages)
{
var stageSw = Stopwatch.StartNew();
await stage.ExecuteAsync(context);
await stage.ExecuteAsync(context, ct);
stageSw.Stop();
_logger.LogDebug("[Perf] JobGroup '{JobGroupName}' ({JobGroupId}) | Этап: {Stage} | Время: {Ms} мс",
context.JobGroupName, jobGroupId, stage.StageName, stageSw.ElapsedMilliseconds);
@@ -76,7 +75,7 @@ internal class GroupedTemplateSynchronizer : ITemplateSynchronizer
foreach (var stage in _writeStages)
{
var stageSw = Stopwatch.StartNew();
await stage.ExecuteAsync(context);
await stage.ExecuteAsync(context, ct);
stageSw.Stop();
_logger.LogDebug("[Perf] JobGroup '{JobGroupName}' ({JobGroupId}) | Этап: {Stage} | Время: {Ms} мс",
context.JobGroupName, jobGroupId, stage.StageName, stageSw.ElapsedMilliseconds);

View File

@@ -3,10 +3,10 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
using PARR.Domain.Settings;
using PARR.TemplateMatcher.Services.Interfaces;
@@ -16,106 +16,113 @@ namespace PARR.TemplateMatcher.Services.Implementations;
internal class TemplateDeactivator : ITemplateDeactivator
{
private const bool DefaultUnusedTemplateState = false;
private const bool DefaultUnusedScheduleState = false;
private readonly ILogger<TemplateDeactivator> _logger;
private readonly ITemplateRepository _templateRepository;
private readonly IJobRepository _jobRepository;
private readonly ITemplateMqPublisher _templateMqPublisher;
private readonly IUnusedTemplateMessageFactory _unusedTemplateMessageFactory;
private readonly SettingsFromDb _settingsFromDb;
private readonly IOptions<TemplateSettings> _templateSettings;
private readonly ILogger<TemplateDeactivator> logger;
private readonly ITemplateRepository templateService;
private readonly IJobRepository jobService;
private readonly ITemplateNameNormalizer namenormalizer;
private readonly ITemplateMqPublisher templateMqPublisher;
private readonly SettingsFromDb settingsFromDb;
private readonly IOptions<TemplateSettings> templateSettings;
private Job? _unusedJob;
public TemplateDeactivator(
ILogger<TemplateDeactivator> logger,
ITemplateRepository templateService,
IJobRepository jobService,
ITemplateNameNormalizer namenormalizer,
ITemplateRepository templateRepository,
IJobRepository jobRepository,
ITemplateMqPublisher templateMqPublisher,
IUnusedTemplateMessageFactory unusedTemplateMessageFactory,
SettingsFromDb settingsFromDb,
IOptions<TemplateSettings> templateSettings
)
IOptions<TemplateSettings> templateSettings)
{
this.logger = logger;
this.templateService = templateService;
this.jobService = jobService;
this.namenormalizer = namenormalizer;
this.templateMqPublisher = templateMqPublisher;
this.settingsFromDb = settingsFromDb;
this.templateSettings = templateSettings;
_logger = logger;
_templateRepository = templateRepository;
_jobRepository = jobRepository;
_templateMqPublisher = templateMqPublisher;
_unusedTemplateMessageFactory = unusedTemplateMessageFactory;
_settingsFromDb = settingsFromDb;
_templateSettings = templateSettings;
}
public async Task<bool> DeactivateTemplateAsync(Template template, HistoryInitiator initiator)
public async Task<bool> DeactivateTemplateAsync(
Template template,
Unit targetUnit,
HistoryInitiator initiator,
CancellationToken ct = default)
{
if (template.StatusTypeId == TemplateStatusTypeEnum.Updating)
return true;
if (template.StatusTypeId == TemplateStatusTypeEnum.Unused)
{
logger.LogDebug("Шаблон {TemplateId} уже неактивен (Unused), пропускаем деактивацию.", template.Id);
_logger.LogDebug(
"Шаблон {TemplateId} уже находится в статусе Unused. Пропускаем деактивацию.",
template.Id);
return true;
}
logger.LogInformation("Шаблон {TemplateId} (UnitId {UnitId}) → деактивация.",
template.Id, template.UnitId);
if (template.StatusTypeId == TemplateStatusTypeEnum.Updating)
{
_logger.LogDebug(
"Шаблон {TemplateId} уже находится в статусе Updating. Пропускаем повторную деактивацию.",
template.Id);
return true;
}
var unusedJob = await GetUnusedJobAsync(ct);
if (unusedJob == null)
{
_logger.LogError(
"Job для неиспользуемых шаблонов не найден. Шаблон {TemplateId} не деактивирован.",
template.Id);
return false;
}
_logger.LogInformation(
"Деактивация шаблона {TemplateId}. Текущий UnitId: {CurrentUnitId}, целевой UnitId: {TargetUnitId}.",
template.Id,
template.UnitId,
targetUnit.Id);
template.StatusTypeId = TemplateStatusTypeEnum.Updating;
template.DateModified = DateTimeOffset.UtcNow;
if (!await templateService.CommitAsync(initiator))
if (!await _templateRepository.CommitAsync(initiator))
{
logger.LogError("Не удалось перевести шаблон {TemplateId} в Updating.", template.Id);
_logger.LogError(
"Не удалось перевести шаблон {TemplateId} в статус Updating.",
template.Id);
return false;
}
var unusedJob = await jobService.Get()
var updateRequest = await _unusedTemplateMessageFactory.CreateAsync(
template,
unusedJob,
targetUnit,
initiator,
ct);
await _templateMqPublisher.PublishUpdateAsync(updateRequest);
return true;
}
private async Task<Job?> GetUnusedJobAsync(CancellationToken ct)
{
if (_unusedJob != null)
return _unusedJob;
_unusedJob = await _jobRepository.Get()
.AsNoTracking()
.Include(j => j.Tnk)
.Include(j => j.Group)
.ThenInclude(g => g!.GroupType)
.FirstOrDefaultAsync(j => j.Id == settingsFromDb.JobIdForUnusedTemplates);
.FirstOrDefaultAsync(
j => j.Id == _settingsFromDb.JobIdForUnusedTemplates,
ct);
if (unusedJob == null)
{
logger.LogError("Job для деактивированных шаблонов не найден.");
return false;
}
unusedJob.TemplateNameMask = templateSettings.Value.UnusedTemplateNameMask;
// === Создаём временный Template для нормализации имени ===
var tempTemplateForName = new Template
{
Id = template.Id,
Name = template.Name,
JobId = unusedJob.Id,
UnitId = template.UnitId,
Index = template.Index,
Job = unusedJob,
Unit = template.Unit,
UnitsInTemplate = new List<UnitsInTemplate>()
};
var expectedName = await namenormalizer.GetNormalizedTemplateNameAsync(tempTemplateForName);
var updateRequest = new TemplateUpdaterMessage
{
TemplateId = template.Id,
JobId = unusedJob.Id,
UnitId = template.UnitId,
Name = expectedName,
IsActiveTemplate = DefaultUnusedTemplateState,
IsActiveSchedule = DefaultUnusedScheduleState,
IsNew = false,
Index = template.Index,
StatusTypeId = TemplateStatusTypeEnum.Unused,
Initiator = initiator,
UnitsInTemplate = new List<UnitInTemplateMessage>()
};
await templateMqPublisher.PublishUpdateAsync(updateRequest);
return true;
return _unusedJob;
}
}

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.Options;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.Unit;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Constants;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Settings;
namespace PARR.TemplateMatcher.Services.Implementations;
internal class UnusedTemplateMessageFactory : IUnusedTemplateMessageFactory
{
private readonly ITemplateNameNormalizer _templateNameNormalizer;
private readonly IOptions<TemplateSettings> _templateSettings;
public UnusedTemplateMessageFactory(
ITemplateNameNormalizer templateNameNormalizer,
IOptions<TemplateSettings> templateSettings)
{
_templateNameNormalizer = templateNameNormalizer;
_templateSettings = templateSettings;
}
public async Task<TemplateUpdaterMessage> CreateAsync(
Template template,
Job unusedJob,
Unit targetUnit,
HistoryInitiator initiator,
CancellationToken ct = default)
{
var jobForName = new Job
{
Id = unusedJob.Id,
Name = unusedJob.Name,
WorkName = unusedJob.WorkName,
MinValueRelationships = unusedJob.MinValueRelationships,
MaxValueRelationships = unusedJob.MaxValueRelationships,
IsParentRelationships = unusedJob.IsParentRelationships,
TemplateNameMask = _templateSettings.Value.UnusedTemplateNameMask,
WorkGroupMask = unusedJob.WorkGroupMask,
ResponseAreaMask = unusedJob.ResponseAreaMask,
TnkId = unusedJob.TnkId,
GroupId = unusedJob.GroupId,
Group = unusedJob.Group,
Tnk = unusedJob.Tnk,
UnitFilters = unusedJob.UnitFilters,
Templates = unusedJob.Templates,
AutoControl = unusedJob.AutoControl
};
var templateForName = new Template
{
Id = template.Id,
Name = template.Name,
JobId = unusedJob.Id,
UnitId = targetUnit.Id,
Index = null,
Job = jobForName,
Unit = targetUnit,
UnitsInTemplate = new List<UnitsInTemplate>()
};
var expectedName = await _templateNameNormalizer.GetNormalizedTemplateNameAsync(templateForName);
return new TemplateUpdaterMessage
{
TemplateId = template.Id,
JobId = unusedJob.Id,
UnitId = targetUnit.Id,
Name = expectedName,
IsActiveTemplate = UnusedTemplateConstants.DefaultUnusedTemplateState,
IsActiveSchedule = UnusedTemplateConstants.DefaultUnusedScheduleState,
IsNew = false,
Index = null,
StatusTypeId = TemplateStatusTypeEnum.Unused,
Initiator = initiator,
UnitsInTemplate = new List<UnitInTemplateMessage>()
};
}
}

View File

@@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Unit;
using PARR.TemplateMatcher.Constants;
using PARR.TemplateMatcher.Services.Interfaces;
namespace PARR.TemplateMatcher.Services.Implementations;
internal class UnusedTemplateTargetUnitResolver : IUnusedTemplateTargetUnitResolver
{
private readonly IUnitFieldRepository _unitFieldRepository;
private readonly IUnitInValueRepository _unitInValueRepository;
private readonly IUnitRepository _unitRepository;
private readonly ILogger<UnusedTemplateTargetUnitResolver> _logger;
public UnusedTemplateTargetUnitResolver(
IUnitFieldRepository unitFieldRepository,
IUnitInValueRepository unitInValueRepository,
IUnitRepository unitRepository,
ILogger<UnusedTemplateTargetUnitResolver> logger)
{
_unitFieldRepository = unitFieldRepository;
_unitInValueRepository = unitInValueRepository;
_unitRepository = unitRepository;
_logger = logger;
}
public async Task<IReadOnlyDictionary<Guid, Unit>> ResolveAsync(
IReadOnlyCollection<Template> templates,
CancellationToken ct = default)
{
var result = new Dictionary<Guid, Unit>();
foreach (var template in templates.Where(t => t.Unit != null))
{
result[template.Id] = template.Unit!;
}
if (templates.Count == 0)
return result;
var responsableAreaField = await _unitFieldRepository.GetByAihitNameAsync(
UnusedTemplateConstants.ResponsibilityAreaFieldName,
ct);
var tagField = await _unitFieldRepository.GetByAihitNameAsync(
UnusedTemplateConstants.ParrTagFieldName,
ct);
if (responsableAreaField == null || tagField == null)
{
_logger.LogWarning(
"Не найдены поля '{ResponsibilityAreaField}' или '{TagField}'. Целевой Unit для неиспользуемых шаблонов будет выбран как текущий.",
UnusedTemplateConstants.ResponsibilityAreaFieldName,
UnusedTemplateConstants.ParrTagFieldName);
await AddFallbackUnitsAsync(templates, result, ct);
return result;
}
var targetTagValueId = await _unitInValueRepository.Get()
.AsNoTracking()
.Where(uiv => uiv.FieldId == tagField.Id
&& uiv.Value != null
&& uiv.Value.Value == UnusedTemplateConstants.NotUsedTagValue)
.Select(uiv => uiv.ValueId)
.FirstOrDefaultAsync(ct);
if (targetTagValueId == Guid.Empty)
{
_logger.LogWarning(
"Значение '{TagValue}' для поля '{FieldName}' не найдено. Целевой Unit для неиспользуемых шаблонов будет выбран как текущий.",
UnusedTemplateConstants.NotUsedTagValue,
UnusedTemplateConstants.ParrTagFieldName);
await AddFallbackUnitsAsync(templates, result, ct);
return result;
}
var unitIds = templates
.Select(t => t.UnitId)
.Distinct()
.ToList();
var unitResponsableAreaValues = await _unitInValueRepository.GetByUnitIdsAndFieldIdsAsync(
unitIds,
new List<Guid> { responsableAreaField.Id },
ct);
var unitToResponsableAreaValueMap = unitResponsableAreaValues
.Where(uiv => uiv.ValueId != Guid.Empty)
.ToDictionary(uiv => uiv.UnitId, uiv => uiv.ValueId);
if (unitToResponsableAreaValueMap.Count == 0)
{
_logger.LogDebug(
"Для юнитов не найдены значения поля '{FieldName}'. Целевой Unit для неиспользуемых шаблонов будет выбран как текущий.",
UnusedTemplateConstants.ResponsibilityAreaFieldName);
await AddFallbackUnitsAsync(templates, result, ct);
return result;
}
var distinctResponsableAreaValues = unitToResponsableAreaValueMap.Values
.Distinct()
.ToList();
var matches = await _unitInValueRepository.Get()
.AsNoTracking()
.Where(uiv => uiv.FieldId == responsableAreaField.Id
&& distinctResponsableAreaValues.Contains(uiv.ValueId))
.Join(
_unitInValueRepository.Get()
.AsNoTracking()
.Where(t => t.FieldId == tagField.Id && t.ValueId == targetTagValueId),
responsableArea => responsableArea.UnitId,
tag => tag.UnitId,
(responsableArea, tag) => new
{
responsableArea.ValueId,
responsableArea.UnitId
})
.ToListAsync(ct);
var responsableAreaToTargetUnitMap = matches
.GroupBy(x => x.ValueId)
.ToDictionary(g => g.Key, g => g.First().UnitId);
var templatesToReload = new Dictionary<Guid, Guid>();
foreach (var template in templates)
{
if (!unitToResponsableAreaValueMap.TryGetValue(template.UnitId, out var responsableAreaValueId))
continue;
if (!responsableAreaToTargetUnitMap.TryGetValue(responsableAreaValueId, out var targetUnitId))
continue;
if (targetUnitId == Guid.Empty || targetUnitId == template.UnitId)
continue;
templatesToReload[template.Id] = targetUnitId;
}
if (templatesToReload.Count > 0)
{
var targetUnitIds = templatesToReload.Values
.Distinct()
.ToList();
var targetUnits = await _unitRepository.Get()
.AsNoTracking()
.Where(u => targetUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, ct);
foreach (var kvp in templatesToReload)
{
if (targetUnits.TryGetValue(kvp.Value, out var targetUnit))
{
result[kvp.Key] = targetUnit;
}
else
{
_logger.LogWarning(
"Не удалось загрузить целевой Unit {TargetUnitId} для шаблона {TemplateId}.",
kvp.Value,
kvp.Key);
}
}
}
await AddFallbackUnitsAsync(templates, result, ct);
return result;
}
private async Task AddFallbackUnitsAsync(
IReadOnlyCollection<Template> templates,
Dictionary<Guid, Unit> result,
CancellationToken ct)
{
var missingUnitIds = templates
.Where(t => !result.ContainsKey(t.Id) && t.UnitId != Guid.Empty)
.Select(t => t.UnitId)
.Distinct()
.ToList();
if (missingUnitIds.Count == 0)
return;
var fallbackUnits = await _unitRepository.Get()
.AsNoTracking()
.Where(u => missingUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, ct);
foreach (var template in templates.Where(t => !result.ContainsKey(t.Id) && t.UnitId != Guid.Empty))
{
if (fallbackUnits.TryGetValue(template.UnitId, out var fallbackUnit))
{
result[template.Id] = fallbackUnit;
}
}
}
}

View File

@@ -1,10 +1,15 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.Unit;
namespace PARR.TemplateMatcher.Services.Interfaces
{
public interface ITemplateDeactivator
{
Task<bool> DeactivateTemplateAsync(Template template, HistoryInitiator initiator);
Task<bool> DeactivateTemplateAsync(
Template template,
Unit targetUnit,
HistoryInitiator initiator,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,18 @@
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Entities;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Entities.Unit;
namespace PARR.TemplateMatcher.Services.Interfaces
{
internal interface IUnusedTemplateMessageFactory
{
Task<TemplateUpdaterMessage> CreateAsync(
Template template,
Job unusedJob,
Unit targetUnit,
HistoryInitiator initiator,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,12 @@
using PARR.Domain.Entities;
using PARR.Domain.Entities.Unit;
namespace PARR.TemplateMatcher.Services.Interfaces
{
internal interface IUnusedTemplateTargetUnitResolver
{
Task<IReadOnlyDictionary<Guid, Unit>> ResolveAsync(
IReadOnlyCollection<Template> templates,
CancellationToken ct = default);
}
}

View File

@@ -1,10 +1,10 @@
using Microsoft.Extensions.Logging;
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.TemplateMatcher.Models;
using PARR.TemplateMatcher.Services.Implementations;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class AllocateTemplatesStage : ISimpleSyncWriteStage
{

View File

@@ -3,9 +3,8 @@ using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class AnalyzeChangesStage : ISimpleSyncStage
{

View File

@@ -1,33 +1,81 @@
using Microsoft.Extensions.Logging;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class DeactivateTemplatesStage : ISimpleSyncWriteStage
{
private readonly ITemplateDeactivator _deactivator;
private readonly IUnusedTemplateTargetUnitResolver _targetUnitResolver;
private readonly ILogger<DeactivateTemplatesStage> _logger;
public string StageName => "Деактивация шаблонов";
public DeactivateTemplatesStage(ITemplateDeactivator deactivator, ILogger<DeactivateTemplatesStage> logger)
public DeactivateTemplatesStage(
ITemplateDeactivator deactivator,
IUnusedTemplateTargetUnitResolver targetUnitResolver,
ILogger<DeactivateTemplatesStage> logger)
{
_deactivator = deactivator;
_targetUnitResolver = targetUnitResolver;
_logger = logger;
}
public async Task<SimpleSyncContext> ExecuteAsync(SimpleSyncContext context, CancellationToken ct = default)
public async Task<SimpleSyncContext> ExecuteAsync(
SimpleSyncContext context,
CancellationToken ct = default)
{
if (!context.UnusedTemplates.Any())
return context;
var targetUnits = await _targetUnitResolver.ResolveAsync(context.UnusedTemplates, ct);
int deactivated = 0;
foreach (var template in context.UnusedTemplates)
{
_logger.LogInformation("Job '{JobName}' ({JobId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Unit {Unit}",
context.JobName, context.JobId, template.Name, template.Id,
_logger.LogInformation(
"Job '{JobName}' ({JobId}): деактивация шаблона '{TemplateName}' ({TemplateId}), Unit {Unit}",
context.JobName,
context.JobId,
template.Name,
template.Id,
context.FormatUnit(template.UnitId));
await _deactivator.DeactivateTemplateAsync(template, context.Initiator);
if (!targetUnits.TryGetValue(template.Id, out var targetUnit))
{
_logger.LogError(
"Не удалось определить целевой Unit для деактивации шаблона {TemplateId}.",
template.Id);
continue;
}
var success = await _deactivator.DeactivateTemplateAsync(
template,
targetUnit,
context.Initiator,
ct);
if (success)
{
deactivated++;
}
else
{
_logger.LogError(
"Не удалось деактивировать шаблон {TemplateId}.",
template.Id);
}
}
_logger.LogDebug(
"Job '{JobName}' ({JobId}): деактивировано {Count} шаблонов из {Total}.",
context.JobName,
context.JobId,
deactivated,
context.UnusedTemplates.Count);
return context;
}
}

View File

@@ -1,8 +1,7 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.UnitFilterService;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class FilterUnitsStage : ISimpleSyncStage
{

View File

@@ -1,9 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class LoadJobStage : ISimpleSyncStage
{

View File

@@ -2,9 +2,8 @@
using PARR.Domain.Common.Rabbit.Messages.TemplateMatching;
using PARR.Domain.Enums;
using PARR.TemplateMatcher.Services.Interfaces;
using PARR.TemplateMatcher.Services.SimpleSync;
namespace PARR.TemplateMatcher.Services.Implementations.SimpleSync;
namespace PARR.TemplateMatcher.Services.SimpleSync;
internal class UpdateNamesStage : ISimpleSyncWriteStage
{

View File

@@ -35,27 +35,27 @@ namespace PARR.TemplateMatcher
services.AddScoped<ITemplateMatcher, Services.Implementations.TemplateMatcher>();
// Simple pipeline: Read-этапы
services.AddScoped<ISimpleSyncStage, Services.Implementations.SimpleSync.LoadJobStage>(); //1
services.AddScoped<ISimpleSyncStage, Services.Implementations.SimpleSync.FilterUnitsStage>(); //2
services.AddScoped<ISimpleSyncStage, Services.Implementations.SimpleSync.AnalyzeChangesStage>(); //3
services.AddScoped<ISimpleSyncStage, LoadJobStage>(); //1
services.AddScoped<ISimpleSyncStage, Services.SimpleSync.FilterUnitsStage>(); //2
services.AddScoped<ISimpleSyncStage, AnalyzeChangesStage>(); //3
// Simple pipeline: Write-этапы
services.AddScoped<ISimpleSyncWriteStage, Services.Implementations.SimpleSync.AllocateTemplatesStage>(); //4
services.AddScoped<ISimpleSyncWriteStage, Services.Implementations.SimpleSync.UpdateNamesStage>(); //5
services.AddScoped<ISimpleSyncWriteStage, Services.Implementations.SimpleSync.DeactivateTemplatesStage>(); //6
services.AddScoped<ISimpleSyncWriteStage, AllocateTemplatesStage>(); //4
services.AddScoped<ISimpleSyncWriteStage, UpdateNamesStage>(); //5
services.AddScoped<ISimpleSyncWriteStage, Services.SimpleSync.DeactivateTemplatesStage>(); //6
services.AddScoped<ITemplateSynchronizer, SimpleTemplateSynchronizer>();
// Grouped pipeline: Read-этапы
services.AddScoped<IGroupedSyncStage, Services.Implementations.GroupedSync.LoadJobGroupStage>(); //1
services.AddScoped<IGroupedSyncStage, Services.Implementations.GroupedSync.FilterUnitsStage>(); //2
services.AddScoped<IGroupedSyncStage, Services.Implementations.GroupedSync.GroupFilterStage>(); //3
services.AddScoped<IGroupedSyncStage, Services.Implementations.GroupedSync.ResolveConflictsStage>(); //4
services.AddScoped<IGroupedSyncStage, Services.Implementations.GroupedSync.BuildGroupsStage>(); //5
services.AddScoped<IGroupedSyncStage, LoadJobGroupStage>(); //1
services.AddScoped<IGroupedSyncStage, Services.GroupedSync.FilterUnitsStage>(); //2
services.AddScoped<IGroupedSyncStage, GroupFilterStage>(); //3
services.AddScoped<IGroupedSyncStage, ResolveConflictsStage>(); //4
services.AddScoped<IGroupedSyncStage, BuildGroupsStage>(); //5
// Grouped pipeline: Write-этапы
services.AddScoped<IGroupedSyncWriteStage, Services.Implementations.GroupedSync.ProcessGroupsStage>(); //6
services.AddScoped<IGroupedSyncWriteStage, Services.Implementations.GroupedSync.DeactivateTemplatesStage>();//7
services.AddScoped<IGroupedSyncWriteStage, ProcessGroupsStage>(); //6
services.AddScoped<IGroupedSyncWriteStage, Services.GroupedSync.DeactivateTemplatesStage>();//7
services.AddScoped<ITemplateSynchronizer, GroupedTemplateSynchronizer>();
@@ -81,6 +81,8 @@ namespace PARR.TemplateMatcher
//Сервис для неактуальных шаблонов
services.AddScoped<IUnusedTemplatesSyncService, UnusedTemplatesSyncService>();
services.AddTransient<IUnusedTemplateMessageFactory, UnusedTemplateMessageFactory>();
services.AddScoped<IUnusedTemplateTargetUnitResolver, UnusedTemplateTargetUnitResolver>();
}
public static IConfigurationBuilder AddTemplateMatcherConfigurations(this IConfigurationBuilder builder, IServiceCollection services)

View File

@@ -25,4 +25,8 @@
<ProjectReference Include="..\PARR.TemplateDistributor\PARR.TemplateDistributor.csproj" />
<ProjectReference Include="..\PARR.TemplateMatcher\PARR.TemplateMatcher.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="log\" />
</ItemGroup>
</Project>

View File

@@ -1,8 +1,6 @@
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.JobGroupRepositories;
using PARR.Core.Repositories.Interfaces.JobRepositories;
using PARR.Domain.Entities.Base.History;
using PARR.Domain.Entities.JobEntities;
using PARR.Domain.Enums;
using PARR.EsppApi;
using PARR.EsppApi.Constants;
@@ -43,12 +41,19 @@ namespace PARR.Test
//var bbb = aaa.ToOffset(new TimeSpan(3, 0, 0));
await TemplateMatcherTest();
//await TemplateMatcherTest();
await using var scope = serviceProvider.CreateAsyncScope();
//var unitFilterService = scope.ServiceProvider.GetRequiredService<IUnitFilterService>();
//var t = await unitFilterService.GetRelatedUnitNamesAsync(Guid.Parse("4c35d6c3-f07d-4910-98af-b88247d22247"), Guid.Parse("4d4a1503-3a56-4e2a-bf92-a615c91c14ef"));
//t.ForEach(unit =>
//{
// _logger.LogInformation(unit);
//});
//await NextRunTest();
//await GroupedSyncTest();
await SimpleSyncTest();
#region test
//using (var scope = serviceProvider.CreateScope())
@@ -164,7 +169,6 @@ namespace PARR.Test
}
#region SimpleSyncTest
private async Task SimpleSyncTest()
{
await using var scope = serviceProvider.CreateAsyncScope();
@@ -182,7 +186,7 @@ namespace PARR.Test
{
//Guid.Parse("cfb0d2dd-192a-4ed5-a29c-25f0abf61895"),//11010
//Guid.Parse("79976eaf-be7b-42a0-a4e3-a89d7f68a18e"),//17
Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a")//318
Guid.Parse("edcabe55-7f89-45ae-b946-d136b8594c53")//48
};
if (!jobIds.Any())
@@ -207,7 +211,8 @@ namespace PARR.Test
InitiatorIp = "127.0.0.1",
InitiatorParrComponentId = ParrComponentsEnum.Master,
InitiatorComment = "Тестовая синхронизация SimpleTemplateSynchronizer"
});
}, dryRun: false
);
sw.Stop();
passed++;
@@ -236,7 +241,7 @@ namespace PARR.Test
var templateMatcher = scope.ServiceProvider.GetRequiredService<ITemplateMatcher>();
var groupRepository = scope.ServiceProvider.GetRequiredService<IJobGroupRepository>();
await templateMatcher.SyncTemplatesForJob(Guid.Parse("055ac0cc-10f7-4f96-baf6-9c8145b1332a"),
await templateMatcher.SyncTemplatesForJobGroup(Guid.Parse("9ed70f1e-69f6-4559-9139-b4947cce8d54"),
new HistoryInitiator
{
InitiatorIp = "10.99.246.156",
@@ -267,6 +272,56 @@ namespace PARR.Test
return;
}
#endregion
#region GroupedSyncTest
private async Task GroupedSyncTest()
{
_logger.LogInformation("=== DryRun-тестирование GroupedTemplateSynchronizer ===");
await using var scope = serviceProvider.CreateAsyncScope();
var synchronizers = scope.ServiceProvider.GetRequiredService<IEnumerable<ITemplateSynchronizer>>();
var groupedSync = synchronizers.FirstOrDefault(s => s is GroupedTemplateSynchronizer);
if (groupedSync == null)
{
_logger.LogError("[FAIL] GroupedTemplateSynchronizer не найден в DI");
return;
}
// Подставьте ID нужных JobGroup для тестирования
var jobGroupIds = new List<Guid>
{
Guid.Parse("fb8678d0-aa8b-457d-8ea8-6452040c0692"), // Анализ производительности БД
// Добавьте другие ID для проверки
};
foreach (var jobGroupId in jobGroupIds)
{
_logger.LogInformation("--- DryRun: JobGroup {JobGroupId} ---", jobGroupId);
try
{
await groupedSync.SyncTemplatesForJobGroupAsync(
jobGroupId,
new HistoryInitiator
{
InitiatorIp = "127.0.0.1",
InitiatorParrComponentId = ParrComponentsEnum.Master,
InitiatorComment = "DryRun-тест"
},
dryRun: true // Ключевой флаг
);
}
catch (Exception ex)
{
_logger.LogError(ex, "[FAIL] DryRun для JobGroup {JobGroupId}", jobGroupId);
}
_logger.LogInformation("--- DryRun завершён ---");
_logger.LogInformation("");
}
}
#endregion
private async Task NextRunTest()
{