using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Broadcast; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Domain.Programming; namespace TeleWave.Application.Programming.Templates.Transfer; /// /// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя /// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось. /// /// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым. /// public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer) : ICommandHandler> { public async Task> Handle( ImportGridCommand command, CancellationToken cancellationToken ) { var template = await dbContext .ScheduleTemplates.Include(t => t.Layers) .ThenInclude(l => l.Slots) .AsSplitQuery() .FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken); if (template is null) return Result.Failure(ChannelErrors.TemplateNotFound); var groups = await NamesAsync( dbContext.Groups.AsNoTracking().Select(g => new NamedRef(g.Id, g.Name)), cancellationToken ); var junctions = await NamesAsync( dbContext.JunctionTemplates.AsNoTracking().Select(j => new NamedRef(j.Id, j.Name)), cancellationToken ); var warnings = new List(); var removed = command.Replace ? Clear(template) : 0; var created = 0; var skipped = 0; foreach (var configLayer in command.Config.Layers) { var layer = EnsureLayer(template, configLayer); foreach (var configSlot in configLayer.Slots) { var input = ToInput(configSlot, groups, junctions, warnings); if (input is null) { skipped++; continue; } var applied = await writer.ApplyAsync(layer, null, input, cancellationToken); if (applied.IsSuccess) { created++; continue; } skipped++; warnings.Add( $"Слот «{configSlot.Title}» ({configSlot.Start:HH\\:mm}): {applied.Error.Message}" ); } } Apply(template, command.Config, groups, junctions, warnings); template.MarkChanged(); return Result.Success( new GridImportResultDto( command.Config.Layers.Count, created, skipped, removed, warnings ) ); } /// Снимает все слоты шаблона и возвращает их число — режим замены. private static int Clear(ScheduleTemplate template) { var removed = 0; foreach (var layer in template.Layers) { // Идентификаторы снимаем заранее: удаление правит ту самую коллекцию, по которой идём. var slotIds = layer.Slots.Select(s => s.Id).ToList(); foreach (var slotId in slotIds) layer.RemoveSlot(slotId); removed += slotIds.Count; } return removed; } /// /// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен /// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один /// и создаётся вместе с шаблоном. /// private static GridLayer EnsureLayer(ScheduleTemplate template, GridConfigLayer configLayer) { if (configLayer.IsBackground && template.Background is { } background) return background; var existing = template.Layers.FirstOrDefault(l => !l.IsBackground && string.Equals(l.Name, configLayer.Name, StringComparison.OrdinalIgnoreCase) ); if (existing is not null) return existing; var layer = template.AddLayer(configLayer.Name, configLayer.Priority); layer.Update( configLayer.Name, configLayer.Priority, configLayer.Applicability?.ToJson(), configLayer.IsEnabled ); return layer; } /// Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале. private static void Apply( ScheduleTemplate template, GridConfig config, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, List warnings ) { if (config.Rules is { } rules) template.SetRules(rules.ToJson()); if (config.FallbackGroup is { Length: > 0 } fallback) { if (Resolve(fallback, groups, "Группа", warnings) is { } groupId) template.SetFallbackGroup(groupId); } if (config.DefaultJunction is { Length: > 0 } junction) { if (Resolve(junction, junctions, "Стык", warnings) is { } junctionId) template.SetDefaultJunction(junctionId); } } private static SlotInput? ToInput( GridConfigSlot slot, IReadOnlyDictionary groups, IReadOnlyDictionary junctions, List warnings ) { if (string.IsNullOrWhiteSpace(slot.Title) || slot.DurationMinutes <= 0) { warnings.Add($"Слот «{slot.Title}»: нет названия или длительности."); return null; } Guid? groupId = null; if (slot.Group is { Length: > 0 } groupName) { groupId = Resolve(groupName, groups, "Группа", warnings); if (groupId is null) return null; } else if (slot.Kind == SlotKind.Content) { warnings.Add($"Слот «{slot.Title}»: для содержательного слота нужна группа."); return null; } return new SlotInput( slot.Title, slot.Weekday, slot.Start, slot.DurationMinutes, slot.Daypart, slot.Kind, groupId, slot.Strategy, slot.Repeat, slot.BlockMode, slot.BlockValue, slot.Overflow, slot.IsAnchor, slot.MaxDriftMinutes, slot.SnapToMinutes, slot.JunctionBetween is { Length: > 0 } between ? Resolve(between, junctions, "Стык", warnings) : null, slot.JunctionAfter is { Length: > 0 } after ? Resolve(after, junctions, "Стык", warnings) : null ); } private static Guid? Resolve( string name, IReadOnlyDictionary known, string subject, List warnings ) { if (known.TryGetValue(name.Trim(), out var id)) return id; var warning = $"{subject} «{name}» не найдена в библиотеке."; if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning); return null; } /// Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное. private static async Task> NamesAsync( IQueryable source, CancellationToken cancellationToken ) { var items = await source.ToListAsync(cancellationToken); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in items) map.TryAdd(item.Name.Trim(), item.Id); return map; } private sealed record NamedRef(Guid Id, string Name); }