using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.Constants; using PARR.DAL.Cache.Services.Base; using PARR.DAL.Contracts; using PARR.DAL.DomainModels; using PARR.DAL.DomainServices.Shortcodes.Models; using PARR.DAL.DomainServices.UnitFilterService; using PARR.DAL.Models; using PARR.DAL.Models.Job; using PARR.DAL.Services.Interfaces; using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace PARR.DAL.DomainServices.Shortcodes { /// /// Сервис для подстановки шорткодов в строке на основе данных шаблона. /// internal class ShortcodesService : IShortcodesService { private const string MissingJobWarningMsg = "[{Caller}] Шаблон {TemplateId} не содержит Job в переданных данных. Данные будут догружены из БД. " + "Рекомендуется обновить запрос шаблона с Include(j => j.Job)."; private const string MissingIncludesWarningMsg = "[{Caller}] Шаблон {TemplateId} содержит Job, но не хватает данных для шорткодов: {Shortcodes}. " + "Отсутствуют Include: {MissingIncludes}. Данные будут догружены из БД. " + "Рекомендуется обновить запрос шаблона."; private const string MissingUnitNameWarningMsg = "[{Caller}] Шаблон {TemplateId} не содержит Unit.Name в переданных данных. Данные будут догружены из БД. " + "Рекомендуется обновить запрос шаблона с Include(t => t.Unit)."; 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 HashSet SupportedStandardShortcodes = new(StringComparer.OrdinalIgnoreCase) { "%ЭК%", "%ГРУППА_РАБОТ%", "%РАБОТА%", "%ТНК%", "%ТНК-КРАТКО%", "%ТИКТАК%" }; private readonly ILogger logger; private readonly SettingsFromDb settingsFromDb; private readonly IJobService jobService; private readonly IUnitService unitService; private readonly IUnitInValueService unitInValueService; private readonly IUnitFieldService unitFieldService; private readonly ITemplateService 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, IJobService jobService, IUnitService unitService, IUnitFilterService unitFilterService, IUnitInValueService unitInValueService, IUnitFieldService unitFieldService, ITemplateService 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"; 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; } logger.LogDebug("[{Caller}] Подстановка завершена. Результат: '{Result}'", callerName, result); return result; } private async Task PrepareTemplateDataAsync(Template template, List shortcodes, string caller) { 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() ); // Загружаем Unit.Name var unitName = template.Unit?.Name ?? await GetUnitNameAsyncWithWarning(template, shortcodes, caller).ConfigureAwait(false); // Проверяем, нужны ли данные Job if (!NeedsJobForShortcodes(shortcodes)) { // Всё равно создаём пустой JobForShortcodes, чтобы не было null data = data with { Job = new JobForShortcodes(null, null, string.Empty, string.Empty) }; return data; } 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 }; } 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.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 }; } } } private static bool NeedsJobForShortcodes(List shortcodes) { return shortcodes.Any(sc => sc != "%ЭК%" && // %ЭК% не требует Job (SupportedStandardShortcodes.Contains(sc) || sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || sc.Equals("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase) || sc.Equals("%СВЯЗИ%", StringComparison.OrdinalIgnoreCase) || sc.Equals("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase))); } private static List GetMissingIncludesForShortcodes(List shortcodes, Job? job) { var missing = new List(); if (shortcodes.Any(sc => sc.Equals("%ГРУППА_РАБОТ%", StringComparison.OrdinalIgnoreCase) || sc.StartsWith("%МАКС:", StringComparison.OrdinalIgnoreCase) || sc.Equals("%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase) || sc.Equals("%ИНДЕКС%", 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)) && 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))) { 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).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. Transform: %БУКВЫ:... var lettersShortcodes = LettersShortcodeRegex.Matches(result); if (lettersShortcodes.Count > 0) { result = await ReplaceLettersShortcodesAsync(data.UnitId, result, lettersShortcodes, caller).ConfigureAwait(false); } // 5. %СВЯЗИ% и %СВЯЗИ-ПН% 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); } } // 6. Поля 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 } }); // 3. Групповые шорткоды result.AddRange(new[] { new ShortcodeInfoDto { Shortcode = "%ИНДЕКС%", Description = "Порядковый индекс шаблона для групповых работ", Type = ShortcodeTypeEnum.GroupValue }, new ShortcodeInfoDto { Shortcode = "%МАКС:ИМЯ АТРИБУТА%", Description = "Используется только с групповым типом работ. Наиболее часто встречающееся значение поля в группе (игнорирует пустые). Пример: %МАКС:РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК%", Type = ShortcodeTypeEnum.GroupValue }, new ShortcodeInfoDto { Shortcode = "%ГР_ПОЛЕ-ПН%", Description = "Нумерованный список unit-ов из шаблона: 1. ЭК-123 (Значение1, Значение2). Использует GroupingUnitFieldId из JobGroup.", Type = ShortcodeTypeEnum.GroupValue }, }); // 4. Трансформирующие шорткоды result.Add(new ShortcodeInfoDto { Shortcode = "%БУКВЫ:ИМЯ АТРИБУТА%", Description = "Извлекает только буквы из значения поля. Пример: %БУКВЫ:ЗОНА_ОТВЕТСТВЕННОСТИ% → ПРИВ", Type = ShortcodeTypeEnum.Transform }); // 5. Связи result.AddRange(new[] { new ShortcodeInfoDto { Shortcode = "%СВЯЗИ%", Description = "Связанные ЭК (по одному на строку), выбираются только при настроенном фильтре по полям в связанных ЭК", Type = ShortcodeTypeEnum.Relationship }, new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией (1. ..., 2. ...), выбираются только при настроенном фильтре по полям в связанных ЭК", Type = ShortcodeTypeEnum.Relationship }, }); // 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) { 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).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) { 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 cachedData = await cacheService.GetCachedDataAsync(cacheKey).ConfigureAwait(false); if (cachedData != null) { logger.LogDebug("[{Caller}] Кэш попал для GroupedShortCode '{Name}': {Value}", caller, fullShortcode, cachedData.Value); return cachedData.Value; } logger.LogDebug("[{Caller}] Кэш промахнут для GroupedShortCode '{Name}'. Запрашиваем из БД.", caller, fullShortcode); // Загружаем юниты из БД 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); 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 CachedGroupedShortCode { Value = mostFrequentValue, Timestamp = DateTimeOffset.UtcNow, Source = typeof(ShortcodesService).Name, Version = 1 }; await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)).ConfigureAwait(false); return mostFrequentValue; } return NoContent; } 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(); } private static string ComputeHash(string input) { using var sha256 = System.Security.Cryptography.SHA256.Create(); var hashedBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); return Convert.ToBase64String(hashedBytes).Replace('+', '-').Replace('/', '_').Substring(0, 16); } } }