feat(dal): уточнена логика получение имени связанных ЭК, переписано кэширование МАКС:FIELD, в синхронизацию и контролер заданий роботов добавлены include

This commit is contained in:
Mikhail Kuznetsov
2025-12-29 19:21:51 +10:00
parent d5d84c1853
commit acf232ecdf
24 changed files with 598 additions and 558 deletions

View File

@@ -1,95 +0,0 @@
using Microsoft.Extensions.Logging;
using PARR.DAL.CacheServices;
using PARR.DAL.DomainServices.Interfaces;
using PARR.DAL.Settings;
using System.Security.Cryptography;
using System.Text;
namespace PARR.DAL.DomainServices.Implementations
{
public class GroupedShortcodesCacheService : IGroupedShortcodesCacheService
{
private readonly IRedisCacheService cacheService;
private readonly GroupedShortcodesCacheSettings settings;
private readonly ILogger<GroupedShortcodesCacheService> logger;
public GroupedShortcodesCacheService(
IRedisCacheService cacheService,
GroupedShortcodesCacheSettings settings,
ILogger<GroupedShortcodesCacheService> logger)
{
this.cacheService = cacheService;
this.settings = settings;
this.logger = logger;
}
public async Task<string> GetAggregatedValueAsync(
Guid jobGroupId,
Guid unitId,
string shortcode,
Func<Task<string>> computeIfMissing)
{
if (string.IsNullOrEmpty(shortcode))
throw new ArgumentException("Ключ шорткода должен быть указан.", nameof(shortcode));
var cacheKey = GetCacheKey(jobGroupId, unitId, shortcode);
try
{
var cachedValue = await cacheService.GetCachedDataAsync<string>(cacheKey);
if (cachedValue != null)
{
logger.LogDebug(
"Попадание в кэш для шорткода '{ShortcodeKey}': unit={UnitId} → '{Value}'",
shortcode, unitId, cachedValue);
return cachedValue;
}
logger.LogDebug(
"Промах кэша для шорткода '{ShortcodeKey}': unit={UnitId}. Вычисление...",
shortcode, unitId);
var computedValue = await computeIfMissing();
await cacheService.SetCachedDataAsync(cacheKey, computedValue, settings.ValueTtl);
logger.LogDebug(
"Вычислено и сохранено значение для '{ShortcodeKey}': unit={UnitId} → '{Value}' (срок хранения={Ttl})",
shortcode, unitId, computedValue, settings.ValueTtl);
return computedValue;
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Ошибка при получении или вычислении значения для шорткода '{ShortcodeKey}' (unit={UnitId}). Возвращена пустая строка.",
shortcode, unitId);
return string.Empty;
}
}
private static string GetCacheKey(Guid jobGroupId, Guid unitId, string shortcodeKey)
{
var safeKey = shortcodeKey
.Trim()
.Replace(":", "_")
.Replace(" ", "_")
.Replace(".", "_")
.Replace("%", "")
.Replace("[", "_")
.Replace("]", "_")
.Replace("/", "_")
.Replace("\\", "_");
// ✅ SHA256 от safeKey
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(safeKey));
var hashHex = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
// ✅ Новый формат ключа
return $"gr_shcd_{jobGroupId:N}{unitId:N}_{hashHex}";
}
}
}

View File

