Files
parr_api/PARR.DAL/DomainServices/Shortcodes/ShortcodesService.cs

1010 lines
49 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
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 PARR.Domain.Common.Template;
using PARR.Domain.Enums;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
namespace PARR.DAL.DomainServices.Shortcodes
{
/// <summary>
/// Сервис для подстановки шорткодов в строке на основе данных шаблона.
/// </summary>
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 Regex RelShortcodeRegex = new(@"%СВЯЗЬ:([а-яА-Яa-zA-Z0-9_]+)%", RegexOptions.Compiled);
private static readonly HashSet<string> SupportedStandardShortcodes = new(StringComparer.OrdinalIgnoreCase)
{
"%ЭК%", "%ГРУППА_РАБОТ%", "%РАБОТА%", "%ТНК%", "%ТНК-КРАТКО%", "%ТИКТАК%", "%ИД%"
};
private readonly ILogger<ShortcodesService> 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<UnitInTemplateForShortcodes> 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<ShortcodesService> 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;
}
/// <summary>
/// Подставляет шорткоды в строке на основе данных шаблона.
/// </summary>
/// <param name="str">Строка с шорткодами</param>
/// <param name="template">Шаблон, из которого берутся данные</param>
/// <returns>Строка с подставленными значениями</returns>
public async Task<string> 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<TemplateForShortcodes> PrepareTemplateDataAsync(Template template, List<string> 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<UnitInTemplateForShortcodes>()
);
// Загружаем 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.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 };
}
}
}
private static bool NeedsJobForShortcodes(List<string> shortcodes)
{
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)));
}
private static List<string> GetMissingIncludesForShortcodes(List<string> shortcodes, Job? job)
{
var missing = new List<string>();
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<string> GetUnitNameAsyncWithWarning(Template template, List<string> 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<JobForShortcodes> LoadJobForShortcodesAsync(Guid jobId, List<string> 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<string> ApplyAllShortcodesOnceAsync(string str, TemplateForShortcodes data, List<string> 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<Guid> 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<Guid>();
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<Guid, string> 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<string>? 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<string> 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<Match> GetShortCodes(string resultName)
{
var shortcodesInMask = GeneralShortcodeRegex.Matches(resultName).ToList();
return shortcodesInMask;
}
public async Task<List<ShortcodeInfoDto>> GetAvailableShortcodesAsync()
{
var result = new List<ShortcodeInfoDto>();
// 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
});
// 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<string> ReplaceFieldValues(Guid unitId, string resultName, List<Match> 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<TemplateNameConstantPart> nameConstants, string resultName)
{
foreach (var item in nameConstants)
resultName = resultName.Replace($"%{item.Name}%", item.Value);
return resultName;
}
private async Task<string> ReplaceMaxShortcodesAsync(
Guid jobGroupId,
Guid unitId,
string input,
MatchCollection maxShortcodes,
string caller,
List<UnitInTemplateForShortcodes> 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<Match>()
.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<string> GetMaxShortCodeFromCacheOrDbAsync(
Guid jobGroupId,
Guid unitId,
string fullShortcode,
string fieldName,
string caller,
List<UnitInTemplateForShortcodes> 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<CachedGroupedShortCode>(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 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 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;
}
/// <summary>
/// Получает список UnitId для подстановки шорткода %МАКС:...%.
/// Сначала пробует использовать переданные юниты, затем загружает из БД.
/// </summary>
/// <param name="jobGroupId">ID JobGroup, для которого ищутся юниты</param>
/// <param name="unitId">ID ключевого юнита шаблона</param>
/// <param name="unitsInTemplateForCurrentTemplate">Юниты, переданные с текущим шаблоном (опционально)</param>
/// <param name="caller">Имя вызывающего метода (для логирования)</param>
/// <returns>Список UnitId</returns>
private async Task<List<Guid>> GetEffectiveUnitIdsAsync(
Guid jobGroupId,
Guid unitId,
List<UnitInTemplateForShortcodes> 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<string> ReplaceRelShortcodesAsync(
Guid templateId,
Guid templateUnitId,
List<UnitInTemplateForShortcodes> unitsInTemplate,
string input,
MatchCollection relShortcodes,
string caller)
{
var shortcodeToMatches = relShortcodes
.Cast<Match>()
.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<Guid> 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<Guid>();
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<string> { 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<string> ReplaceLettersShortcodesAsync(Guid unitId, string input, MatchCollection lettersShortcodes, string caller)
{
var shortcodeToMatches = lettersShortcodes
.Cast<Match>()
.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<string> { 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();
}
}
}