feat(core): Рефакторинг ShortcodesService, реализован паттерн стратегия

This commit is contained in:
Mikhail Kuznetsov
2026-05-14 16:31:03 +10:00
parent e5900be547
commit f7a74d315a
20 changed files with 843 additions and 1008 deletions

View File

@@ -0,0 +1,28 @@
using PARR.Core.Services.Shortcodes.Models;
using PARR.Domain.Settings;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class ConstantsShortcodeHandler : IShortcodeHandler
{
private readonly SettingsFromDb settings;
public int Order => 5;
public Regex Pattern => new(@"%[А-ЯA-Z0-9_\-]+%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public ConstantsShortcodeHandler(SettingsFromDb settings) => this.settings = settings;
public Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var result = input;
foreach (var constant in settings.TemplateNameConstantPartsList)
{
var shortcode = $"%{constant.Name}%";
if (result.Contains(shortcode, StringComparison.OrdinalIgnoreCase))
{
result = result.Replace(shortcode, constant.Value ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
}
return Task.FromResult(result);
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class FieldShortcodeHandler : IShortcodeHandler
{
private readonly IUnitInValueRepository unitInValueRepository;
private readonly ILogger<FieldShortcodeHandler> logger;
public int Order => 90; // Всегда последним
public Regex Pattern => new(@"%[^%]+%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public FieldShortcodeHandler(IUnitInValueRepository unitInValueRepository, ILogger<FieldShortcodeHandler> logger)
{
this.unitInValueRepository = unitInValueRepository;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return input;
var fieldNames = matches.Cast<Match>()
.Select(m => m.Value.Trim('%').ToUpperInvariant())
.Distinct()
.ToList();
if (fieldNames.Count == 0) return input;
var fieldValues = await unitInValueRepository.GetFieldValuesAsync(data.UnitId, fieldNames, ct).ConfigureAwait(false);
var valuesMap = fieldValues
.GroupBy(x => x.FieldName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => string.Join(", ", g.Select(v => v.Value ?? "null")),
StringComparer.OrdinalIgnoreCase);
var result = input;
foreach (var match in matches.Cast<Match>())
{
var fieldName = match.Value.Trim('%').ToUpperInvariant();
if (valuesMap.TryGetValue(fieldName, out var combinedValue))
{
result = result.Replace(match.Value, combinedValue, StringComparison.OrdinalIgnoreCase);
}
else
{
logger.LogDebug("[{Caller}] Поле '{FieldName}' не найдено для UnitId={UnitId}. Шорткод пропущен.", caller, fieldName, data.UnitId);
}
}
return result;
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class GroupedFieldShortcodeHandler : IShortcodeHandler
{
private readonly IUnitRepository unitRepo;
private readonly IUnitFieldValueRepository fieldValueRepo;
private readonly ITemplateRepository templateRepo;
private readonly ILogger<GroupedFieldShortcodeHandler> logger;
public int Order => 30;
public Regex Pattern => new(@"%ГРОЛЕ-ПН%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public GroupedFieldShortcodeHandler(
IUnitRepository unitRepo,
IUnitFieldValueRepository fieldValueRepo,
ITemplateRepository templateRepo,
ILogger<GroupedFieldShortcodeHandler> logger)
{
this.unitRepo = unitRepo;
this.fieldValueRepo = fieldValueRepo;
this.templateRepo = templateRepo;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
if (!input.Contains("%ГРОЛЕ-ПН%", StringComparison.OrdinalIgnoreCase)) return input;
if (data.Job?.Group == null)
{
logger.LogWarning("[{Caller}] Job не содержит Group, необходимый для %ГРОЛЕ-ПН%. Шорткод пропущен.", caller);
return input.Replace("%ГРОЛЕ-ПН%", string.Empty, StringComparison.OrdinalIgnoreCase);
}
var unitsList = data.UnitsInTemplate;
if (unitsList.Count == 0)
{
logger.LogWarning("[{Caller}] Template {TemplateId} не содержит UnitsInTemplate. Данные догружаются из БД.", caller, data.Id);
var fullTemplate = await templateRepo.Get()
.AsNoTracking()
.Include(t => t.UnitsInTemplate)
.FirstOrDefaultAsync(t => t.Id == data.Id, ct);
unitsList = fullTemplate?.UnitsInTemplate
?.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId))
.ToList() ?? new List<UnitInTemplateForShortcode>();
}
if (unitsList.Count == 0)
return input.Replace("%ГРОЛЕ-ПН%", string.Empty, StringComparison.OrdinalIgnoreCase);
var unitIds = unitsList.Select(u => u.UnitId).Distinct().ToList();
var fieldValueIds = unitsList.Select(u => u.UnitFieldValueId).Distinct().ToList();
var unitNames = await unitRepo.Get()
.AsNoTracking()
.Where(u => unitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.Name ?? u.Id.ToString(), ct);
var fieldValueStrings = await fieldValueRepo.Get()
.AsNoTracking()
.Where(fv => fieldValueIds.Contains(fv.Id))
.ToDictionaryAsync(fv => fv.Id, fv => fv.Value ?? string.Empty, ct);
var lines = unitsList.Select((uit, i) =>
{
var uName = unitNames.GetValueOrDefault(uit.UnitId, $"(UnitId={uit.UnitId})");
var fVal = fieldValueStrings.GetValueOrDefault(uit.UnitFieldValueId, string.Empty);
return $"{i + 1}. {uName} ({fVal})";
});
return input.Replace("%ГРОЛЕ-ПН%", string.Join("\n", lines), StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,24 @@
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers
{
public interface IShortcodeHandler
{
/// <summary>
/// Порядок выполнения в пайплайне.
/// Меньшее значение = раньше выполнение. Важно для зависимых шорткодов.
/// </summary>
int Order { get; }
/// <summary>
/// Регулярное выражение для поиска шорткодов, которые обрабатывает этот хендлер.
/// </summary>
Regex Pattern { get; }
/// <summary>
/// Заменяет все найденные шорткоды в строке на вычисленные значения.
/// </summary>
Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,19 @@
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class IndexShortcodeHandler : IShortcodeHandler
{
public int Order => 15;
public Regex Pattern => new(@"%ИНДЕКС%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
if (data.Index.HasValue && input.Contains("%ИНДЕКС%", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(input.Replace("%ИНДЕКС%", data.Index.Value.ToString(), StringComparison.OrdinalIgnoreCase));
}
return Task.FromResult(input);
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class LettersShortcodeHandler : IShortcodeHandler
{
private readonly IUnitInValueRepository unitInValueRepo;
private readonly ILogger<LettersShortcodeHandler> logger;
public int Order => 60;
public Regex Pattern => new(@"%БУКВЫ:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public LettersShortcodeHandler(IUnitInValueRepository unitInValueRepo, ILogger<LettersShortcodeHandler> logger)
{
this.unitInValueRepo = unitInValueRepo;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return input;
var uniqueFields = matches.Cast<Match>()
.Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var field in uniqueFields)
{
var values = await unitInValueRepo.GetFieldValuesAsync(data.UnitId, new List<string> { field }, ct);
var rawValue = values.FirstOrDefault().Value;
var letters = string.IsNullOrEmpty(rawValue) ? string.Empty : new string(rawValue.Where(char.IsLetter).ToArray());
resolved[$"%БУКВЫ:{field}%"] = letters;
}
var result = input;
foreach (Match m in matches)
{
if (resolved.TryGetValue(m.Value, out var val))
result = result.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
}
return result;
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Models;
using PARR.Domain.Cache.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class MaxShortcodeHandler : IShortcodeHandler
{
private readonly IUnitInValueRepository unitInValueRepo;
private readonly IRedisCacheService cacheService;
private readonly ILogger<MaxShortcodeHandler> logger;
public int Order => 40;
public Regex Pattern => new(@"%МАКС:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public MaxShortcodeHandler(
IUnitInValueRepository unitInValueRepo,
IRedisCacheService cacheService,
ILogger<MaxShortcodeHandler> logger)
{
this.unitInValueRepo = unitInValueRepo;
this.cacheService = cacheService;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return input;
var uniqueFields = matches.Cast<Match>()
.Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var field in uniqueFields)
{
resolved[$"%МАКС:{field}%"] = await GetMaxValueAsync(data, field, caller, ct);
}
var result = input;
foreach (Match m in matches)
{
if (resolved.TryGetValue(m.Value, out var val))
result = result.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
}
return result;
}
private async Task<string> GetMaxValueAsync(TemplateForShortcode data, string fieldName, string caller, CancellationToken ct = default)
{
if (data.Job?.Group?.Id == Guid.Empty || data.UnitId == Guid.Empty) return "Нет данных";
var cacheKey = cacheService.GetKey(
new[] { "grouped", "shortcode", $"{data.Job.Group.Id:N}", $"{data.UnitId:N}" },
new[] { $"%МАКС:{fieldName}%" });
var cached = await cacheService.GetCachedDataAsync<GroupedShortcode>(cacheKey);
if (cached != null) return cached.Data.Value;
var unitIds = data.UnitsInTemplate.Select(u => u.UnitId).Distinct().ToList();
var value = await unitInValueRepo.GetMostFrequentValueForFieldAsync(unitIds, fieldName, ct);
if (!string.IsNullOrEmpty(value))
{
await cacheService.SetCachedDataAsync(cacheKey, new GroupedShortcode
{
Data = new GroupedShortcodeDto { Value = value },
Timestamp = DateTimeOffset.UtcNow,
Source = nameof(MaxShortcodeHandler)
}, TimeSpan.FromHours(1));
}
return value ?? "Нет данных";
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PARR.Core.Repositories.Interfaces;
using PARR.Core.Repositories.Interfaces.Unit;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class RelShortcodeHandler : IShortcodeHandler
{
private readonly IUnitInValueRepository unitInValueRepo;
private readonly ITemplateRepository templateRepo;
private readonly ILogger<RelShortcodeHandler> logger;
public int Order => 50;
public Regex Pattern => new(@"%СВЯЗЬ:([^%]+)%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public RelShortcodeHandler(
IUnitInValueRepository unitInValueRepo,
ITemplateRepository templateRepo,
ILogger<RelShortcodeHandler> logger)
{
this.unitInValueRepo = unitInValueRepo;
this.templateRepo = templateRepo;
this.logger = logger;
}
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var matches = Pattern.Matches(input);
if (matches.Count == 0) return input;
var uniqueFields = matches.Cast<Match>()
.Select(m => m.Value.Trim('%').Split(':', 2)[1].Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var field in uniqueFields)
{
resolved[$"%СВЯЗЬ:{field}%"] = await GetRelValueAsync(data, field, caller, ct);
}
var result = input;
foreach (Match m in matches)
{
if (resolved.TryGetValue(m.Value, out var val))
result = result.Replace(m.Value, val, StringComparison.OrdinalIgnoreCase);
}
return result;
}
private async Task<string> GetRelValueAsync(TemplateForShortcode data, string fieldName, string caller, CancellationToken ct)
{
var unitsList = data.UnitsInTemplate;
if (unitsList.Count == 0)
{
var fullTemplate = await templateRepo.Get()
.AsNoTracking()
.Include(t => t.UnitsInTemplate)
.FirstOrDefaultAsync(t => t.Id == data.Id, ct);
unitsList = fullTemplate?.UnitsInTemplate
?.Select(uit => new UnitInTemplateForShortcode(uit.UnitId, uit.UnitFieldValueId))
.ToList() ?? new List<UnitInTemplateForShortcode>();
}
if (unitsList.Count == 0) return string.Empty;
var unitIds = unitsList.Select(u => u.UnitId).Distinct().ToList();
// Пакетный запрос вместо N+1
var allValues = await unitInValueRepo.Get()
.AsNoTracking()
.Include(uv => uv.Field)
.Include(uv => uv.Value)
.Where(uv => unitIds.Contains(uv.UnitId) &&
uv.Field!.AihitName == fieldName &&
uv.Value != null &&
!string.IsNullOrEmpty(uv.Value.Value))
.Select(uv => uv.Value!.Value)
.ToListAsync(ct);
var distinctSorted = allValues.Distinct().OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList();
if (distinctSorted.Count == 0) return string.Empty;
if (distinctSorted.Count > 1)
logger.LogWarning("[{Caller}] %СВЯЗЬ:{Field}% нашёл {Count} значений. Используется первое: '{First}'", caller, fieldName, distinctSorted.Count, distinctSorted[0]);
return distinctSorted[0]!;
}
}

View File

@@ -0,0 +1,38 @@
using PARR.Core.Services.Shortcodes.Models;
using PARR.Core.Services.UnitFilterService;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class RelationshipsShortcodeHandler : IShortcodeHandler
{
private readonly IUnitFilterService unitFilterService;
public int Order => 70;
public Regex Pattern => new(@"%СВЯЗИ(-ПН)?%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public RelationshipsShortcodeHandler(IUnitFilterService unitFilterService) => this.unitFilterService = unitFilterService;
public async Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
if (data.Job == null) return input;
var hasPlain = input.Contains("%СВЯЗИ%", StringComparison.OrdinalIgnoreCase);
var hasNumbered = input.Contains("%СВЯЗИ-ПН%", StringComparison.OrdinalIgnoreCase);
if (!hasPlain && !hasNumbered) return input;
var relatedNames = await unitFilterService.GetRelatedUnitNamesAsync(data.JobId, data.UnitId, ct);
var result = input;
if (hasPlain)
result = result.Replace("%СВЯЗИ%", string.Join("\n", relatedNames), StringComparison.OrdinalIgnoreCase);
if (hasNumbered)
{
var numbered = relatedNames.Select((n, i) => $"{i + 1}. {n}");
result = result.Replace("%СВЯЗИ-ПН%", string.Join("\n", numbered), StringComparison.OrdinalIgnoreCase);
}
return result;
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.Shortcodes.Models;
using System.Text.RegularExpressions;
namespace PARR.Core.Services.Shortcodes.Handlers;
internal class StandardShortcodeHandler : IShortcodeHandler
{
private readonly ILogger<StandardShortcodeHandler> logger;
public int Order => 10;
public Regex Pattern => new(@"%(?:ЭК|ТИКТАК|РАБОТА|ГРУППА_РАБОТ|ТНК|ТНК-КРАТКО|ИД)%", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public StandardShortcodeHandler(ILogger<StandardShortcodeHandler> logger) => this.logger = logger;
public Task<string> ResolveAsync(string input, TemplateForShortcode data, string caller, CancellationToken ct = default)
{
var result = input;
result = Replace(result, "%ЭК%", data.UnitName);
result = Replace(result, "%ТИКТАК%", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString());
result = Replace(result, "%ИД%", data.Id.ToString("N"));
if (data.Job != null)
{
result = Replace(result, "%РАБОТА%", data.Job.WorkName);
result = Replace(result, "%ГРУППА_РАБОТ%", data.Job.Group?.GroupName);
result = Replace(result, "%ТНК%", data.Job.Tnk?.Name);
result = Replace(result, "%ТНК-КРАТКО%", data.Job.Tnk?.ShortName);
}
return Task.FromResult(result);
}
private string Replace(string input, string shortcode, string? value)
{
if (string.IsNullOrEmpty(value)) return input;
return input.Replace(shortcode, value, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record class JobForShortcode(
JobGroupForShortcode? Group,
TnkForShortcode? Tnk,
string WorkName,
string Name
);
}

View File

@@ -0,0 +1,9 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record class JobGroupForShortcode(
Guid Id,
Guid? GroupingUnitFieldId,
JobGroupTypeForShortcodes? GroupType,
string GroupName
);
}

View File

@@ -0,0 +1,8 @@
using PARR.Domain.Enums;
namespace PARR.Core.Services.Shortcodes.Models
{
public record class JobGroupTypeForShortcodes(
JobGroupTypesEnum Code
);
}

View File

@@ -0,0 +1,12 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record class TemplateForShortcode(
Guid Id,
int? Index,
Guid JobId,
Guid UnitId,
string UnitName,
JobForShortcode? Job,
List<UnitInTemplateForShortcode> UnitsInTemplate
);
}

View File

@@ -0,0 +1,7 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record class TnkForShortcode(
string Name,
string ShortName
);
}

View File

@@ -0,0 +1,7 @@
namespace PARR.Core.Services.Shortcodes.Models
{
public record class UnitInTemplateForShortcode(
Guid UnitId,
Guid UnitFieldValueId
);
}

File diff suppressed because it is too large Load Diff