using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PARR.Constants; using PARR.DAL.CacheServices; using PARR.DAL.Contracts; using PARR.DAL.DomainModels; using PARR.DAL.DomainServices.Interfaces; using PARR.DAL.DomainServices.Shortcodes.Models; using PARR.DAL.Models.Unit; using PARR.DAL.Services.Interfaces; using PARR.DAL.Services.Interfaces.Job; using PARR.DAL.Services.Interfaces.Unit; using System.Text.RegularExpressions; namespace PARR.DAL.DomainServices.Shortcodes { internal class ShortcodesService : IShortcodesService { private const string shortcodePattern = "%[^%\\s]+%"; private const string maxShortcodePattern = @"%МАКС:([а-яА-Яa-zA-Z0-9_]+)%"; private const string lettersShortcodePattern = @"%БУКВЫ:([^%]+)%"; private static readonly HashSet SupportedShortcodes = 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; 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, TemplateForShortcodes template) { logger.LogDebug("Начата подстановка шорткодов. Вход: '{Input}', templateId={TemplateId}, index={Index}", str, template.Id, template.Index); var job = template.Job; if (job == null) { logger.LogError("Шаблон {TemplateId} не содержит Job. Подстановка прервана.", template.Id); return str; } var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId); if (unit == null || string.IsNullOrEmpty(str)) { logger.LogError("Переданы некорректные данные для подстановки динамических записей"); return str; } var resultName = str; var shortcodesInMask = GetShortCodes(resultName); // 1. Статические константы var nameConstants = settingsFromDb.TemplateNameConstantPartsList; if (shortcodesInMask.Any(m => nameConstants.Any(c => $"%{c.Name}%".Equals(m.Value, StringComparison.OrdinalIgnoreCase)))) { resultName = ReplaceConstants(nameConstants, resultName); } // 2. Стандартные шорткоды — с поддержкой вложенных (%РАБОТА% → "Мониторинг | %ЭК%") const int MaxStandardIterations = 3; var iteration = 0; while (iteration < MaxStandardIterations) { var remainingShortcodes = GetShortCodes(resultName) .Select(m => m.Value) .Where(s => SupportedShortcodes.Contains(s)) .ToList(); if (!remainingShortcodes.Any()) break; var oldResult = resultName; resultName = ReplaceStandardShortcodes(job, unit, resultName, template.Index); iteration++; if (resultName == oldResult) { logger.LogDebug("Замена стандартных шорткодов не изменила строку на итерации {Iteration}. Останов.", iteration); break; } } if (iteration >= MaxStandardIterations) { logger.LogWarning( "Достигнуто максимальное число итераций ({Max}) при замене стандартных шорткодов. Текущий результат: {Result}", MaxStandardIterations, resultName); } // 2.5. %МАКС:FIELD% — только для групповых job (JobGroup.Type == Group) if (job.Group != null && job.Group.GroupType != null && job.Group.GroupType!.Code == JobGroupTypesEnum.Group) { var maxShortcodes = Regex.Matches(resultName, maxShortcodePattern); if (maxShortcodes.Count > 0) { resultName = await ReplaceMaxShortcodesAsync(job.Group.Id, template.UnitId, resultName, maxShortcodes); } } // 2.6. %БУКВЫ:FIELD% — извлекает только буквы из значения поля var lettersShortcodes = Regex.Matches(resultName, lettersShortcodePattern); if (lettersShortcodes.Count > 0) { resultName = await ReplaceLettersShortcodesAsync(template.UnitId, resultName, lettersShortcodes); } // 2.7. %ГР_ПОЛЕ-ПН% — нумерованный список UnitsInTemplate с GroupingUnitFieldId, отсортированный по имени юнита if (shortcodesInMask.Any(m => string.Equals(m.Value, "%ГР_ПОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase))) { var unitsInTemplate = template.UnitsInTemplate; if (unitsInTemplate == null || !unitsInTemplate.Any()) { logger.LogDebug("Шаблон {TemplateId} не содержит UnitsInTemplate. %ГР_ПОЛЕ-ПН% заменён на пустую строку.", template.Id); resultName = Regex.Replace(resultName, "%ГР_ПОЛЕ-ПН%", "", RegexOptions.IgnoreCase); } else { var unitIds = unitsInTemplate.Select(uit => uit.UnitId).ToList(); var units = await unitService.Get() .AsNoTracking() .Where(u => unitIds.Contains(u.Id)) .ToListAsync(); // Словарь: UnitId -> Unit (для быстрого поиска) var unitDict = units.ToDictionary(u => u.Id, u => u); var groupingFieldId = 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(); valuesByUnit = fieldValues .GroupBy(x => x.UnitId) .ToDictionary( g => g.Key, g => string.Join(", ", g.Select(v => v.Value).OrderBy(v => v)) ); } // Сортируем UnitsInTemplate по имени юнита var sortedUnitsInTemplate = unitsInTemplate .OrderBy(uit => unitDict.TryGetValue(uit.UnitId, out var unit) ? unit.Name : $"(UnitId={uit.UnitId})") .ToList(); var lines = sortedUnitsInTemplate .Select((uit, indexInList) => { var unitInList = unitDict.TryGetValue(uit.UnitId, out var unit) ? unit : null; var unitName = unitInList?.Name ?? $"(UnitId={uit.UnitId})"; var valuesStr = valuesByUnit.TryGetValue(uit.UnitId, out var vals) ? vals : ""; return $"{indexInList + 1}. {unitName} ({valuesStr})"; }) .ToList(); var resultText = string.Join("\n", lines); resultName = Regex.Replace(resultName, "%ГР_ПОЛЕ-ПН%", resultText, RegexOptions.IgnoreCase); } } // 3. %СВЯЗИ% или %СВЯЗИ-ПН% (если всё ещё зависят от jobId/unitId) List? relatedUnitNames = null; if (shortcodesInMask.Any(m => string.Equals(m.Value, "%СВЯЗИ%", StringComparison.OrdinalIgnoreCase))) { relatedUnitNames ??= await unitFilterService.GetRelatedUnitNamesAsync(template.JobId, template.UnitId); var linksText = string.Join("\n", relatedUnitNames); resultName = Regex.Replace(resultName, "%СВЯЗИ%", linksText, RegexOptions.IgnoreCase); } if (shortcodesInMask.Any(m => string.Equals(m.Value, "%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase))) { relatedUnitNames ??= await unitFilterService.GetRelatedUnitNamesAsync(template.JobId, template.UnitId); var linksText = string.Join("\n", relatedUnitNames.Select((name, i) => $"{i + 1}. {name}")); resultName = Regex.Replace(resultName, "%СВЯЗИ-ПН%", linksText, RegexOptions.IgnoreCase); } // 4. Поля (оставшиеся %FIELD_NAME%) shortcodesInMask = GetShortCodes(resultName); if (shortcodesInMask.Count > 0) resultName = await ReplaceFieldValues(template.UnitId, resultName, shortcodesInMask); logger.LogDebug("Подстановка завершена. Результат: '{Result}'", resultName); return resultName; } public bool IsAnyShortcodes(string str) { return Regex.IsMatch(str, shortcodePattern) || Regex.IsMatch(str, maxShortcodePattern) || Regex.IsMatch(str, lettersShortcodePattern); } private static List GetShortCodes(string resultName) { var shortcodesInMask = Regex.Matches(resultName, shortcodePattern).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.Standart }, new ShortcodeInfoDto { Shortcode = "%ГРУППА_РАБОТ%", Description = "Наименование группы работ", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto { Shortcode = "%РАБОТА%", Description = "Наименование работы в АСУ ЕСПП", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto { Shortcode = "%ТНК%", Description = "Полное наименование ТНК", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto { Shortcode = "%ТНК-КРАТКО%", Description = "Краткое наименование ТНК", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto { Shortcode = "%ИНДЕКС%", Description = "Порядковый индекс шаблона для групповых работ", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto { Shortcode = "%МАКС:ИМЯ АТРИБУТА%", Description = "Используется только с групповым типом работ. Наиболее часто встречающееся значение поля в группе (игнорирует пустые). Пример: %МАКС:РАБОЧАЯ_ГР_ОТВ_ЗА_ЭК%", Type = ShortcodeTypeEnum.Standart }, new ShortcodeInfoDto{ Shortcode = "%БУКВЫ:ИМЯ АТРИБУТА%", Description = "Извлекает только буквы из значения поля. Пример: %БУКВЫ:ЗОНА_ОТВЕТСТВЕННОСТИ% → ПРИВ", Type = ShortcodeTypeEnum.Standart } }); // 3. Связи result.AddRange(new[] { new ShortcodeInfoDto { Shortcode = "%СВЯЗИ%", Description = "Связанные ЭК (по одному на строку), выбираются только при настроенном фильтре по полям в связанных ЭК", Type = ShortcodeTypeEnum.Relationship }, new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией (1. ..., 2. ...), выбираются только при настроенном фильтре по полям в связанных ЭК", Type = ShortcodeTypeEnum.Relationship }, new ShortcodeInfoDto { Shortcode = "%ГР_ПОЛЕ-ПН%", Description = "Нумерованный список unit-ов из шаблона: 1. ЭК-123 (Значение1, Значение2). Использует GroupingUnitFieldId из JobGroup.", Type = ShortcodeTypeEnum.Relationship }, }); // 4. Все доступные поля из UnitField var fieldNames = await unitFieldService.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync(); 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); 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, Unit unit, string input, int? index = null) { return input .Replace("%ЭК%", unit.Name, StringComparison.OrdinalIgnoreCase) .Replace("%ГРУППА_РАБОТ%", job.Group?.GroupName ?? "", StringComparison.OrdinalIgnoreCase) .Replace("%РАБОТА%", job.WorkName, StringComparison.OrdinalIgnoreCase) .Replace("%ТНК%", job.Tnk?.Name ?? "", StringComparison.OrdinalIgnoreCase) .Replace("%ТНК-КРАТКО%", job.Tnk?.ShortName ?? "", StringComparison.OrdinalIgnoreCase) .Replace("%ИНДЕКС%", index?.ToString() ?? "", 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) { if (jobGroupId == Guid.Empty) { logger.LogWarning("jobGroupId не задан. Пропускаем обработку %МАКС:...%"); return input; } if (unitId == Guid.Empty) { logger.LogWarning("unitId не задан. Пропускаем обработку %МАКС:...%"); 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("Обработка {Shortcode} для JobGroup {JobGroupId}, Template.UnitId {UnitId}, fieldName {FieldName}", fullShortcode, jobGroupId, unitId, fieldName); var mostFrequentValue = await GetMaxShortCodeFromCacheOrDbAsync(jobGroupId, unitId, fullShortcode, fieldName); 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) { if (jobGroupId == Guid.Empty) { logger.LogWarning("jobGroupId не задан. Пропускаем обработку шорткода {Shortcode}", fullShortcode); return "Нет данных"; } if (unitId == Guid.Empty) { logger.LogWarning("unitId не задан. Пропускаем обработку шорткода {Shortcode}", fullShortcode); return "Нет данных"; } var cacheKey = $"gr_shcd_{jobGroupId:N}_{unitId:N}_{ComputeHash(fullShortcode)}"; var cachedData = await cacheService.GetCachedDataAsync(cacheKey); if (cachedData != null) { logger.LogDebug("Кэш попал для GroupedShortCode '{Name}': {Value}", fullShortcode, cachedData.Value); return cachedData.Value; } logger.LogDebug("Кэш промахнут для GroupedShortCode '{Name}'. Запрашиваем из БД.", 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(); logger.LogDebug("EffectiveUnitIds: [{Ids}], Count: {Count}", string.Join(", ", effectiveUnitIds), effectiveUnitIds.Count); // Вызываем метод из сервиса var mostFrequentValue = await unitInValueService.GetMostFrequentValueForFieldAsync(effectiveUnitIds, fieldName); logger.LogDebug("Результат GetMostFrequentValueForFieldAsync: {Result}, для поля {FieldName}, unitIds: [{Ids}]", mostFrequentValue, fieldName, string.Join(", ", effectiveUnitIds)); // Не кэшируем пустые значения if (!string.IsNullOrEmpty(mostFrequentValue)) { var toCache = new CachedGroupedShortCode { Value = mostFrequentValue, Timestamp = DateTimeOffset.UtcNow, Source = GetType().Name, Version = 1 }; await cacheService.SetCachedDataAsync(cacheKey, toCache, TimeSpan.FromHours(1)); return mostFrequentValue; } return "Нет данных"; } private async Task ReplaceLettersShortcodesAsync(Guid unitId, string input, MatchCollection lettersShortcodes) { 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("Обработка {Shortcode} для unitId {UnitId}, fieldName {FieldName}", fullShortcode, unitId, fieldName); // Получаем значение поля через unitInValueService.GetFieldValuesAsync var fieldValues = await unitInValueService.GetFieldValuesAsync(unitId, new List { fieldName }); string extractedLetters = string.Empty; if (fieldValues.Any()) { var value = fieldValues.First().Value; // Берём первое значение, если несколько if (value != null) { extractedLetters = ExtractLettersOnly(value); logger.LogDebug("Извлечены буквы: '{Letters}' из значения '{Value}'", extractedLetters, value); } } if (string.IsNullOrEmpty(extractedLetters)) { logger.LogDebug("Для шорткода {Shortcode} не найдено подходящее значение или из него нельзя извлечь буквы", 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); } } }