@@ -398,44 +398,68 @@ namespace PARR.DAL.DomainServices.Implementations
logger.LogDebug("Обработка UnitFilter.Id {FilterId}. Количество RelationshipFilters: {RelFilterCount}", filter.Id, filter.RelationshipFilters.Count());
foreach (var rf in filter.RelationshipFilters)
// ✅ Получить все связи для юнита
var parentLinks = await unitInUnitService.GetByChildIdAsync(unitId);
var childLinks = await unitInUnitService.GetByParentIdAsync(unitId);
// ✅ Собрать все UnitId, участвующие в связях
var allRelatedUnitIds = parentLinks
.Select(l => l.ParentUnitId)
.Concat(childLinks.Select(l => l.ChildUnitId))
.Distinct()
.ToList();
if (!allRelatedUnitIds.Any()) continue;
// ✅ Получить значения для всех связанных юнитов
var allUnitValues = await unitInValueService.GetByUnitIdsAsync(allRelatedUnitIds);
// ✅ Сгруппировать значения по UnitId
var valuesByUnit = allUnitValues
.GroupBy(uv => uv.UnitId)
.ToDictionary(g => g.Key, g => g.ToList());
// ✅ Найти UnitId, которые проходят все RelationshipFilters
var matchingUnitIds = new HashSet<Guid>();
foreach (var relatedUnitId in allRelatedUnitIds)
{
logger.LogDebug("Обработка RelationshipFilter (UnitFilterId={UnitFilterId}): IsParent={IsParent}, FieldId={FieldId}, ValueMask={ValueMask}",
rf.UnitFilterId, rf.IsParent, rf.FieldId, rf.ValueMask);
var valueMask = rf.ValueMask?.Trim();
if (string.IsNullOrEmpty(valueMask)) continue;
List<UnitInUnit> relevantLinks;
if (rf.IsParent)
relevantLinks = await unitInUnitService.GetByChildIdAsync(unitId);
else
relevantLinks = await unitInUnitService.GetByParentIdAsync(unitId);
var unitIdsToCheck = rf.IsParent
? relevantLinks.Select(l => l.ParentUnitId).ToList()
: relevantLinks.Select(l => l.ChildUnitId).ToList();
if (!unitIdsToCheck.Any()) continue;
var unitValues = await unitInValueService.GetByUnitIdsAsync(unitIdsToCheck);
var matchingUnitIds = unitValues
.Where(uv => uv.FieldId == rf.FieldId
&& uv.Value?.Value != null
&& uv.Value.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
.Select(uv => uv.UnitId)
.Distinct()
.ToList();
if (matchingUnitIds.Any())
// ✅ Изменяем на `All` — все фильтры должны подходить
bool passesAllFilters = filter.RelationshipFilters.All(rf =>
{
var names = await unitService.Get().AsNoTracking()
.Where(u => matchingUnitIds.Contains(u.Id))
.Select(u => u.Name)
.ToListAsync();
result.UnionWith(names);
}
var values = valuesByUnit.GetValueOrDefault(relatedUnitId, new List<UnitInValue>());
var matchingValues = values
.Where(uv => uv.FieldId == rf.FieldId && uv.Value?.Value != null)
.ToList();
if (!matchingValues.Any()) // Нет значений по полю
{
// ✅ Если нет значений, и IsInverse = false → не подходит
// ✅ Если нет значений, и IsInverse = true → подходит
return rf.IsInverse;
}
var hasMatch = matchingValues.Any(uv => uv.Value!.Value!.Contains(rf.ValueMask.Trim('%'), StringComparison.OrdinalIgnoreCase));
// ✅ Учитываем IsInverse
if (rf.IsInverse)
hasMatch = !hasMatch;
return hasMatch;
});
if (passesAllFilters)
matchingUnitIds.Add(relatedUnitId);
}
if (matchingUnitIds.Any())
{
var names = await unitService.Get().AsNoTracking()
.Where(u => matchingUnitIds.Contains(u.Id))
.Select(u => u.Name)
.ToListAsync();
result.UnionWith(names);
}
}
@@ -450,21 +474,25 @@ namespace PARR.DAL.DomainServices.Implementations
if (string.IsNullOrEmpty(valueMask))
return query;
logger.LogDebug("Применяем FieldFilterDto: FieldId={FieldId}, ValueMask={ValueMask}", fieldFilter.FieldId, valueMask);
logger.LogDebug("Применяем FieldFilterDto: FieldId={FieldId}, ValueMask={ValueMask}, IsInverse={IsInverse}", fieldFilter.FieldId, valueMask, fieldFilter.IsInverse);
bool isStartsWith = valueMask.EndsWith("%") && !valueMask.EndsWith("%%");
bool isEndsWith = valueMask.StartsWith("%") && !valueMask.StartsWith("%%");
return query.AsEnumerable().Where(dto =>
dto.Values.Any(v =>
{
var hasMatch = dto.Values.Any(v =>
v.FieldId == fieldFilter.FieldId &&
v.Value != null &&
(isStartsWith && isEndsWith ? v.Value.Contains(valueMask.Trim('%'), StringComparison.OrdinalIgnoreCase) :
isStartsWith ? v.Value.StartsWith(valueMask.TrimEnd('%'), StringComparison.OrdinalIgnoreCase) :
isEndsWith ? v.Value.EndsWith(valueMask.TrimStart('%'), StringComparison.OrdinalIgnoreCase) :
v.Value.Contains(valueMask, StringComparison.OrdinalIgnoreCase))
)
).AsQueryable();
);
// ✅ Правильная логика: если IsInverse = true, то юнит подходит, если НЕ проходит фильтр
return fieldFilter.IsInverse ? !hasMatch : hasMatch;
}).AsQueryable();
}
private IQueryable<UnitDto> ApplyRelationshipFilterToQuery(IQueryable<UnitDto> query, JobRelationshipFilter relFilter)
@@ -640,13 +668,12 @@ namespace PARR.DAL.DomainServices.Implementations
// Есть фильтры по полям → считаем только связанные юниты, подходящие под фильтр
foreach (var link in links)
{
bool hasMatch = link.Values.Any(v =>
activeFilters.Any(f =>
v.FieldId == f.FieldId &&
v.Value != null &&
v.Value.Contains(f.ValueMask, StringComparison.OrdinalIgnoreCase)
)
);
// ✅ Изменяем на `All` — все фильтры должны подходить
bool hasMatch = activeFilters.All(f =>
{
var value = link.Values.FirstOrDefault(v => v.FieldId == f.FieldId);
return value != null && value.Value != null && value.Value.Contains(f.ValueMask, StringComparison.OrdinalIgnoreCase);
});
if (hasMatch)
matchingCount++;

View File

@@ -1,11 +0,0 @@
namespace PARR.DAL.DomainServices.Interfaces
{
public interface IGroupedShortcodesCacheService
{
Task<string> GetAggregatedValueAsync(
Guid jobGroupId,
Guid unitId,
string shortcodeKey,
Func<Task<string>> computeIfMissing);
}
}

View File

@@ -0,0 +1,10 @@
namespace PARR.DAL.DomainServices.Shortcodes.Models
{
internal class CachedGroupedShortCode
{
public string Value { get; set; } = string.Empty;
public DateTimeOffset Timestamp { get; set; }
public string? Source { get; set; } = "ShortcodesService";
public int Version { get; set; } = 1;
}
}

View File

@@ -1,6 +1,7 @@
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;
@@ -31,7 +32,7 @@ namespace PARR.DAL.DomainServices.Shortcodes
private readonly IUnitInValueService unitInValueService;
private readonly IUnitFieldService unitFieldService;
private readonly ITemplateService templateService;
private readonly IGroupedShortcodesCacheService groupedShortcodesCacheService;
private readonly IRedisCacheService cacheService;
private readonly IUnitFilterService unitFilterService;
public ShortcodesService(
@@ -43,7 +44,7 @@ namespace PARR.DAL.DomainServices.Shortcodes
IUnitInValueService unitInValueService,
IUnitFieldService unitFieldService,
ITemplateService templateService,
IGroupedShortcodesCacheService groupedShortcodesCacheService
IRedisCacheService cacheService
)
{
this.logger = logger;
@@ -53,7 +54,7 @@ namespace PARR.DAL.DomainServices.Shortcodes
this.unitInValueService = unitInValueService;
this.unitFieldService = unitFieldService;
this.templateService = templateService;
this.groupedShortcodesCacheService = groupedShortcodesCacheService;
this.cacheService = cacheService;
this.unitFilterService = unitFilterService;
}
@@ -124,8 +125,7 @@ namespace PARR.DAL.DomainServices.Shortcodes
var maxShortcodes = Regex.Matches(resultName, maxShortcodePattern);
if (maxShortcodes.Count > 0)
{
var unitIds = template.UnitsInTemplate.Select(uit => uit.UnitId).ToList();
resultName = await ReplaceMaxShortcodesAsync(job.Group.Id, template.UnitId, unitIds, resultName, maxShortcodes);
resultName = await ReplaceMaxShortcodesAsync(job.Group.Id, template.UnitId, resultName, maxShortcodes);
}
}
@@ -273,9 +273,6 @@ namespace PARR.DAL.DomainServices.Shortcodes
new ShortcodeInfoDto { Shortcode = "%МАКС:ИМЯ АТРИБУТА%",
Description = "Используется только с групповым типом работ. Наиболее часто встречающееся значение поля в группе (игнорирует пустые). Пример: %МАКС:РАБОЧАЯ_ГР_ОТВ_ЗАК%",
Type = ShortcodeTypeEnum.Standart },
new ShortcodeInfoDto { Shortcode = "%ГРОЛЕ-ПН%",
Description = "Нумерованный список unit-ов из шаблона: 1. ЭК-123 (Значение1, Значение2). Использует GroupingUnitFieldId из JobGroup.",
Type = ShortcodeTypeEnum.Relationship },
new ShortcodeInfoDto{ Shortcode = "%БУКВЫ:ИМЯ АТРИБУТА%",
Description = "Извлекает только буквы из значения поля. Пример: %БУКВЫ:ЗОНА_ОТВЕТСТВЕННОСТИ% → ПРИВ",
Type = ShortcodeTypeEnum.Standart
@@ -290,7 +287,10 @@ namespace PARR.DAL.DomainServices.Shortcodes
Type = ShortcodeTypeEnum.Relationship },
new ShortcodeInfoDto { Shortcode = "%СВЯЗИ-ПН%",
Description = "Связанные ЭК с нумерацией (1. ..., 2. ...), выбираются только при настроенном фильтре по полям в связанных ЭК",
Type = ShortcodeTypeEnum.Relationship }
Type = ShortcodeTypeEnum.Relationship },
new ShortcodeInfoDto { Shortcode = "%ГРОЛЕ-ПН%",
Description = "Нумерованный список unit-ов из шаблона: 1. ЭК-123 (Значение1, Значение2). Использует GroupingUnitFieldId из JobGroup.",
Type = ShortcodeTypeEnum.Relationship },
});
// 4. Все доступные поля из UnitField
@@ -363,7 +363,7 @@ namespace PARR.DAL.DomainServices.Shortcodes
return resultName;
}
private async Task<string> ReplaceMaxShortcodesAsync(Guid jobGroupId, Guid unitId, List<Guid> unitIds, string input, MatchCollection maxShortcodes)
private async Task<string> ReplaceMaxShortcodesAsync(Guid jobGroupId, Guid unitId, string input, MatchCollection maxShortcodes)
{
var shortcodeToMatches = maxShortcodes
.Cast<Match>()
@@ -379,56 +379,69 @@ namespace PARR.DAL.DomainServices.Shortcodes
logger.LogDebug("Обработка {Shortcode} для JobGroup {JobGroupId}, Template.UnitId {UnitId}, fieldName {FieldName}",
fullShortcode, jobGroupId, unitId, fieldName);
var mostFrequentValue = await groupedShortcodesCacheService.GetAggregatedValueAsync(
jobGroupId,
unitId,
fullShortcode,
async () =>
{
logger.LogDebug("Кэш промахнут для {Shortcode}, начинаем вычисление.", fullShortcode);
// ✅ Пытаемся загрузить UnitsInTemplate из БД
var dbUnitIds = await jobService.Get()
.Where(j => j.GroupId == jobGroupId)
.Join(
templateService.Get()
.Where(t => t.StatusTypeId == TemplateStatusTypeEnum.Used)
.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("Загружено {Count} unitIds из БД для JobGroup {JobGroupId}", dbUnitIds.Count, jobGroupId);
// ✅ Если в БД нет UnitsInTemplate — используем переданные unitIds
var effectiveUnitIds = dbUnitIds.Any() ? dbUnitIds : unitIds;
logger.LogDebug("EffectiveUnitIds: [{Ids}], Count: {Count}", string.Join(", ", effectiveUnitIds), effectiveUnitIds.Count);
// Вызываем метод из сервиса
var result = await unitInValueService.GetMostFrequentValueForFieldAsync(effectiveUnitIds, fieldName);
logger.LogDebug("Результат GetMostFrequentValueForFieldAsync: {Result}, для поля {FieldName}, unitIds: [{Ids}]", result, fieldName, string.Join(", ", effectiveUnitIds));
return result ?? string.Empty;
});
logger.LogDebug("Итоговое значение для {Shortcode}: {Value}", fullShortcode, mostFrequentValue);
var mostFrequentValue = await GetMaxShortCodeFromCacheOrDbAsync(jobGroupId, unitId, fullShortcode, fieldName);
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)
{
var cacheKey = $"gr_shcd_{jobGroupId:N}_{unitId:N}_{ComputeHash(fullShortcode)}";
var cachedData = await cacheService.GetCachedDataAsync<CachedGroupedShortCode>(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<string> ReplaceLettersShortcodesAsync(Guid unitId, string input, MatchCollection lettersShortcodes)
{
var shortcodeToMatches = lettersShortcodes
@@ -485,5 +498,12 @@ namespace PARR.DAL.DomainServices.Shortcodes
}
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);
}
}
}