diff --git a/PARR.Core/DependencyInjection.cs b/PARR.Core/DependencyInjection.cs index 89a31080..49e9f42c 100644 --- a/PARR.Core/DependencyInjection.cs +++ b/PARR.Core/DependencyInjection.cs @@ -7,6 +7,7 @@ using PARR.Core.Services.MatchingStatusService; using PARR.Core.Services.NextRunServices; using PARR.Core.Services.NextRunServices.Subservices; using PARR.Core.Services.Shortcodes; +using PARR.Core.Services.Shortcodes.Handlers; using PARR.Core.Services.TaskServices.Handlers; using PARR.Core.Services.TaskServices.Handlers.Factory; using PARR.Core.Services.TaskServices.Implementations; @@ -71,7 +72,21 @@ namespace PARR.Core #region Services - services.AddTransient(); + #region Shortсodes + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + #endregion + services.AddTransient(); //services.AddScoped(); diff --git a/PARR.Core/Repositories/Interfaces/Unit/IUnitInValueRepository.cs b/PARR.Core/Repositories/Interfaces/Unit/IUnitInValueRepository.cs index 7c9f5c56..d0a18dc0 100644 --- a/PARR.Core/Repositories/Interfaces/Unit/IUnitInValueRepository.cs +++ b/PARR.Core/Repositories/Interfaces/Unit/IUnitInValueRepository.cs @@ -4,16 +4,16 @@ namespace PARR.Core.Repositories.Interfaces.Unit { public interface IUnitInValueRepository { - Task> GetByUnitIdsAsync(IEnumerable unitIds); + Task> GetByUnitIdsAsync(IEnumerable unitIds, CancellationToken ct = default); - Task> GetByUnitIdAsync(Guid unitId); + Task> GetByUnitIdAsync(Guid unitId, CancellationToken ct = default); - Task> GetFieldValuesAsync(Guid unitId, IReadOnlyCollection aihitNames); + Task> GetFieldValuesAsync(Guid unitId, IReadOnlyCollection aihitNames, CancellationToken ct = default); /// /// Получает UnitInValue (с Value) для заданных UnitId и FieldId. /// - Task> GetByUnitIdsAndFieldIdsAsync(IEnumerable unitIds, IEnumerable fieldIds); + Task> GetByUnitIdsAndFieldIdsAsync(IEnumerable unitIds, IEnumerable fieldIds, CancellationToken ct = default); /// /// Находит наиболее часто встречающееся непустое значение указанного поля среди переданных юнитов. @@ -21,7 +21,7 @@ namespace PARR.Core.Repositories.Interfaces.Unit /// Список Id юнитов для анализа. /// Имя поля (AihitName) для поиска значения. /// Наиболее частое значение поля или null, если не найдено. - Task GetMostFrequentValueForFieldAsync(List unitIds, string fieldName); + Task GetMostFrequentValueForFieldAsync(List unitIds, string fieldName, CancellationToken ct = default); IQueryable Get(); diff --git a/PARR.Core/Services/Shortcodes/Handlers/ConstantsShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/ConstantsShortcodeHandler.cs new file mode 100644 index 00000000..2499025c --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/ConstantsShortcodeHandler.cs @@ -0,0 +1,28 @@ +using PARR.Core.Services.Shortcodes.Models; +using PARR.Domain.Settings; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class ConstantsShortcodeHandler : IShortcodeHandler +{ + private readonly SettingsFromDb settings; + public int Order => 5; + public Regex Pattern => new(@"%[А-ЯA-Z0-9_\-]+%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public ConstantsShortcodeHandler(SettingsFromDb settings) => this.settings = settings; + + public Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var result = input; + foreach (var constant in settings.TemplateNameConstantPartsList) + { + var shortcode = $"%{constant.Name}%"; + if (result.Contains(shortcode, StringComparison.OrdinalIgnoreCase)) + { + result = result.Replace(shortcode, constant.Value ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + } + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/FieldShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/FieldShortcodeHandler.cs new file mode 100644 index 00000000..830627ce --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/FieldShortcodeHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class FieldShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitInValueRepository unitInValueRepository; + private readonly ILogger logger; + public int Order => 90; // Всегда последним + public Regex Pattern => new(@"%[^%]+%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public FieldShortcodeHandler(IUnitInValueRepository unitInValueRepository, ILogger logger) + { + this.unitInValueRepository = unitInValueRepository; + this.logger = logger; + } + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var matches = Pattern.Matches(input); + if (matches.Count == 0) return input; + + var fieldNames = matches.Cast() + .Select(m => m.Value.Trim('%').ToUpperInvariant()) + .Distinct() + .ToList(); + + if (fieldNames.Count == 0) return input; + + var fieldValues = await unitInValueRepository.GetFieldValuesAsync(data.UnitId, fieldNames, ct).ConfigureAwait(false); + + var valuesMap = fieldValues + .GroupBy(x => x.FieldName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => string.Join(", ", g.Select(v => v.Value ?? "null")), + StringComparer.OrdinalIgnoreCase); + + var result = input; + foreach (var match in matches.Cast()) + { + var fieldName = match.Value.Trim('%').ToUpperInvariant(); + if (valuesMap.TryGetValue(fieldName, out var combinedValue)) + { + result = result.Replace(match.Value, combinedValue, StringComparison.OrdinalIgnoreCase); + } + else + { + logger.LogDebug("[{Caller}] Поле '{FieldName}' не найдено для UnitId={UnitId}. Шорткод пропущен.", caller, fieldName, data.UnitId); + } + } + + return result; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/GroupedFieldShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/GroupedFieldShortcodeHandler.cs new file mode 100644 index 00000000..4830fb17 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/GroupedFieldShortcodeHandler.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class GroupedFieldShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitRepository unitRepo; + private readonly IUnitFieldValueRepository fieldValueRepo; + private readonly ITemplateRepository templateRepo; + private readonly ILogger logger; + + public int Order => 30; + public Regex Pattern => new(@"%ГР_ПОЛЕ-ПН%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public GroupedFieldShortcodeHandler( + IUnitRepository unitRepo, + IUnitFieldValueRepository fieldValueRepo, + ITemplateRepository templateRepo, + ILogger logger) + { + this.unitRepo = unitRepo; + this.fieldValueRepo = fieldValueRepo; + this.templateRepo = templateRepo; + this.logger = logger; + } + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + if (!input.Contains("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase)) return input; + + if (data.Job?.Group == null) + { + logger.LogWarning("[{Caller}] Job не содержит Group, необходимый для %ГР_ПОЛЕ-ПН%. Шорткод пропущен.", caller); + return input.Replace("%ГР_ПОЛЕ-ПН%", string.Empty, StringComparison.OrdinalIgnoreCase); + } + + var unitsList = data.UnitsInTemplate; + if (unitsList.Count == 0) + { + logger.LogWarning("[{Caller}] Template {TemplateId} не содержит UnitsInTemplate. Данные догружаются из БД.", caller, data.Id); + var fullTemplate = await templateRepo.Get() + .AsNoTracking() + .Include(t => t.UnitsInTemplate) + .FirstOrDefaultAsync(t => t.Id == data.Id, ct); + + unitsList = fullTemplate?.UnitsInTemplate + ?.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId)) + .ToList() ?? new List(); + } + + if (unitsList.Count == 0) + return input.Replace("%ГР_ПОЛЕ-ПН%", string.Empty, StringComparison.OrdinalIgnoreCase); + + var unitIds = unitsList.Select(u => u.UnitId).Distinct().ToList(); + var fieldValueIds = unitsList.Select(u => u.UnitFieldValueId).Distinct().ToList(); + + var unitNames = await unitRepo.Get() + .AsNoTracking() + .Where(u => unitIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString(), ct); + + var fieldValueStrings = await fieldValueRepo.Get() + .AsNoTracking() + .Where(fv => fieldValueIds.Contains(fv.Id)) + .ToDictionaryAsync(fv => fv.Id, fv => fv.Value ?? string.Empty, ct); + + var lines = unitsList.Select((uit, i) => + { + var uName = unitNames.GetValueOrDefault(uit.UnitId, $"(UnitId={uit.UnitId})"); + var fVal = fieldValueStrings.GetValueOrDefault(uit.UnitFieldValueId, string.Empty); + return $"{i + 1}. {uName} ({fVal})"; + }); + + return input.Replace("%ГР_ПОЛЕ-ПН%", string.Join("\n", lines), StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/IShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/IShortcodeHandler.cs new file mode 100644 index 00000000..47fc7d2e --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/IShortcodeHandler.cs @@ -0,0 +1,24 @@ +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers +{ + public interface IShortcodeHandler + { + /// + /// Порядок выполнения в пайплайне. + /// Меньшее значение = раньше выполнение. Важно для зависимых шорткодов. + /// + int Order { get; } + + /// + /// Регулярное выражение для поиска шорткодов, которые обрабатывает этот хендлер. + /// + Regex Pattern { get; } + + /// + /// Заменяет все найденные шорткоды в строке на вычисленные значения. + /// + Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default); + } +} diff --git a/PARR.Core/Services/Shortcodes/Handlers/IndexShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/IndexShortcodeHandler.cs new file mode 100644 index 00000000..a49581c2 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/IndexShortcodeHandler.cs @@ -0,0 +1,19 @@ +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class IndexShortcodeHandler : IShortcodeHandler +{ + public int Order => 15; + public Regex Pattern => new(@"%ИНДЕКС%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + if (data.Index.HasValue && input.Contains("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(input.Replace("%ИНДЕКС%", data.Index.Value.ToString(), StringComparison.OrdinalIgnoreCase)); + } + return Task.FromResult(input); + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/LettersShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/LettersShortcodeHandler.cs new file mode 100644 index 00000000..c659a464 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/LettersShortcodeHandler.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class LettersShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitInValueRepository unitInValueRepo; + private readonly ILogger logger; + + public int Order => 60; + public Regex Pattern => new(@"%БУКВЫ:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public LettersShortcodeHandler(IUnitInValueRepository unitInValueRepo, ILogger logger) + { + this.unitInValueRepo = unitInValueRepo; + this.logger = logger; + } + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var matches = Pattern.Matches(input); + if (matches.Count == 0) return input; + + var uniqueFields = matches.Cast() + .Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in uniqueFields) + { + var values = await unitInValueRepo.GetFieldValuesAsync(data.UnitId, new List { field }, ct); + var rawValue = values.FirstOrDefault().Value; + var letters = string.IsNullOrEmpty(rawValue) ? string.Empty : new string(rawValue.Where(char.IsLetter).ToArray()); + resolved[$"%БУКВЫ:{field}%"] = letters; + } + + 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 result; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/MaxShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/MaxShortcodeHandler.cs new file mode 100644 index 00000000..8af7cd07 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/MaxShortcodeHandler.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Common.Interfaces; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.Core.Services.Shortcodes.Models; +using PARR.Domain.Cache.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class MaxShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitInValueRepository unitInValueRepo; + private readonly IRedisCacheService cacheService; + private readonly ILogger logger; + + public int Order => 40; + public Regex Pattern => new(@"%МАКС:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public MaxShortcodeHandler( + IUnitInValueRepository unitInValueRepo, + IRedisCacheService cacheService, + ILogger logger) + { + this.unitInValueRepo = unitInValueRepo; + this.cacheService = cacheService; + this.logger = logger; + } + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var matches = Pattern.Matches(input); + if (matches.Count == 0) return input; + + var uniqueFields = matches.Cast() + .Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in uniqueFields) + { + resolved[$"%МАКС:{field}%"] = await GetMaxValueAsync(data, field, caller, ct); + } + + 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 result; + } + + private async Task GetMaxValueAsync(TemplateForShortcode data, string fieldName, string caller, CancellationToken ct = default) + { + if (data.Job?.Group?.Id == Guid.Empty || data.UnitId == Guid.Empty) return "Нет данных"; + + var cacheKey = cacheService.GetKey( + new[] { "grouped", "shortcode", $"{data.Job.Group.Id:N}", $"{data.UnitId:N}" }, + new[] { $"%МАКС:{fieldName}%" }); + + var cached = await cacheService.GetCachedDataAsync(cacheKey); + if (cached != null) return cached.Data.Value; + + var unitIds = data.UnitsInTemplate.Select(u => u.UnitId).Distinct().ToList(); + var value = await unitInValueRepo.GetMostFrequentValueForFieldAsync(unitIds, fieldName, ct); + + if (!string.IsNullOrEmpty(value)) + { + await cacheService.SetCachedDataAsync(cacheKey, new GroupedShortcode + { + Data = new GroupedShortcodeDto { Value = value }, + Timestamp = DateTimeOffset.UtcNow, + Source = nameof(MaxShortcodeHandler) + }, TimeSpan.FromHours(1)); + } + + return value ?? "Нет данных"; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/RelShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/RelShortcodeHandler.cs new file mode 100644 index 00000000..ace5c072 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/RelShortcodeHandler.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PARR.Core.Repositories.Interfaces; +using PARR.Core.Repositories.Interfaces.Unit; +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class RelShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitInValueRepository unitInValueRepo; + private readonly ITemplateRepository templateRepo; + private readonly ILogger logger; + + public int Order => 50; + public Regex Pattern => new(@"%СВЯЗЬ:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public RelShortcodeHandler( + IUnitInValueRepository unitInValueRepo, + ITemplateRepository templateRepo, + ILogger logger) + { + this.unitInValueRepo = unitInValueRepo; + this.templateRepo = templateRepo; + this.logger = logger; + } + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var matches = Pattern.Matches(input); + if (matches.Count == 0) return input; + + var uniqueFields = matches.Cast() + .Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in uniqueFields) + { + resolved[$"%СВЯЗЬ:{field}%"] = await GetRelValueAsync(data, field, caller, ct); + } + + 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 result; + } + + private async Task GetRelValueAsync(TemplateForShortcode data, string fieldName, string caller, CancellationToken ct) + { + var unitsList = data.UnitsInTemplate; + if (unitsList.Count == 0) + { + var fullTemplate = await templateRepo.Get() + .AsNoTracking() + .Include(t => t.UnitsInTemplate) + .FirstOrDefaultAsync(t => t.Id == data.Id, ct); + + unitsList = fullTemplate?.UnitsInTemplate + ?.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId)) + .ToList() ?? new List(); + } + + if (unitsList.Count == 0) return string.Empty; + + var unitIds = unitsList.Select(u => u.UnitId).Distinct().ToList(); + + // Пакетный запрос вместо N+1 + var allValues = await unitInValueRepo.Get() + .AsNoTracking() + .Include(uv => uv.Field) + .Include(uv => uv.Value) + .Where(uv => unitIds.Contains(uv.UnitId) && + uv.Field!.AihitName == fieldName && + uv.Value != null && + !string.IsNullOrEmpty(uv.Value.Value)) + .Select(uv => uv.Value!.Value) + .ToListAsync(ct); + + var distinctSorted = allValues.Distinct().OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList(); + if (distinctSorted.Count == 0) return string.Empty; + + if (distinctSorted.Count > 1) + logger.LogWarning("[{Caller}] %СВЯЗЬ:{Field}% нашёл {Count} значений. Используется первое: '{First}'", caller, fieldName, distinctSorted.Count, distinctSorted[0]); + + return distinctSorted[0]!; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/RelationshipsShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/RelationshipsShortcodeHandler.cs new file mode 100644 index 00000000..33938cff --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/RelationshipsShortcodeHandler.cs @@ -0,0 +1,38 @@ +using PARR.Core.Services.Shortcodes.Models; +using PARR.Core.Services.UnitFilterService; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class RelationshipsShortcodeHandler : IShortcodeHandler +{ + private readonly IUnitFilterService unitFilterService; + + public int Order => 70; + public Regex Pattern => new(@"%СВЯЗИ(-ПН)?%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public RelationshipsShortcodeHandler(IUnitFilterService unitFilterService) => this.unitFilterService = unitFilterService; + + public async Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + if (data.Job == null) return input; + + var hasPlain = input.Contains("%СВЯЗИ%", StringComparison.OrdinalIgnoreCase); + var hasNumbered = input.Contains("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase); + if (!hasPlain && !hasNumbered) return input; + + var relatedNames = await unitFilterService.GetRelatedUnitNamesAsync(data.JobId, data.UnitId, ct); + var result = input; + + if (hasPlain) + result = result.Replace("%СВЯЗИ%", string.Join("\n", relatedNames), StringComparison.OrdinalIgnoreCase); + + if (hasNumbered) + { + var numbered = relatedNames.Select((n, i) => $"{i + 1}. {n}"); + result = result.Replace("%СВЯЗИ-ПН%", string.Join("\n", numbered), StringComparison.OrdinalIgnoreCase); + } + + return result; + } +} \ No newline at end of file diff --git a/PARR.Core/Services/Shortcodes/Handlers/StandardShortcodeHandler.cs b/PARR.Core/Services/Shortcodes/Handlers/StandardShortcodeHandler.cs new file mode 100644 index 00000000..769b3fcb --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Handlers/StandardShortcodeHandler.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; +using PARR.Core.Services.Shortcodes.Models; +using System.Text.RegularExpressions; + +namespace PARR.Core.Services.Shortcodes.Handlers; + +internal class StandardShortcodeHandler : IShortcodeHandler +{ + private readonly ILogger logger; + public int Order => 10; + public Regex Pattern => new(@"%(?:ЭК|ТИКТАК|РАБОТА|ГРУППА_РАБОТ|ТНК|ТНК-КРАТКО|ИД)%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public StandardShortcodeHandler(ILogger logger) => this.logger = logger; + + public Task ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default) + { + var result = input; + result = Replace(result, "%ЭК%", data.UnitName); + result = Replace(result, "%ТИКТАК%", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()); + result = Replace(result, "%ИД%", data.Id.ToString("N")); + + if (data.Job != null) + { + result = Replace(result, "%РАБОТА%", data.Job.WorkName); + result = Replace(result, "%ГРУППА_РАБОТ%", data.Job.Group?.GroupName); + result = Replace(result, "%ТНК%", data.Job.Tnk?.Name); + result = Replace(result, "%ТНК-КРАТКО%", data.Job.Tnk?.ShortName); + } + return Task.FromResult(result); + } + + private string Replace(string input, string shortcode, string? value) + { + if (string.IsNullOrEmpty(value)) return input; + return input.Replace(shortcode, value, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PARR.Core/Services/Shortcodes/Models/JobForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/JobForShortcode.cs new file mode 100644 index 00000000..2f5403fa --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/JobForShortcode.cs @@ -0,0 +1,9 @@ +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class JobForShortcode( + JobGroupForShortcode? Group, + TnkForShortcode? Tnk, + string WorkName, + string Name + ); +} diff --git a/PARR.Core/Services/Shortcodes/Models/JobGroupForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/JobGroupForShortcode.cs new file mode 100644 index 00000000..b5964bee --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/JobGroupForShortcode.cs @@ -0,0 +1,9 @@ +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class JobGroupForShortcode( + Guid Id, + Guid? GroupingUnitFieldId, + JobGroupTypeForShortcodes? GroupType, + string GroupName + ); +} diff --git a/PARR.Core/Services/Shortcodes/Models/JobGroupTypeForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/JobGroupTypeForShortcode.cs new file mode 100644 index 00000000..d41b1bc0 --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/JobGroupTypeForShortcode.cs @@ -0,0 +1,8 @@ +using PARR.Domain.Enums; + +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class JobGroupTypeForShortcodes( + JobGroupTypesEnum Code + ); +} diff --git a/PARR.Core/Services/Shortcodes/Models/TemplateForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/TemplateForShortcode.cs new file mode 100644 index 00000000..f4a0b49a --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/TemplateForShortcode.cs @@ -0,0 +1,12 @@ +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class TemplateForShortcode( + Guid Id, + int? Index, + Guid JobId, + Guid UnitId, + string UnitName, + JobForShortcode? Job, + List UnitsInTemplate + ); +} diff --git a/PARR.Core/Services/Shortcodes/Models/TnkForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/TnkForShortcode.cs new file mode 100644 index 00000000..e143596d --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/TnkForShortcode.cs @@ -0,0 +1,7 @@ +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class TnkForShortcode( + string Name, + string ShortName + ); +} diff --git a/PARR.Core/Services/Shortcodes/Models/UnitInTemplateForShortcode.cs b/PARR.Core/Services/Shortcodes/Models/UnitInTemplateForShortcode.cs new file mode 100644 index 00000000..8fed24da --- /dev/null +++ b/PARR.Core/Services/Shortcodes/Models/UnitInTemplateForShortcode.cs @@ -0,0 +1,7 @@ +namespace PARR.Core.Services.Shortcodes.Models +{ + public record class UnitInTemplateForShortcode( + Guid UnitId, + Guid UnitFieldValueId + ); +} diff --git a/PARR.Core/Services/Shortcodes/ShortcodesService.cs b/PARR.Core/Services/Shortcodes/ShortcodesService.cs index 4a62ec97..9fc105b8 100644 --- a/PARR.Core/Services/Shortcodes/ShortcodesService.cs +++ b/PARR.Core/Services/Shortcodes/ShortcodesService.cs @@ -1,12 +1,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.Core.Common.Interfaces; -using PARR.Core.Repositories.Interfaces; using PARR.Core.Repositories.Interfaces.Job; using PARR.Core.Repositories.Interfaces.Unit; -using PARR.Core.Services.UnitFilterService; +using PARR.Core.Services.Shortcodes.Handlers; +using PARR.Core.Services.Shortcodes.Models; using PARR.Domain.Cache.Models; -using PARR.Domain.Common.Template; using PARR.Domain.DTOs.Shortcode; using PARR.Domain.Entities; using PARR.Domain.Entities.Job; @@ -15,1018 +14,290 @@ using PARR.Domain.Settings; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; -namespace PARR.Core.Services.Shortcodes +namespace PARR.Core.Services.Shortcodes; + +internal class ShortcodesService : IShortcodesService { - /// - /// Сервис для подстановки шорткодов в строке на основе данных шаблона. - /// - internal class ShortcodesService : IShortcodesService + private const string MissingUnitNameWarningMsg = + "[{Caller}] Шаблон {TemplateId} не содержит Unit.Name в переданных данных. Данные будут догружены из БД. " + + "Рекомендуется обновить запрос шаблона с Include(t => t.Unit)."; + + private const int MaxIterations = 3; + private static readonly Regex GeneralShortcodeRegex = new(@"%[^%]+%", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly HashSet SupportedStandardShortcodes = new(StringComparer.OrdinalIgnoreCase) { - private const string MissingJobWarningMsg = - "[{Caller}] Шаблон {TemplateId} не содержит Job в переданных данных. Данные будут догружены из БД. " + - "Рекомендуется обновить запрос шаблона с Include(j => j.Job)."; + "%ЭК%", "%ГРУППА_РАБОТ%", "%РАБОТА%", "%ТНК%", "%ТНК-КРАТКО%", "%ТИКТАК%", "%ИД%" + }; - private const string MissingIncludesWarningMsg = - "[{Caller}] Шаблон {TemplateId} содержит Job, но не хватает данных для шорткодов: {Shortcodes}. " + - "Отсутствуют Include: {MissingIncludes}. Данные будут догружены из БД. " + - "Рекомендуется обновить запрос шаблона."; + private readonly ILogger logger; + private readonly IEnumerable handlers; + private readonly IRedisCacheService cacheService; + private readonly SettingsFromDb settingsFromDb; + private readonly IJobRepository jobService; + private readonly IUnitRepository unitService; + private readonly IUnitFieldRepository unitFieldService; - private const string MissingUnitNameWarningMsg = - "[{Caller}] Шаблон {TemplateId} не содержит Unit.Name в переданных данных. Данные будут догружены из БД. " + - "Рекомендуется обновить запрос шаблона с Include(t => t.Unit)."; + public ShortcodesService( + ILogger logger, + IEnumerable handlers, + IRedisCacheService cacheService, + SettingsFromDb settingsFromDb, + IJobRepository jobService, + IUnitRepository unitService, + IUnitFieldRepository unitFieldService) + { + this.logger = logger; + this.handlers = handlers.OrderBy(h => h.Order).ToList(); + this.cacheService = cacheService; + this.settingsFromDb = settingsFromDb; + this.jobService = jobService; + this.unitService = unitService; + this.unitFieldService = unitFieldService; + } - private const int MaxIterations = 3; - private const string NoContent = "Нет данных"; - private static readonly Regex GeneralShortcodeRegex = new(@"%[^%\s]+%", RegexOptions.Compiled); - private static readonly Regex MaxShortcodeRegex = new(@"%МАКС:([а-яА-Яa-zA-Z0-9_]+)%", RegexOptions.Compiled); - private static readonly Regex LettersShortcodeRegex = new(@"%БУКВЫ:([^%]+)%", RegexOptions.Compiled); - private static readonly Regex RelShortcodeRegex = new(@"%СВЯЗЬ:([а-яА-Яa-zA-Z0-9_]+)%", RegexOptions.Compiled); + public async Task ApplyShortcodesAsync(string str, Template template, [CallerMemberName] string? caller = null) + { + if (string.IsNullOrEmpty(str)) return str; + var callerName = caller ?? "Unknown"; - private static readonly HashSet SupportedStandardShortcodes = new(StringComparer.OrdinalIgnoreCase) + var cacheKey = cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str }); + var cachedResult = await cacheService.GetCachedDataAsync(cacheKey).ConfigureAwait(false); + if (cachedResult != null) return cachedResult.Data.Result; + + var shortcodes = GeneralShortcodeRegex.Matches(str).Select(m => m.Value).ToList(); + var data = await PrepareTemplateDataAsync(template, shortcodes, callerName).ConfigureAwait(false); + + var result = str; + var iteration = 0; + + while (iteration < MaxIterations) { - "%ЭК%", "%ГРУППА_РАБОТ%", "%РАБОТА%", "%ТНК%", "%ТНК-КРАТКО%", "%ТИКТАК%", "%ИД%" - }; + var changed = false; - private readonly ILogger logger; - private readonly SettingsFromDb settingsFromDb; - private readonly IJobRepository jobService; - private readonly IUnitRepository unitService; - private readonly IUnitInValueRepository unitInValueService; - private readonly IUnitFieldRepository unitFieldService; - private readonly ITemplateRepository templateService; - private readonly IRedisCacheService cacheService; - private readonly IUnitFilterService unitFilterService; - - // Используем record struct вместо class - private record class TemplateForShortcodes( - Guid Id, - int? Index, - Guid JobId, - Guid UnitId, - JobForShortcodes? Job, - List UnitsInTemplate - ); - - private record class JobForShortcodes( - JobGroupForShortcodes? Group, - TnkForShortcodes? Tnk, - string WorkName, - string Name - ); - - private record class JobGroupForShortcodes( - Guid Id, - Guid? GroupingUnitFieldId, - JobGroupTypeForShortcodes? GroupType, - string GroupName - ); - - private record class JobGroupTypeForShortcodes( - JobGroupTypesEnum Code - ); - - private record class TnkForShortcodes( - string Name, - string ShortName - ); - - private record class UnitInTemplateForShortcodes( - Guid UnitId - ); - - public ShortcodesService( - ILogger logger, - SettingsFromDb settingsFromDb, - IJobRepository jobService, - IUnitRepository unitService, - IUnitFilterService unitFilterService, - IUnitInValueRepository unitInValueService, - IUnitFieldRepository unitFieldService, - ITemplateRepository templateService, - IRedisCacheService cacheService - ) - { - this.logger = logger; - this.settingsFromDb = settingsFromDb; - this.jobService = jobService; - this.unitService = unitService; - this.unitInValueService = unitInValueService; - this.unitFieldService = unitFieldService; - this.templateService = templateService; - this.cacheService = cacheService; - this.unitFilterService = unitFilterService; - } - - /// - /// Подставляет шорткоды в строке на основе данных шаблона. - /// - /// Строка с шорткодами - /// Шаблон, из которого берутся данные - /// Строка с подставленными значениями - public async Task ApplyShortcodesAsync(string str, Template template, [CallerMemberName] string? caller = null) - { - if (string.IsNullOrEmpty(str)) return str; - if (template == null) throw new ArgumentNullException(nameof(template)); - - var callerName = caller ?? "Unknown"; - - // Ключ: shortcodes_result_{TemplateIdN}_{Hash(str)} - var cacheKey = cacheService.GetKey( - new[] { "shortcodes", "result", template.Id.ToString("N") }, - new[] { str } - ); - - var cachedResult = await cacheService.GetCachedDataAsync(cacheKey).ConfigureAwait(false); - if (cachedResult != null) + foreach (var handler in handlers) { - logger.LogDebug("[{Caller}] Кэш попал для шаблона {TemplateId}", callerName, template.Id); - return cachedResult.Data.Result; - } - - logger.LogDebug("[{Caller}] Начата подстановка шорткодов. Вход: '{Input}', templateId={TemplateId}", callerName, str, template.Id); - - var result = str; - var iteration = 0; - - while (iteration < MaxIterations) - { - var shortcodes = GetShortCodes(result).Select(m => m.Value).ToList(); - if (!shortcodes.Any()) break; - - var data = await PrepareTemplateDataAsync(template, shortcodes, callerName).ConfigureAwait(false); - var oldResult = result; - - result = await ApplyAllShortcodesOnceAsync(result, data, shortcodes, callerName).ConfigureAwait(false); - iteration++; - - if (result == oldResult) break; - } - - // Сохранение в кэш (только если были изменения) - // Нельзя писать template.Id == Guid.Empty, так как id для новых шаблонов 0000-000-000000 и будет не верно реализован шорт-код - if (!string.IsNullOrEmpty(result) && result != str && template.Id != Guid.Empty) - { - var toCache = new ShortcodesResult + if (handler.Pattern.IsMatch(result)) { - Data = new ShortcodesResultDto { Result = result }, - Timestamp = DateTimeOffset.UtcNow, - Source = typeof(ShortcodesService).Name - }; - - await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromMinutes(15)).ConfigureAwait(false); - logger.LogDebug("[{Caller}] Результат сохранён в кэш для ключа '{Key}'", callerName, cacheKey); + var before = result; + result = await handler.ResolveAsync(result, data, callerName).ConfigureAwait(false); + if (result != before) changed = true; + } } - logger.LogDebug("[{Caller}] Подстановка завершена. Результат: '{Result}'", callerName, result); - return result; + if (!changed) break; + iteration++; } - private async Task PrepareTemplateDataAsync(Template template, List shortcodes, string caller) + if (iteration == MaxIterations && result != str) { - var data = new TemplateForShortcodes( - Id: template.Id, - Index: template.Index, - JobId: template.JobId, - UnitId: template.UnitId, - Job: null, - UnitsInTemplate: template.UnitsInTemplate?.Select(uit => new UnitInTemplateForShortcodes(uit.UnitId)).ToList() ?? new List() - ); + logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость шорткодов.", callerName, template.Id); + } - // Загружаем Unit.Name - var unitName = template.Unit?.Name ?? await GetUnitNameAsyncWithWarning(template, shortcodes, caller).ConfigureAwait(false); - - // Проверяем, нужны ли данные Job - if (!NeedsJobForShortcodes(shortcodes)) + if (!string.IsNullOrEmpty(result) && result != str && template.Id != Guid.Empty) + { + await cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult { - // Всё равно создаём пустой JobForShortcodes, чтобы не было null - data = data with { Job = new JobForShortcodes(null, null, string.Empty, string.Empty) }; - return data; - } + Data = new ShortcodesResultDto { Result = result }, + Timestamp = DateTimeOffset.UtcNow, + Source = nameof(ShortcodesService) + }, TimeSpan.FromMinutes(15)).ConfigureAwait(false); + } - if (template.Job == null) - { - logger.LogWarning(MissingJobWarningMsg, caller, template.Id); - var jobData = await LoadJobForShortcodesAsync(template.JobId, shortcodes, caller).ConfigureAwait(false); - return data with { Job = jobData }; - } + return result; + } + + private async Task PrepareTemplateDataAsync( + Template template, + List shortcodes, + string caller) + { + // 1. Загружаем имя юнита, если его нет + var unitName = template.Unit?.Name ?? await GetUnitNameAsyncWithWarning(template, shortcodes, caller).ConfigureAwait(false); + + // 2. Формируем базовые данные + var data = new TemplateForShortcode( + Id: template.Id, + Index: template.Index, + JobId: template.JobId, + UnitId: template.UnitId, + UnitName: unitName, + Job: null, + UnitsInTemplate: template.UnitsInTemplate?.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId)).ToList() ?? new List() + ); + + // 3. Проверяем, нужны ли вообще данные о работе + if (!NeedsJobForShortcodes(shortcodes)) + { + return data with { Job = new JobForShortcode(null, null, string.Empty, string.Empty) }; + } + + JobForShortcode jobData; + + // 4. Проверяем полноту контекста Job + if (!IsJobContextComplete(template.Job)) + { + // === Graceful Degradation + Structured Logging === + + var missingParts = new List(); + if (template.Job == null) missingParts.Add("Job"); else { - var missingIncludes = GetMissingIncludesForShortcodes(shortcodes, template.Job); - if (missingIncludes.Any()) - { - var problematicShortcodes = shortcodes.Where(sc => - (sc.Equals("%ГРУППА_РАБОТ%", StringComparison.OrdinalIgnoreCase) && template.Job.Group == null) || - (sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) && template.Job.Group?.GroupType == null) || - (sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) && template.Job.Group?.GroupType == null) || - (sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) && template.Job.Group?.GroupType == null) || - (sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase) && template.Job.Group?.GroupType == null) || - ((sc.Equals("%ТНК%", StringComparison.OrdinalIgnoreCase) || sc.Equals("%ТНК-КРАТКО%", StringComparison.OrdinalIgnoreCase)) && template.Job.Tnk == null) - ).ToList(); - - logger.LogWarning(MissingIncludesWarningMsg, caller, template.Id, string.Join(", ", problematicShortcodes), string.Join(" ", missingIncludes)); - - var jobData = await LoadJobForShortcodesAsync(template.JobId, shortcodes, caller).ConfigureAwait(false); - return data with { Job = jobData }; - } - else - { - var jobData = MapJobForShortcodes(template.Job); - return data with { Job = jobData }; - } + if (template.Job.Group == null) missingParts.Add("Group"); + if (template.Job.Group?.GroupType == null) missingParts.Add("Group.GroupType"); + if (template.Job.Tnk == null) missingParts.Add("Tnk"); } + + logger.LogWarning( + "[{Caller}] Шаблон {TemplateId} передан без необходимых Include для шорткодов. Отсутствуют: {MissingIncludes}. " + + "Данные будут догружены автоматически. Для оптимизации добавьте .Include() в запрос.", + caller, template.Id, string.Join(", ", missingParts)); + + // Делаем ОДИН пакетный запрос за всем недостающим + jobData = await LoadFullJobAsync(template.JobId, caller).ConfigureAwait(false); + } + else + { + // Данные полные, используем их без запросов к БД + jobData = MapJobForShortcodes(template.Job); } - private static bool NeedsJobForShortcodes(List shortcodes) + return data with { Job = jobData }; + } + + /// + /// Контракт полноты данных Job для движка шорткодов. + /// Если появятся новые зависимости (например, Job.AutoControl), добавляем проверку сюда. + /// + private static bool IsJobContextComplete(Job? job) + { + if (job == null) return false; + + // Базовые зависимости, необходимые для корректной работы стандартных и групповых шорткодов, + // а также для безопасного разрешения динамических цепочек. + return job.Group != null && + job.Group.GroupType != null && + job.Tnk != null; + } + + private async Task LoadFullJobAsync(Guid jobId, string caller) + { + var job = await jobService.Get().AsNoTracking() + .Include(j => j.Group).ThenInclude(g => g!.GroupType) + .Include(j => j.Tnk) + .FirstOrDefaultAsync(j => j.Id == jobId) + .ConfigureAwait(false); + + if (job == null) + throw new InvalidOperationException($"Job {jobId} не найден."); + + return MapJobForShortcodes(job); + } + + private static bool NeedsJobForShortcodes(List shortcodes) + { + return shortcodes.Any(sc => + sc != "%ЭК%" && + (SupportedStandardShortcodes.Contains(sc) || + sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || + sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase) || + sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || + sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || + sc.Equals("%СВЯЗИ%", StringComparison.OrdinalIgnoreCase) || + sc.Equals("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase))); + } + + + private async Task GetUnitNameAsyncWithWarning(Template template, List shortcodes, string caller) + { + if (shortcodes.Any(sc => sc.Equals("%ЭК%", StringComparison.OrdinalIgnoreCase))) { - return shortcodes.Any(sc => - sc != "%ЭК%" && // %ЭК% не требует Job - (SupportedStandardShortcodes.Contains(sc) || - sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || - sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%СВЯЗИ%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase))); + logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id); } - private static List GetMissingIncludesForShortcodes(List shortcodes, Job? job) + var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false); + if (unit == null || string.IsNullOrEmpty(unit.Name)) + throw new InvalidOperationException($"Unit {template.UnitId} не найден или не содержит Name."); + + return unit.Name; + } + + private JobForShortcode MapJobForShortcodes(Job job) + { + return new JobForShortcode( + Group: job.Group == null ? null : new JobGroupForShortcode( + Id: job.Group.Id, + GroupingUnitFieldId: job.Group.GroupingUnitFieldId, + GroupName: job.Group.GroupName ?? string.Empty, + GroupType: job.Group.GroupType == null ? null : new JobGroupTypeForShortcodes(job.Group.GroupType.Code) + ), + Tnk: job.Tnk == null ? null : new TnkForShortcode( + Name: job.Tnk.Name ?? string.Empty, + ShortName: job.Tnk.ShortName ?? string.Empty + ), + WorkName: job.WorkName ?? string.Empty, + Name: job.Name ?? string.Empty + ); + } + + + public async Task> GetAvailableShortcodesAsync() + { + var result = new List(); + + foreach (var constant in settingsFromDb.TemplateNameConstantPartsList) { - var missing = new List(); - - if (shortcodes.Any(sc => - sc.Equals("%ГРУППА_РАБОТ%", StringComparison.OrdinalIgnoreCase) || - sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || - sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase)) - && job?.Group == null) - { - missing.Add(".Include(j => j.Group)"); - } - - if (shortcodes.Any(sc => - sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || - sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase)) - && job?.Group?.GroupType == null) - { - missing.Add(".ThenInclude(g => g.GroupType)"); - } - - if (shortcodes.Any(sc => - sc.Equals("%ТНК%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ТНК-КРАТКО%", StringComparison.OrdinalIgnoreCase)) - && job?.Tnk == null) - { - missing.Add(".Include(j => j.Tnk)"); - } - - return missing; - } - - private async Task GetUnitNameAsyncWithWarning(Template template, List shortcodes, string caller) - { - if (shortcodes.Any(sc => sc.Equals("%ЭК%", StringComparison.OrdinalIgnoreCase))) - { - logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id); - } - - var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false); - if (unit == null || string.IsNullOrEmpty(unit.Name)) - throw new InvalidOperationException($"Unit {template.UnitId} не найден или не содержит Name."); - - return unit.Name; - } - - private JobForShortcodes MapJobForShortcodes(Job job) - { - return new JobForShortcodes( - Group: job.Group == null ? null : new JobGroupForShortcodes( - Id: job.Group.Id, - GroupingUnitFieldId: job.Group.GroupingUnitFieldId, - GroupName: job.Group.GroupName ?? string.Empty, - GroupType: job.Group.GroupType == null ? null : new JobGroupTypeForShortcodes(job.Group.GroupType.Code) - ), - Tnk: job.Tnk == null ? null : new TnkForShortcodes( - Name: job.Tnk.Name ?? string.Empty, - ShortName: job.Tnk.ShortName ?? string.Empty - ), - WorkName: job.WorkName ?? string.Empty, - Name: job.Name ?? string.Empty - ); - } - - private async Task LoadJobForShortcodesAsync(Guid jobId, List shortcodes, string caller) - { - var query = jobService.Get().AsNoTracking(); - - // Всегда загружаем Group и GroupType, если нужны групповые шорткоды - if (shortcodes.Any(sc => - sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || - sc.StartsWith("%СВЯЗЬ:", StringComparison.OrdinalIgnoreCase))) - { - query = query - .Include(j => j.Group) - .ThenInclude(g => g!.GroupType); - } - - // Для стандартных шорткодов тоже может понадобиться Group.GroupName - if (shortcodes.Any(sc => sc.Equals("%ГРУППА_РАБОТ%", StringComparison.OrdinalIgnoreCase))) - { - query = query - .Include(j => j.Group); - } - - // Для Tnk - if (shortcodes.Any(sc => - sc.Equals("%ТНК%", StringComparison.OrdinalIgnoreCase) || - sc.Equals("%ТНК-КРАТКО%", StringComparison.OrdinalIgnoreCase))) - { - query = query - .Include(j => j.Tnk); - } - - var job = await query.FirstOrDefaultAsync(j => j.Id == jobId).ConfigureAwait(false); - if (job == null) - throw new InvalidOperationException($"Job {jobId} не найден."); - - return MapJobForShortcodes(job); - } - - private async Task ApplyAllShortcodesOnceAsync(string str, TemplateForShortcodes data, List shortcodesInMask, string caller) - { - var result = str; - - // 1. Константы - var nameConstants = settingsFromDb.TemplateNameConstantPartsList; - if (shortcodesInMask.Any(m => nameConstants.Any(c => $"%{c.Name}%".Equals(m, StringComparison.OrdinalIgnoreCase)))) - { - result = ReplaceConstants(nameConstants, result); - } - - // 2. Стандартные - var hasStandardShortcodes = shortcodesInMask.Any(m => SupportedStandardShortcodes.Contains(m)); - if (hasStandardShortcodes) - { - var unitName = await GetUnitNameForTemplateAsync(data.UnitId).ConfigureAwait(false); - result = ReplaceStandardShortcodes(data.Job, unitName, result); - } - - // 3. GroupJob: %ИНДЕКС%, %МАКС:..., %ГР_ПОЛЕ-ПН% - if (shortcodesInMask.Any(m => string.Equals(m, "%ИНДЕКС%", StringComparison.OrdinalIgnoreCase))) - { - result = ReplaceSingleShortcode(result, "%ИНДЕКС%", data.Index?.ToString()); - } - - if (data.Job?.Group?.GroupType?.Code == JobGroupTypesEnum.Group) - { - var maxShortcodes = MaxShortcodeRegex.Matches(result); - if (maxShortcodes.Count > 0) - { - result = await ReplaceMaxShortcodesAsync(data.Job.Group.Id, data.UnitId, result, maxShortcodes, caller, data.UnitsInTemplate).ConfigureAwait(false); - } - } - - if (shortcodesInMask.Any(m => string.Equals(m, "%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase))) - { - if (data.Job?.Group == null) - { - logger.LogWarning("[{Caller}] Job не содержит Group, необходимый для %ГР_ПОЛЕ-ПН%. Шорткод пропущен.", caller); - } - else - { - List unitIds; - bool wasLoadedFromDb = false; - - if (data.UnitsInTemplate.Count == 0) - { - logger.LogWarning( - "[{Caller}] Template {TemplateId} не содержит UnitsInTemplate. Данные будут догружены из БД. " + - "Рекомендуется обновить запрос с Include(t => t.UnitsInTemplate).", - caller, data.Id); - - var fullTemplate = await templateService.Get() - .AsNoTracking() - .Include(t => t.UnitsInTemplate) - .FirstOrDefaultAsync(t => t.Id == data.Id) - .ConfigureAwait(false); - - unitIds = fullTemplate?.UnitsInTemplate?.Select(uit => uit.UnitId).ToList() ?? new List(); - wasLoadedFromDb = true; - } - else - { - unitIds = data.UnitsInTemplate.Select(uit => uit.UnitId).ToList(); - } - - if (unitIds.Count == 0) - { - logger.LogDebug("[{Caller}] Нет UnitsInTemplate для шаблона {TemplateId}. %ГР_ПОЛЕ-ПН% заменён на пустую строку.", caller, data.Id); - result = Regex.Replace(result, "%ГР_ПОЛЕ-ПН%", "", RegexOptions.IgnoreCase); - } - else - { - var units = await unitService.Get() - .AsNoTracking() - .Where(u => unitIds.Contains(u.Id)) - .ToDictionaryAsync(u => u.Id, u => u).ConfigureAwait(false); - - var groupingFieldId = data.Job.Group.GroupingUnitFieldId; - Dictionary valuesByUnit = new(); - - if (groupingFieldId.HasValue) - { - var fieldValues = await unitInValueService.Get() - .AsNoTracking() - .Include(uv => uv.Value) - .Where(uv => - uv.FieldId == groupingFieldId.Value && - unitIds.Contains(uv.UnitId) && - uv.Value != null && - !string.IsNullOrWhiteSpace(uv.Value.Value)) - .Select(uv => new { uv.UnitId, Value = uv.Value!.Value }) - .ToListAsync().ConfigureAwait(false); - - valuesByUnit = fieldValues - .GroupBy(x => x.UnitId) - .ToDictionary(g => g.Key, g => string.Join(", ", g.OrderBy(v => v.Value).Select(v => v.Value))); - } - - var actualUnitsInTemplate = unitIds.Select(id => new UnitInTemplateForShortcodes(id)).ToList(); - - var sortedUnitsInTemplate = actualUnitsInTemplate - .OrderBy(uit => units.TryGetValue(uit.UnitId, out var u) ? u.Name : uit.UnitId.ToString()) - .ToList(); - - var lines = sortedUnitsInTemplate - .Select((uit, i) => - { - var unitName = units.TryGetValue(uit.UnitId, out var u) ? u.Name : $"(UnitId={uit.UnitId})"; - var valuesStr = valuesByUnit.TryGetValue(uit.UnitId, out var vals) ? vals : ""; - return $"{i + 1}. {unitName} ({valuesStr})"; - }); - - result = Regex.Replace(result, "%ГР_ПОЛЕ-ПН%", string.Join("\n", lines), RegexOptions.IgnoreCase); - } - } - } - - // 4. СВЯЗЬ:ИМЯ_АТРИБУТА (только для групповых работ) - if (data.Job?.Group?.GroupType?.Code == JobGroupTypesEnum.Group) - { - var svyazShortcodes = RelShortcodeRegex.Matches(result); - if (svyazShortcodes.Count > 0) - { - result = await ReplaceRelShortcodesAsync(data.Id, data.UnitId, data.UnitsInTemplate, result, svyazShortcodes, caller).ConfigureAwait(false); - } - } - - // 5. Transform: %БУКВЫ:... - var lettersShortcodes = LettersShortcodeRegex.Matches(result); - if (lettersShortcodes.Count > 0) - { - result = await ReplaceLettersShortcodesAsync(data.UnitId, result, lettersShortcodes, caller).ConfigureAwait(false); - } - - // 6. %СВЯЗИ% и %СВЯЗИ-ПН% - List? relatedUnitNames = null; - if (shortcodesInMask.Any(m => string.Equals(m, "%СВЯЗИ%", StringComparison.OrdinalIgnoreCase))) - { - if (data.Job != null) - { - relatedUnitNames ??= await unitFilterService.GetRelatedUnitNamesAsync(data.JobId, data.UnitId).ConfigureAwait(false); - result = Regex.Replace(result, "%СВЯЗИ%", string.Join("\n", relatedUnitNames), RegexOptions.IgnoreCase); - } - } - if (shortcodesInMask.Any(m => string.Equals(m, "%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase))) - { - if (data.Job != null) - { - relatedUnitNames ??= await unitFilterService.GetRelatedUnitNamesAsync(data.JobId, data.UnitId).ConfigureAwait(false); - var numbered = relatedUnitNames.Select((name, i) => $"{i + 1}. {name}"); - result = Regex.Replace(result, "%СВЯЗИ-ПН%", string.Join("\n", numbered), RegexOptions.IgnoreCase); - } - } - - // 7. ИД шаблона - if (shortcodesInMask.Any(m => string.Equals(m, "%ИД%", StringComparison.OrdinalIgnoreCase))) - { - var templateIdWithoutDashes = data.Id.ToString("N"); - result = ReplaceSingleShortcode(result, "%ИД%", templateIdWithoutDashes); - } - - // 8. Поля - var fieldShortcodes = GetShortCodes(result); - if (fieldShortcodes.Count > 0) - result = await ReplaceFieldValues(data.UnitId, result, fieldShortcodes).ConfigureAwait(false); - - return result; - } - - private async Task GetUnitNameForTemplateAsync(Guid unitId) - { - var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == unitId).ConfigureAwait(false); - if (unit == null || string.IsNullOrEmpty(unit.Name)) - throw new InvalidOperationException($"Unit {unitId} не найден или не содержит Name."); - return unit.Name; - } - - private static List GetShortCodes(string resultName) - { - var shortcodesInMask = GeneralShortcodeRegex.Matches(resultName).ToList(); - return shortcodesInMask; - } - - public async Task> GetAvailableShortcodesAsync() - { - var result = new List(); - - // 1. Статические константы — из settingsFromDb - foreach (var constant in settingsFromDb.TemplateNameConstantPartsList) - { - result.Add(new ShortcodeInfoDto - { - Shortcode = $"%{constant.Name}%", - Description = $"Константа: {constant.Value ?? "(пусто)"}", - Type = ShortcodeTypeEnum.Static - }); - } - - // 2. Стандартные шорткоды - result.AddRange(new[] - { - new ShortcodeInfoDto { Shortcode = "%ЭК%", - Description = "Наименование ЭК(Код поиска)", - Type = ShortcodeTypeEnum.Standard }, - new ShortcodeInfoDto { Shortcode = "%ГРУППА_РАБОТ%", - Description = "Наименование группы работ", - Type = ShortcodeTypeEnum.Standard }, - new ShortcodeInfoDto { Shortcode = "%РАБОТА%", - Description = "Наименование работы в АСУ ЕСПП", - Type = ShortcodeTypeEnum.Standard }, - new ShortcodeInfoDto { Shortcode = "%ТНК%", - Description = "Полное наименование ТНК", - Type = ShortcodeTypeEnum.Standard }, - new ShortcodeInfoDto { Shortcode = "%ТНК-КРАТКО%", - Description = "Краткое наименование ТНК", - Type = ShortcodeTypeEnum.Standard }, - new ShortcodeInfoDto { Shortcode = "%ТИКТАК%", - Description = "Текущее время в формате Unix timestamp (секунды с 1970-01-01 UTC)", - Type = ShortcodeTypeEnum.Standard}, - new ShortcodeInfoDto { - Shortcode = "%ИД%", - Description = "Уникальный идентификатор шаблона (GUID без дефисов)", - Type = ShortcodeTypeEnum.Standard} - }); - - // 3. Групповые шорткоды - result.AddRange(new[] - { - new ShortcodeInfoDto { Shortcode = "%ИНДЕКС%", - Description = "Порядковый индекс шаблона для групповых работ", - Type = ShortcodeTypeEnum.GroupValue }, - new ShortcodeInfoDto { Shortcode = "%МАКС:ИМЯ АТРИБУТА%", - Description = "Используется только с групповым типом работ. Наиболее часто встречающееся значение поля в группе (игнорирует пустые). Пример: %МАКС:РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК%", - Type = ShortcodeTypeEnum.GroupValue }, - new ShortcodeInfoDto { Shortcode = "%ГР_ПОЛЕ-ПН%", - Description = "Нумерованный список связанных ЭК с основноым ЭК шаблона: 1. ЭК-123 (Значение1, Значение2). Использует имя поля группировки для вывода значений", - Type = ShortcodeTypeEnum.GroupValue }, - new ShortcodeInfoDto { - Shortcode = "%СВЯЗЬ:ИМЯ АТРИБУТА%", - Description = "Извлекает значения поля с именем ИМЯ АТРИБУТА из связанных ЭК, сортирует по алфавиту и берёт первое. Пример: %СВЯЗЬ:ЗОНА_ОТВЕТСТВЕННОСТИ%", - Type = ShortcodeTypeEnum.GroupValue} - }); - - // 4. Трансформирующие шорткоды result.Add(new ShortcodeInfoDto { - Shortcode = "%БУКВЫ:ИМЯ АТРИБУТА%", - Description = "Извлекает только буквы из значения поля. Пример: %БУКВЫ:ЗОНА_ОТВЕТСТВЕННОСТИ% → ПРИВ", - Type = ShortcodeTypeEnum.Transform + Shortcode = $"%{constant.Name}%", + Description = $"Константа: {constant.Value ?? "(пусто)"}", + Type = ShortcodeTypeEnum.Static }); + } - // 5. Связи - result.AddRange(new[] + result.AddRange(new[] + { + new ShortcodeInfoDto { Shortcode = "%ЭК%", Description = "Наименование ЭК(Код поиска)", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%ГРУППА_РАБОТ%", Description = "Наименование группы работ", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%РАБОТА%", Description = "Наименование работы в АСУ ЕСПП", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%ТНК%", Description = "Полное наименование ТНК", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%ТНК-КРАТКО%", Description = "Краткое наименование ТНК", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%ТИКТАК%", Description = "Текущее время в формате Unix timestamp", Type = ShortcodeTypeEnum.Standard }, + new ShortcodeInfoDto { Shortcode = "%ИД%", Description = "Уникальный идентификатор шаблона (GUID без дефисов)", Type = ShortcodeTypeEnum.Standard } + }); + + result.AddRange(new[] + { + new ShortcodeInfoDto { Shortcode = "%ИНДЕКС%", Description = "Порядковый индекс шаблона для групповых работ", Type = ShortcodeTypeEnum.GroupValue }, + new ShortcodeInfoDto { Shortcode = "%МАКС:ИМЯ АТРИБУТА%", Description = "Наиболее частое значение поля в группе", Type = ShortcodeTypeEnum.GroupValue }, + new ShortcodeInfoDto { Shortcode = "%ГР_ПОЛЕ-ПН%", Description = "Нумерованный список связанных ЭК со значениями", Type = ShortcodeTypeEnum.GroupValue }, + new ShortcodeInfoDto { Shortcode = "%СВЯЗЬ:ИМЯ АТРИБУТА%", Description = "Первое значение поля из связанных ЭК", Type = ShortcodeTypeEnum.GroupValue } + }); + + result.Add(new ShortcodeInfoDto + { + Shortcode = "%БУКВЫ:ИМЯ АТРИБУТА%", + Description = "Извлекает только буквы из значения поля", + Type = ShortcodeTypeEnum.Transform + }); + + result.AddRange(new[] + { + new ShortcodeInfoDto { Shortcode = "%СВЯЗИ%", Description = "Связанные ЭК (по одному на строку)", Type = ShortcodeTypeEnum.Relationship }, + new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией", Type = ShortcodeTypeEnum.Relationship } + }); + + var fieldNames = await unitFieldService.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 { - new ShortcodeInfoDto { Shortcode = "%СВЯЗИ%", - Description = "Связанные ЭК (по одному на строку), выбираются только при настроенном фильтре по полям в связанных ЭК", - Type = ShortcodeTypeEnum.Relationship }, - new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", - Description = "Связанные ЭК с нумерацией (1. ..., 2. ...), выбираются только при настроенном фильтре по полям в связанных ЭК", - Type = ShortcodeTypeEnum.Relationship }, + Shortcode = $"%{fieldName.AihitName}%", + Description = $"Атрибут: {fieldName.DisplayName ?? fieldName.AihitName}", + Type = ShortcodeTypeEnum.FieldValue }); - - // 6. Все доступные поля из UnitField - var fieldNames = await unitFieldService.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 - { - Shortcode = $"%{fieldName.AihitName}%", - Description = $"Атрибут: {fieldName.DisplayName ?? fieldName.AihitName}", - Type = ShortcodeTypeEnum.FieldValue - }); - } - return result; } - private async Task ReplaceFieldValues(Guid unitId, string resultName, List shortcodesInMask) - { - var requiredFieldNames = shortcodesInMask - .Select(m => m.Value.Trim('%').ToUpperInvariant()) - .ToList(); - - if (requiredFieldNames.Count == 0) - return resultName; - - var fieldValues = await unitInValueService.GetFieldValuesAsync(unitId, requiredFieldNames).ConfigureAwait(false); - - var fieldValuesMap = fieldValues - .GroupBy(x => x.FieldName, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - g => g.Key, - g => g.Select(x => x.Value).ToList(), - StringComparer.OrdinalIgnoreCase); - - foreach (var match in shortcodesInMask) - { - var fieldName = match.Value.Trim('%').ToUpperInvariant(); - - if (fieldValuesMap.TryGetValue(fieldName, out var values)) - { - var combinedValue = string.Join(", ", values.Select(v => v ?? "null")); - resultName = resultName.Replace(match.Value, combinedValue); - } - else - { - logger.LogWarning("Поле '{FieldName}' не найдено для unitId={UnitId} при подстановке шорткода '{Shortcode}'", - fieldName, unitId, match.Value); - } - } - - return resultName; - } - - private static string ReplaceStandardShortcodes(JobForShortcodes? job, string unitName, string input) - { - var result = input; - - // %ЭК% — заменяем, только если есть в строке - if (result.Contains("%ЭК%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%ЭК%", unitName); - } - - // %ТИКТАК% — не зависит от Job или Unit - if (result.Contains("%ТИКТАК%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%ТИКТАК%", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()); - } - - if (job != null) - { - if (result.Contains("%РАБОТА%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%РАБОТА%", job.WorkName); - } - - if (result.Contains("%ГРУППА_РАБОТ%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%ГРУППА_РАБОТ%", job.Group?.GroupName); - } - - if (result.Contains("%ТНК%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%ТНК%", job.Tnk?.Name); - } - - if (result.Contains("%ТНК-КРАТКО%", StringComparison.OrdinalIgnoreCase)) - { - result = ReplaceSingleShortcode(result, "%ТНК-КРАТКО%", job.Tnk?.ShortName); - } - } - - return result; - } - - private static string ReplaceSingleShortcode(string input, string shortcode, string? replacement) - { - if (replacement == null) - { - throw new InvalidOperationException($"Шорткод '{shortcode}' требует непустое значение, но подставляемое значение равно null."); - } - - return input.Replace(shortcode, replacement, StringComparison.OrdinalIgnoreCase); - } - - private static string ReplaceConstants(List nameConstants, string resultName) - { - foreach (var item in nameConstants) - resultName = resultName.Replace($"%{item.Name}%", item.Value); - - return resultName; - } - - private async Task ReplaceMaxShortcodesAsync( - Guid jobGroupId, - Guid unitId, - string input, - MatchCollection maxShortcodes, - string caller, - List unitsInTemplateForCurrentTemplate = null) - { - if (jobGroupId == Guid.Empty) - { - logger.LogWarning("[{Caller}] jobGroupId не задан. Пропускаем обработку %МАКС:...%", caller); - return input; - } - - if (unitId == Guid.Empty) - { - logger.LogWarning("[{Caller}] unitId не задан. Пропускаем обработку %МАКС:...%", caller); - return input; - } - - var shortcodeToMatches = maxShortcodes - .Cast() - .GroupBy(m => m.Value, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var kvp in shortcodeToMatches) - { - var fullShortcode = kvp.Key; - var matches = kvp.Value; - var fieldName = fullShortcode.Trim('%').Split(':', 2)[1].Trim(); - - logger.LogDebug("[{Caller}] Обработка {Shortcode} для JobGroup {JobGroupId}, Template.UnitId {UnitId}, fieldName {FieldName}", - caller, fullShortcode, jobGroupId, unitId, fieldName); - - var mostFrequentValue = await GetMaxShortCodeFromCacheOrDbAsync(jobGroupId, unitId, fullShortcode, fieldName, caller, unitsInTemplateForCurrentTemplate).ConfigureAwait(false); - - foreach (var match in matches) - input = input.Replace(match.Value, mostFrequentValue); - } - - return input; - } - - private async Task GetMaxShortCodeFromCacheOrDbAsync( - Guid jobGroupId, - Guid unitId, - string fullShortcode, - string fieldName, - string caller, - List unitsInTemplateForCurrentTemplate = null) - { - if (jobGroupId == Guid.Empty) - { - logger.LogWarning("[{Caller}] jobGroupId не задан. Пропускаем обработку шорткода {Shortcode}", caller, fullShortcode); - return NoContent; - } - - if (unitId == Guid.Empty) - { - logger.LogWarning("[{Caller}] unitId не задан. Пропускаем обработку шорткода {Shortcode}", caller, fullShortcode); - return NoContent; - } - - //var cacheKey = $"gr_shcd_{jobGroupId:N}_{unitId:N}_{ComputeHash(fullShortcode)}"; - var cacheKey = cacheService.GetKey(new[] { "grouped", "shortcode", $"{jobGroupId:N}", $"{unitId:N}" }, new[] { fullShortcode }); - - var cachedData = await cacheService.GetCachedDataAsync(cacheKey).ConfigureAwait(false); - if (cachedData != null) - { - logger.LogDebug("[{Caller}] Кэш попал для GroupedShortCode '{Name}': {Value}", caller, fullShortcode, cachedData.Data.Value); - return cachedData.Data.Value; - } - - logger.LogDebug("[{Caller}] Кэш промахнут для GroupedShortCode '{Name}'. Запрашиваем из БД.", caller, fullShortcode); - - var effectiveUnitIds = await GetEffectiveUnitIdsAsync(jobGroupId, unitId, unitsInTemplateForCurrentTemplate, caller).ConfigureAwait(false); - - logger.LogDebug("[{Caller}] EffectiveUnitIds: [{Ids}], Count: {Count}", caller, string.Join(", ", effectiveUnitIds), effectiveUnitIds.Count); - - // Вызываем метод из сервиса - var mostFrequentValue = await unitInValueService.GetMostFrequentValueForFieldAsync(effectiveUnitIds, fieldName).ConfigureAwait(false); - - logger.LogDebug("[{Caller}] Результат GetMostFrequentValueForFieldAsync: {Result}, для поля {FieldName}, unitIds: [{Ids}]", caller, mostFrequentValue, fieldName, string.Join(", ", effectiveUnitIds)); - - // Не кэшируем пустые значения - if (!string.IsNullOrEmpty(mostFrequentValue)) - { - var toCache = new GroupedShortcode - { - Data = new GroupedShortcodeDto { Value = mostFrequentValue }, - Timestamp = DateTimeOffset.UtcNow, - Source = typeof(ShortcodesService).Name - }; - await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)).ConfigureAwait(false); - return mostFrequentValue; - } - - return NoContent; - } - - /// - /// Получает список UnitId для подстановки шорткода %МАКС:...%. - /// Сначала пробует использовать переданные юниты, затем загружает из БД. - /// - /// ID JobGroup, для которого ищутся юниты - /// ID ключевого юнита шаблона - /// Юниты, переданные с текущим шаблоном (опционально) - /// Имя вызывающего метода (для логирования) - /// Список UnitId - private async Task> GetEffectiveUnitIdsAsync( - Guid jobGroupId, - Guid unitId, - List unitsInTemplateForCurrentTemplate, - string caller) - { - if (unitsInTemplateForCurrentTemplate != null && unitsInTemplateForCurrentTemplate.Count > 0) - { - logger.LogDebug("[{Caller}] Используем UnitsInTemplate из входных данных.", caller); - return unitsInTemplateForCurrentTemplate.Select(uit => uit.UnitId).ToList(); - } - - logger.LogDebug("[{Caller}] Загружаем UnitsInTemplate из БД.", caller); - - var effectiveUnitIds = await jobService.Get() - .Where(j => j.GroupId == jobGroupId) - .Join( - templateService.Get() - .Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used && t.UnitId == unitId) - .Include(t => t.UnitsInTemplate), - job => job.Id, - template => template.JobId, - (job, template) => template - ) - .SelectMany(template => template.UnitsInTemplate) - .Select(uit => uit.UnitId) - .Distinct() - .ToListAsync().ConfigureAwait(false); - - return effectiveUnitIds; - } - - private async Task ReplaceRelShortcodesAsync( - Guid templateId, - Guid templateUnitId, - List unitsInTemplate, - string input, - MatchCollection relShortcodes, - string caller) - { - var shortcodeToMatches = relShortcodes - .Cast() - .GroupBy(m => m.Value, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var kvp in shortcodeToMatches) - { - var fullShortcode = kvp.Key; - var matches = kvp.Value; - var fieldName = fullShortcode.Trim('%').Split(':', 2)[1].Trim(); - - logger.LogDebug("[{Caller}] Обработка {Shortcode} для шаблона с UnitId {UnitId}, fieldName {FieldName}", caller, templateUnitId, fieldName); - - List unitIds; - bool wasLoadedFromDb = false; - - if (unitsInTemplate.Count > 0) - { - unitIds = unitsInTemplate.Select(uit => uit.UnitId).ToList(); - } - else - { - logger.LogWarning( - "[{Caller}] Шаблон с UnitId {UnitId} не содержит UnitsInTemplate. Данные будут догружены из БД. " + - "Рекомендуется обновить запрос с Include(t => t.UnitsInTemplate).", - caller, templateUnitId); - - var fullTemplate = await templateService.Get() - .AsNoTracking() - .Include(t => t.UnitsInTemplate) - .FirstOrDefaultAsync(t => t.Id == templateId) - .ConfigureAwait(false); - - unitIds = fullTemplate?.UnitsInTemplate?.Select(uit => uit.UnitId).ToList() ?? new List(); - wasLoadedFromDb = true; - } - - if (unitIds.Count == 0) - { - logger.LogDebug("[{Caller}] Нет UnitsInTemplate для шаблона {TemplateId}. Шорткод {Shortcode} пропущен.", caller, templateUnitId, fullShortcode); - foreach (var match in matches) - input = input.Replace(match.Value, ""); - continue; - } - - var allFieldValues = new List<(string FieldName, string? Value)>(); - - foreach (var unitId in unitIds) - { - var values = await unitInValueService.GetFieldValuesAsync(unitId, new List { fieldName }).ConfigureAwait(false); - allFieldValues.AddRange(values.Select(v => (v.FieldName, v.Value))); - } - - if (allFieldValues == null || !allFieldValues.Any()) - { - logger.LogDebug("[{Caller}] Для шорткода {Shortcode} не найдено значений поля {FieldName} среди UnitsInTemplate.", caller, fullShortcode, fieldName); - foreach (var match in matches) - input = input.Replace(match.Value, ""); - continue; - } - - var distinctSortedValues = allFieldValues - .Where(fv => !string.IsNullOrEmpty(fv.Value)) - .Select(fv => fv.Value!) - .Distinct() - .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (distinctSortedValues.Count == 0) - { - logger.LogDebug("[{Caller}] Для шорткода {Shortcode} все значения поля {FieldName} пустые.", caller, fullShortcode, fieldName); - foreach (var match in matches) - input = input.Replace(match.Value, ""); - continue; - } - - if (distinctSortedValues.Count > 1) - { - logger.LogWarning( - "[{Caller}] Шорткод {Shortcode} нашёл {Count} различных значений поля {FieldName}: [{Values}]. Используется первое: '{FirstValue}'", - caller, fullShortcode, distinctSortedValues.Count, fieldName, - string.Join(", ", distinctSortedValues), - distinctSortedValues[0]); - } - - var firstValue = distinctSortedValues[0]; - - foreach (var match in matches) - input = input.Replace(match.Value, firstValue); - } - - return input; - } - - private async Task ReplaceLettersShortcodesAsync(Guid unitId, string input, MatchCollection lettersShortcodes, string caller) - { - var shortcodeToMatches = lettersShortcodes - .Cast() - .GroupBy(m => m.Value, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var kvp in shortcodeToMatches) - { - var fullShortcode = kvp.Key; - var matches = kvp.Value; - var fieldName = fullShortcode.Trim('%').Split(':', 2)[1].Trim(); - - logger.LogDebug("[{Caller}] Обработка {Shortcode} для unitId {UnitId}, fieldName {FieldName}", caller, fullShortcode, unitId, fieldName); - - var fieldValues = await unitInValueService.GetFieldValuesAsync(unitId, new List { fieldName }).ConfigureAwait(false); - - string extractedLetters = string.Empty; - - if (fieldValues.Any()) - { - var value = fieldValues.First().Value; - if (value != null) - { - extractedLetters = ExtractLettersOnly(value); - logger.LogDebug("[{Caller}] Извлечены буквы: '{Letters}' из значения '{Value}'", caller, extractedLetters, value); - } - } - - if (string.IsNullOrEmpty(extractedLetters)) - { - logger.LogDebug("[{Caller}] Для шорткода {Shortcode} не найдено подходящее значение или из него нельзя извлечь буквы", caller, fullShortcode); - } - - foreach (var match in matches) - { - input = input.Replace(match.Value, extractedLetters); - } - } - - return input; - } - - private static string ExtractLettersOnly(string input) - { - var result = new System.Text.StringBuilder(); - foreach (char c in input) - { - if (char.IsLetter(c)) - { - result.Append(c); - } - } - return result.ToString(); - } + return result; } } \ No newline at end of file diff --git a/PARR.DAL/Repositories/Unit/UnitInValueRepository.cs b/PARR.DAL/Repositories/Unit/UnitInValueRepository.cs index c7d5d0c2..d2b44e7e 100644 --- a/PARR.DAL/Repositories/Unit/UnitInValueRepository.cs +++ b/PARR.DAL/Repositories/Unit/UnitInValueRepository.cs @@ -20,26 +20,27 @@ namespace PARR.DAL.Repositories.Unit this.dataContext = dataContext; } - public async Task> GetByUnitIdAsync(Guid unitId) + public async Task> GetByUnitIdAsync(Guid unitId, CancellationToken ct = default) { return await dataContext.UnitInValues.AsNoTracking() .Include(t => t.Value) .Where(uv => uv.UnitId == unitId) - .ToListAsync(); + .ToListAsync(ct); } - public async Task> GetByUnitIdsAsync(IEnumerable unitIds) + public async Task> GetByUnitIdsAsync(IEnumerable unitIds, CancellationToken ct = default) { return await dataContext.UnitInValues.AsNoTracking() .Include(t => t.Value) .Where(uv => unitIds.Contains(uv.UnitId)) - .ToListAsync(); + .ToListAsync(ct); } public async Task> GetFieldValuesAsync( Guid unitId, - IReadOnlyCollection aihitNames) + IReadOnlyCollection aihitNames + , CancellationToken ct = default) { if (aihitNames == null || aihitNames.Count == 0) return new List<(string, string?)>(); @@ -52,7 +53,7 @@ namespace PARR.DAL.Repositories.Unit && uiv.Value != null && aihitNames.Contains(uiv.Field.AihitName.ToUpper())) .Select(uiv => new { Key = uiv.Field!.AihitName.ToUpper(), Value = uiv.Value!.Value }) - .ToListAsync(); + .ToListAsync(ct); // Журналируем если не нашли поля var foundFieldNames = keyValuePairs.Select(kvp => kvp.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -69,7 +70,7 @@ namespace PARR.DAL.Repositories.Unit } - public async Task> GetByUnitIdsAndFieldIdsAsync(IEnumerable unitIds, IEnumerable fieldIds) + public async Task> GetByUnitIdsAndFieldIdsAsync(IEnumerable unitIds, IEnumerable fieldIds, CancellationToken ct = default) { var unitIdSet = unitIds.ToHashSet(); var fieldIdSet = fieldIds.ToHashSet(); @@ -78,7 +79,7 @@ namespace PARR.DAL.Repositories.Unit .AsNoTracking() .Include(uv => uv.Value) .Where(uv => unitIdSet.Contains(uv.UnitId) && fieldIdSet.Contains(uv.FieldId)) - .ToListAsync(); + .ToListAsync(ct); } public IQueryable Get() @@ -86,7 +87,7 @@ namespace PARR.DAL.Repositories.Unit return dataContext.UnitInValues; } - public async Task GetMostFrequentValueForFieldAsync(List unitIds, string fieldName) + public async Task GetMostFrequentValueForFieldAsync(List unitIds, string fieldName, CancellationToken ct) { if (unitIds == null || !unitIds.Any() || string.IsNullOrWhiteSpace(fieldName)) { @@ -121,7 +122,7 @@ namespace PARR.DAL.Repositories.Unit .Select(g => new { Value = g.Key, Count = g.Count() }) .OrderByDescending(x => x.Count) .ThenBy(x => x.Value) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(ct); var mostFrequentValue = result?.Value; logger.LogDebug("Наиболее частое значение для поля '{FieldName}': {Value}", fieldName, mostFrequentValue); @@ -131,16 +132,6 @@ namespace PARR.DAL.Repositories.Unit public IQueryable GetMatchingTargetIds(Guid fieldId, string valueMask) { - //TODO: Вынесено из UnitFilterService - - //var matchingTargetIds = await unitInValueService.Get() - // .AsNoTracking() - // .Where(uiv => uiv.FieldId == relFilter.FieldId) - // .Where(uiv => EF.Functions.ILike(uiv.Value.Value, dbValueMask)) - // .Select(uiv => uiv.UnitId) - // .Distinct() - // .ToListAsync(cancellationToken); - return Get() .AsNoTracking() .Where(uiv => uiv.FieldId == fieldId) @@ -152,4 +143,3 @@ namespace PARR.DAL.Repositories.Unit } } -