feat(shortcodesService): добавлен шорткод %РОД_ЭК_ПЕРВЫЙ:ОУ-*:КРАТКОЕ_НАИМЕНОВАНИЕ%. - Позволяет получать значения атрибутов родительских ЭК.

- Родитель определяется по маске, выбирается первый найденный, отсторитрованный по алфавиту
- Имя атрибута определяется по полю AihitName
This commit is contained in:
Mikhail Kuznetsov
2026-10-15 10:39:33 +10:00
parent f9f234d119
commit 0fcbe870b9
6 changed files with 281 additions and 42 deletions

View File

@@ -109,6 +109,7 @@ namespace PARR.Core
services.AddScoped<IShortcodeHandler, FieldShortcodeHandler>();
services.AddScoped<IShortcodeHandler, TagShortcodeHandler>();
services.AddScoped<IShortcodeHandler, RelatedUnitTagShortcodeHandler>();
services.AddScoped<IShortcodeHandler, ParentUnitFirstShortcodeHandler>();
services.AddScoped<IShortcodesService, ShortcodesService>();
#endregion

View File

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

View File

@@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Enums;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class ParentUnitFirstShortcodeHandler : IShortcodeHandler
{
private readonly IUnitInValueRepository unitInValueRepo;
private readonly ILogger<ParentUnitFirstShortcodeHandler> logger;
public int Order => 55; // После RelationshipUnitShortcodeHandler
// Паттерн: %РОД_ЭК_ПЕРВЫЙ:МАСКА:ИМЯ_АТРИБУТА%
private static readonly Regex _pattern = new(
@"%РОД_ЭК_ПЕРВЫЙ:([^:]+):([^%]+)%",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Regex Pattern => _pattern;
public ShortcodeDataRequirementsEnum Requirements => ShortcodeDataRequirementsEnum.ParentUnits;
public ParentUnitFirstShortcodeHandler(
IUnitInValueRepository unitInValueRepo,
ILogger<ParentUnitFirstShortcodeHandler> logger)
{
this.unitInValueRepo = unitInValueRepo;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode template, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return input;
// Группируем уникальные запросы, чтобы не делать повторные БД-запросы для одинаковых шорткодов в одной строке
var uniqueRequests = matches.Cast<Match>()
.Select(m => new
{
FullMatch = m.Value,
Mask = m.Groups[1].Value.Trim(),
FieldName = m.Groups[2].Value.Trim()
})
.GroupBy(x => new { x.Mask, x.FieldName })
.Select(g => g.First())
.ToList();
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var request in uniqueRequests)
{
var value = await GetParentUnitFieldValueAsync(template, request.Mask, request.FieldName, caller, ct);
resolved[request.FullMatch] = value;
}
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<string> GetParentUnitFieldValueAsync(
TemplateForShortcode template,
string mask,
string fieldName,
string caller,
CancellationToken ct)
{
// 1. Проверяем наличие родителей в контексте
if (template.ParentUnits == null || template.ParentUnits.Count == 0)
{
logger.LogDebug("[{Caller}] Родителей нет. Возвращаю 'Нет данных'.", caller);
return "Нет данных";
}
// 2. Ищем ПЕРВОГО родителя, подходящего под маску
var matchingParent = template.ParentUnits
.FirstOrDefault(p => MatchesMask(p.UnitName, mask));
if (matchingParent == null)
{
logger.LogDebug("[{Caller}] Не найден родитель по маске {Mask}. Возвращаю 'Нет данных'.", caller, mask);
return "Нет данных";
}
// 3. Запрашиваем значение атрибута у найденного родителя
var fieldValue = await unitInValueRepo.Get()
.AsNoTracking()
.Include(uv => uv.Field)
.Include(uv => uv.Value)
.Where(uv => uv.UnitId == matchingParent.UnitId &&
uv.Field!.AihitName == fieldName &&
uv.Value != null &&
!string.IsNullOrEmpty(uv.Value.Value))
.Select(uv => uv.Value!.Value)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return fieldValue ?? "Нет данных";
}
private bool MatchesMask(string value, string mask)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(mask))
return false;
// Поддержка wildcard *
if (mask.Contains('*'))
{
// Экранируем спецсимволы regex, кроме *, который заменяем на .*
var regexPattern = "^" + Regex.Escape(mask).Replace("\\*", ".*") + "$";
return Regex.IsMatch(value, regexPattern, RegexOptions.IgnoreCase);
}
// Точное совпадение
return string.Equals(value, mask, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,7 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record ParentUnitForShortcode(
Guid UnitId,
string UnitName
);
}

View File

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

View File

@@ -25,34 +25,37 @@ internal class ShortcodesService : IShortcodesService
private const int MaxIterations = 3;
private readonly ILogger<ShortcodesService> logger;
private readonly IEnumerable<IShortcodeHandler> handlers;
private readonly IRedisCacheService cacheService;
private readonly SettingsFromDb settingsFromDb;
private readonly IJobRepository jobService;
private readonly IUnitRepository unitService;
private readonly IUnitFieldRepository unitFieldRepo;
private readonly ITemplateRepository templateRepo;
private readonly ILogger<ShortcodesService> _logger;
private readonly IEnumerable<IShortcodeHandler> _handlers;
private readonly IRedisCacheService _cacheService;
private readonly SettingsFromDb _settingsFromDb;
private readonly IJobRepository _jobService;
private readonly IUnitRepository _unitRepo;
private readonly IUnitFieldRepository _unitFieldRepo;
private readonly IUnitInUnitRepository _unitInUnitRepo;
private readonly ITemplateRepository _templateRepo;
public ShortcodesService(
ILogger<ShortcodesService> logger,
IEnumerable<IShortcodeHandler> handlers,
IRedisCacheService cacheService,
SettingsFromDb settingsFromDb,
IJobRepository jobService,
IUnitRepository unitService,
IUnitFieldRepository unitFieldService,
IJobRepository jobRepo,
IUnitRepository unitRepo,
IUnitFieldRepository unitFieldRepo,
IUnitInUnitRepository unitInUnitRepo,
ITemplateRepository templateRepo
)
{
this.logger = logger;
this.handlers = handlers.OrderBy(h => h.Order).ToList();
this.cacheService = cacheService;
this.settingsFromDb = settingsFromDb;
this.jobService = jobService;
this.unitService = unitService;
this.unitFieldRepo = unitFieldService;
this.templateRepo = templateRepo;
this._logger = logger;
this._handlers = handlers.OrderBy(h => h.Order).ToList();
this._cacheService = cacheService;
this._settingsFromDb = settingsFromDb;
this._jobService = jobRepo;
this._unitRepo = unitRepo;
this._unitFieldRepo = unitFieldRepo;
this._unitInUnitRepo = unitInUnitRepo;
this._templateRepo = templateRepo;
}
public async Task<string> ApplyShortcodesAsync(string str, Template template, [CallerMemberName] string? caller = null)
@@ -61,8 +64,8 @@ internal class ShortcodesService : IShortcodesService
var callerName = caller ?? "Unknown";
// Кэш результата (проверяем один раз на входе)
var cacheKey = cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
var cachedResult = await cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
var cacheKey = _cacheService.GetKey(new[] { "shortcodes", "result", template.Id.ToString("N") }, new[] { str });
var cachedResult = await _cacheService.GetCachedDataAsync<ShortcodesResult>(cacheKey).ConfigureAwait(false);
if (cachedResult != null) return cachedResult.Data.Result;
// Начальный контекст
@@ -83,7 +86,8 @@ internal class ShortcodesService : IShortcodesService
Job: initialJob,
UnitsInTemplate: initialUnitsInTemplate,
UnitTags: new List<string>(),
RelatedUnitTags: new Dictionary<Guid, List<string>>()
RelatedUnitTags: new Dictionary<Guid, List<string>>(),
ParentUnits: new List<ParentUnitForShortcode>()
);
var result = str;
@@ -92,7 +96,7 @@ internal class ShortcodesService : IShortcodesService
while (iteration < MaxIterations)
{
// 1. Находим хендлеры, которые сработают для ТЕКУЩЕЙ версии строки
var matchingHandlers = handlers
var matchingHandlers = _handlers
.Where(h => h.Pattern.IsMatch(result))
.ToList();
@@ -121,13 +125,13 @@ internal class ShortcodesService : IShortcodesService
if (iteration == MaxIterations && result != str)
{
logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
_logger.LogWarning("[{Caller}] Достигнут лимит итераций для шаблона {TemplateId}. Возможна циклическая зависимость.", callerName, template.Id);
}
// Сохранение в кэш
if (!string.IsNullOrEmpty(result) && result != str && template.Id != Guid.Empty)
{
await cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
await _cacheService.SetCachedDataAsync(cacheKey, new ShortcodesResult
{
Data = new ShortcodesResultDto { Result = result },
Timestamp = DateTimeOffset.UtcNow,
@@ -149,8 +153,8 @@ internal class ShortcodesService : IShortcodesService
if (string.IsNullOrEmpty(unitName) && requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitName))
{
// Логирование и загрузка только если нужно и еще не загружено
logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
var unit = await unitService.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
_logger.LogWarning(MissingUnitNameWarningMsg, caller, template.Id);
var unit = await _unitRepo.Get().AsNoTracking().FirstOrDefaultAsync(u => u.Id == template.UnitId).ConfigureAwait(false);
unitName = unit?.Name ?? throw new InvalidOperationException($"Unit {template.UnitId} не найден.");
}
@@ -181,7 +185,7 @@ internal class ShortcodesService : IShortcodesService
if (requirements.HasFlag(ShortcodeDataRequirementsEnum.JobTnk)) missing.Add("Tnk");
// Логируем только если реально пошли в БД
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует догрузки данных ({Missing}). Данные догружены.",
caller, template.Id, string.Join(", ", missing));
@@ -193,11 +197,11 @@ internal class ShortcodesService : IShortcodesService
if ((unitsInTemplate == null || unitsInTemplate.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplate))
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} передан без UnitsInTemplate. Данные догружены автоматически.",
caller, template.Id);
var fullTemplate = await templateRepo.Get()
var fullTemplate = await _templateRepo.Get()
.AsNoTracking()
.Include(t => t.UnitsInTemplate)
.FirstOrDefaultAsync(t => t.Id == template.Id)
@@ -214,11 +218,11 @@ internal class ShortcodesService : IShortcodesService
if ((unitTags == null || unitTags.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitTags))
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует теги (UnitTags). Данные догружены автоматически.",
caller, template.Id);
unitTags = await unitService.Get()
unitTags = await _unitRepo.Get()
.AsNoTracking()
.Where(u => u.Id == template.UnitId)
.SelectMany(u => u.UnitValues
@@ -252,11 +256,11 @@ internal class ShortcodesService : IShortcodesService
requirements.HasFlag(ShortcodeDataRequirementsEnum.UnitsInTemplateTags) &&
relatedUnitIds.Count > 0)
{
logger.LogWarning(
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует теги связанных ЭК (UnitsInTemplateTags). Данные догружены автоматически.",
caller, template.Id);
var rawTags = await unitService.Get()
var rawTags = await _unitRepo.Get()
.AsNoTracking()
.Where(u => relatedUnitIds.Contains(u.Id))
.SelectMany(u => u.UnitValues
@@ -273,21 +277,56 @@ internal class ShortcodesService : IShortcodesService
.ToDictionary(g => g.Key, g => g.Select(x => x.Tag).ToList());
}
// 6. Возвращаем обновленный рекорд
// 6. Проверка ParentUnits (Только непосредственные родители)
var parentUnits = currentData.ParentUnits;
if ((parentUnits == null || parentUnits.Count == 0) &&
requirements.HasFlag(ShortcodeDataRequirementsEnum.ParentUnits))
{
_logger.LogWarning(
"[{Caller}] Шаблон {TemplateId} требует данные о родителях (ParentUnits). Данные догружены автоматически.",
caller, template.Id);
// Получаем ID всех непосредственных родителей текущего ЭК
var parentIds = await _unitInUnitRepo.Get()
.AsNoTracking()
.Where(link => link.ChildUnitId == template.UnitId)
.Select(link => link.ParentUnitId)
.ToListAsync()
.ConfigureAwait(false);
if (parentIds.Count > 0)
{
// Получаем имена этих родителей одним запросом
parentUnits = await _unitRepo.Get()
.AsNoTracking()
.Where(u => parentIds.Contains(u.Id))
.Select(u => new ParentUnitForShortcode(u.Id, u.Name))
.OrderBy(u => u.UnitName)
.ToListAsync()
.ConfigureAwait(false);
}
else
{
parentUnits = new List<ParentUnitForShortcode>();
}
}
// 7. Возвращаем обновленный рекорд
return currentData with
{
UnitName = unitName ?? string.Empty,
Job = jobData,
UnitsInTemplate = unitsInTemplate ?? currentData.UnitsInTemplate ?? new List<UnitInTemplateForShortcode>(),
UnitTags = unitTags ?? currentData.UnitTags,
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags
UnitTags = unitTags ?? currentData.UnitTags ?? new List<string>(),
RelatedUnitTags = relatedUnitTags ?? currentData.RelatedUnitTags ?? new Dictionary<Guid, List<string>>(),
ParentUnits = parentUnits ?? currentData.ParentUnits ?? new List<ParentUnitForShortcode>()
};
}
private async Task<JobForShortcode> LoadFullJobAsync(Guid jobId, string caller)
{
var job = await jobService.Get().AsNoTracking().AsSingleQuery()
var job = await _jobService.Get().AsNoTracking().AsSingleQuery()
.Include(j => j.Group).ThenInclude(g => g!.GroupType)
.Include(j => j.Tnk)
.FirstOrDefaultAsync(j => j.Id == jobId)
@@ -323,7 +362,7 @@ internal class ShortcodesService : IShortcodesService
{
var result = new List<ShortcodeInfoDto>();
foreach (var constant in settingsFromDb.TemplateNameConstantPartsList)
foreach (var constant in _settingsFromDb.TemplateNameConstantPartsList)
{
result.Add(new ShortcodeInfoDto
{
@@ -367,7 +406,14 @@ internal class ShortcodesService : IShortcodesService
new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%", Description = "Связанные ЭК с нумерацией", Type = ShortcodeTypeEnum.Relationship }
});
var fieldNames = await unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
result.Add(new ShortcodeInfoDto
{
Shortcode = "%РОД_ЭК_ПЕРВЫЙ:МАСКА:ИМЯ_АТРИБУТА%",
Description = "Значение атрибута первого родительского ЭК, подходящего под маску (например, %РОД_ЭК_ПЕРВЫЙ:ОУ-*:КРАТКОЕ_НАИМЕНОВАНИЕ%)",
Type = ShortcodeTypeEnum.Relationship
});
var fieldNames = await _unitFieldRepo.Get().AsNoTracking().Select(t => new { t.AihitName, t.DisplayName }).ToListAsync().ConfigureAwait(false);
foreach (var fieldName in fieldNames.OrderBy(n => n.AihitName))
{
result.Add(new ShortcodeInfoDto
@@ -380,4 +426,62 @@ internal class ShortcodesService : IShortcodesService
return result;
}
private async Task<List<ParentUnitForShortcode>> LoadParentUnitsHierarchyAsync(Guid unitId, string caller)
{
var result = new List<ParentUnitForShortcode>();
var visited = new HashSet<Guid>();
var currentUnitId = unitId;
var maxDepth = 100; // Защита от циклических зависимостей
var depth = 0;
while (depth < maxDepth)
{
// Получаем всех родителей текущего юнита
var parentIds = await _unitInUnitRepo.Get()
.AsNoTracking()
.Where(link => link.ChildUnitId == currentUnitId)
.Select(link => link.ParentUnitId)
.ToListAsync()
.ConfigureAwait(false);
if (parentIds.Count == 0)
break; // Достигли корня
// Берем первого родителя (если их несколько)
var parentId = parentIds.First();
// Защита от циклов
if (visited.Contains(parentId))
{
_logger.LogWarning("[{Caller}] Обнаружена циклическая зависимость в иерархии юнитов для UnitId {UnitId}",
caller, unitId);
break;
}
visited.Add(parentId);
// Получаем информацию о родителе
var parentUnit = await _unitRepo.Get()
.AsNoTracking()
.Where(u => u.Id == parentId)
.Select(u => new ParentUnitForShortcode(u.Id, u.Name))
.FirstOrDefaultAsync()
.ConfigureAwait(false);
if (parentUnit != null)
{
result.Add(parentUnit);
currentUnitId = parentId;
}
else
{
break;
}
depth++;
}
return result;
}
}