Files
TeleWave/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs
T
Leonid Pershin f7e7b5f7c3
ci / build-backend (push) Successful in 1m31s
ci / build-frontend (push) Successful in 1m5s
ci / tests (push) Successful in 3m54s
ci / sonar (push) Successful in 4m40s
Implement template export/import endpoints and enhance grid generation logic
Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
2026-07-28 02:15:04 +03:00

240 lines
8.7 KiB
C#

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;
/// <summary>
/// Загружает сетку из файла. Ссылки в файле — имена, поэтому импорт их разрешает: незнакомое имя
/// не валит загрузку, а становится замечанием, и админ видит списком, чего в библиотеке не нашлось.
///
/// Эфир при этом не двигается — как и любая правка сетки, импорт только помечает шаблон изменённым.
/// </summary>
public sealed class ImportGridCommandHandler(IAppDbContext dbContext, SlotWriter writer)
: ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
{
public async Task<Result<GridImportResultDto>> 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<GridImportResultDto>(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<string>();
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
)
);
}
/// <summary>Снимает все слоты шаблона и возвращает их число — режим замены.</summary>
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;
}
/// <summary>
/// Слой под слоты файла. Существующий берётся по имени: повторный импорт того же файла должен
/// давать ту же сетку, а не «Основная сетка (2)». Фоновый слой по имени не ищется — он один
/// и создаётся вместе с шаблоном.
/// </summary>
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;
}
/// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
private static void Apply(
ScheduleTemplate template,
GridConfig config,
IReadOnlyDictionary<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> 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<string, Guid> groups,
IReadOnlyDictionary<string, Guid> junctions,
List<string> 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<string, Guid> known,
string subject,
List<string> 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;
}
/// <summary>Справочник «имя → идентификатор». Одинаковые имена схлопываются в первое найденное.</summary>
private static async Task<Dictionary<string, Guid>> NamesAsync(
IQueryable<NamedRef> source,
CancellationToken cancellationToken
)
{
var items = await source.ToListAsync(cancellationToken);
var map = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
map.TryAdd(item.Name.Trim(), item.Id);
return map;
}
private sealed record NamedRef(Guid Id, string Name);
}