Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
File diff suppressed because it is too large
Load Diff
@@ -1,237 +1,237 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
||||
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
||||
/// фильм — в одну единицу.
|
||||
///
|
||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||
/// обрабатывается, значит получить дыру в раздаче.
|
||||
/// </summary>
|
||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
{
|
||||
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||
/// null — история не нужна и не загружается.</param>
|
||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
Guid channelId,
|
||||
CancellationToken cancellationToken,
|
||||
DateTimeOffset? historyFrom = null
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||
if (groupIds.Count == 0)
|
||||
return result;
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var collectionParts = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.Concat(collectionParts.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var loaded = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Audience,
|
||||
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
|
||||
|
||||
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
|
||||
var durations = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||
var recentPlays = await LoadRecentPlaysAsync(
|
||||
channelId,
|
||||
showIds,
|
||||
historyFrom,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var data = new ExpansionData(
|
||||
shows,
|
||||
durations,
|
||||
lastPlayed,
|
||||
recentPlays,
|
||||
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
||||
);
|
||||
|
||||
foreach (var group in groups)
|
||||
result[group.Id] = group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(item =>
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? data.ShowElement(item)
|
||||
: data.CollectionElement(item)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
|
||||
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
|
||||
|
||||
/// <summary>
|
||||
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
|
||||
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
|
||||
/// В БД отсюда не ходят — всё уже загружено.
|
||||
/// </summary>
|
||||
private sealed record ExpansionData(
|
||||
IReadOnlyDictionary<Guid, ShowRow> Shows,
|
||||
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
||||
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
|
||||
ILookup<Guid, Guid> CollectionParts
|
||||
)
|
||||
{
|
||||
public PlanningElement ShowElement(GroupItem item) =>
|
||||
new(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
UnitsOf(item.ElementId),
|
||||
LastPlayedOf(item.ElementId),
|
||||
AudienceOf(item.ElementId),
|
||||
RecentPlaysOf(item.ElementId)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
|
||||
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
||||
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
||||
/// </summary>
|
||||
public PlanningElement CollectionElement(GroupItem item)
|
||||
{
|
||||
var parts = CollectionParts[item.ElementId].ToList();
|
||||
return new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
parts.SelectMany(UnitsOf).ToList(),
|
||||
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
|
||||
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
|
||||
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
|
||||
private List<PlanningUnit> UnitsOf(Guid showId) =>
|
||||
Shows.TryGetValue(showId, out var show)
|
||||
? show
|
||||
.AssetIds.Where(Durations.ContainsKey)
|
||||
.Select(
|
||||
(assetId, index) =>
|
||||
new PlanningUnit(assetId, Durations[assetId], showId, index)
|
||||
)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
private DateTimeOffset? LastPlayedOf(Guid showId) =>
|
||||
LastPlayed.TryGetValue(showId, out var at) ? at : null;
|
||||
|
||||
private ShowAudience? AudienceOf(Guid showId) =>
|
||||
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
|
||||
|
||||
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
|
||||
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||
/// остывание среди правил.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId.Value)
|
||||
)
|
||||
.GroupBy(e => e.ShowId!.Value)
|
||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
||||
/// это лишние сотни строк на каждый прогон.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
DateTimeOffset? from,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (from is not { } since || showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var plays = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId.Value)
|
||||
&& e.StartsAtUtc >= since
|
||||
)
|
||||
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return plays
|
||||
.GroupBy(p => p.ShowId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
||||
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
||||
/// фильм — в одну единицу.
|
||||
///
|
||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||
/// обрабатывается, значит получить дыру в раздаче.
|
||||
/// </summary>
|
||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
{
|
||||
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||
/// null — история не нужна и не загружается.</param>
|
||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
Guid channelId,
|
||||
CancellationToken cancellationToken,
|
||||
DateTimeOffset? historyFrom = null
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||
if (groupIds.Count == 0)
|
||||
return result;
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var collectionParts = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.OrderBy(i => i.Position)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.Concat(collectionParts.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var loaded = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Audience,
|
||||
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
|
||||
|
||||
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
|
||||
var durations = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||
var recentPlays = await LoadRecentPlaysAsync(
|
||||
channelId,
|
||||
showIds,
|
||||
historyFrom,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var data = new ExpansionData(
|
||||
shows,
|
||||
durations,
|
||||
lastPlayed,
|
||||
recentPlays,
|
||||
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
||||
);
|
||||
|
||||
foreach (var group in groups)
|
||||
result[group.Id] = group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(item =>
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? data.ShowElement(item)
|
||||
: data.CollectionElement(item)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
|
||||
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
|
||||
|
||||
/// <summary>
|
||||
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
|
||||
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
|
||||
/// В БД отсюда не ходят — всё уже загружено.
|
||||
/// </summary>
|
||||
private sealed record ExpansionData(
|
||||
IReadOnlyDictionary<Guid, ShowRow> Shows,
|
||||
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
||||
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
|
||||
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
|
||||
ILookup<Guid, Guid> CollectionParts
|
||||
)
|
||||
{
|
||||
public PlanningElement ShowElement(GroupItem item) =>
|
||||
new(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
UnitsOf(item.ElementId),
|
||||
LastPlayedOf(item.ElementId),
|
||||
AudienceOf(item.ElementId),
|
||||
RecentPlaysOf(item.ElementId)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
|
||||
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
||||
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
||||
/// </summary>
|
||||
public PlanningElement CollectionElement(GroupItem item)
|
||||
{
|
||||
var parts = CollectionParts[item.ElementId].ToList();
|
||||
return new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
parts.SelectMany(UnitsOf).ToList(),
|
||||
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
|
||||
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
|
||||
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
|
||||
private List<PlanningUnit> UnitsOf(Guid showId) =>
|
||||
Shows.TryGetValue(showId, out var show)
|
||||
? show
|
||||
.AssetIds.Where(Durations.ContainsKey)
|
||||
.Select(
|
||||
(assetId, index) =>
|
||||
new PlanningUnit(assetId, Durations[assetId], showId, index)
|
||||
)
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
private DateTimeOffset? LastPlayedOf(Guid showId) =>
|
||||
LastPlayed.TryGetValue(showId, out var at) ? at : null;
|
||||
|
||||
private ShowAudience? AudienceOf(Guid showId) =>
|
||||
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
|
||||
|
||||
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
|
||||
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||
/// остывание среди правил.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId.Value)
|
||||
)
|
||||
.GroupBy(e => e.ShowId!.Value)
|
||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
||||
/// это лишние сотни строк на каждый прогон.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
||||
Guid channelId,
|
||||
IReadOnlyCollection<Guid> showIds,
|
||||
DateTimeOffset? from,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (from is not { } since || showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var plays = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == channelId
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.ShowId != null
|
||||
&& showIds.Contains(e.ShowId.Value)
|
||||
&& e.StartsAtUtc >= since
|
||||
)
|
||||
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return plays
|
||||
.GroupBy(p => p.ShowId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
/// <summary>
|
||||
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
||||
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
||||
/// </summary>
|
||||
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
||||
|
||||
public sealed record EntryTraceDto(
|
||||
Guid EntryId,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
string? ShowName,
|
||||
int? EpisodeIndex,
|
||||
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
||||
string? LayerName,
|
||||
int? LayerPriority,
|
||||
string? SlotTitle,
|
||||
SlotKind? SlotKind,
|
||||
int? SlotWeekday,
|
||||
TimeOnly? SlotTargetStart,
|
||||
int? SlotDurationMinutes,
|
||||
string? GroupName,
|
||||
int? GroupItemCount,
|
||||
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
|
||||
string? CollectionName,
|
||||
SlotStrategyKind? Strategy,
|
||||
int? CooldownDays,
|
||||
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
||||
int? CandidatesAfterCooldown,
|
||||
int DriftMinutes,
|
||||
bool Snapped,
|
||||
string? JunctionName
|
||||
);
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
/// <summary>
|
||||
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
||||
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
||||
/// </summary>
|
||||
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
||||
|
||||
public sealed record EntryTraceDto(
|
||||
Guid EntryId,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
string? ShowName,
|
||||
int? EpisodeIndex,
|
||||
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
||||
string? LayerName,
|
||||
int? LayerPriority,
|
||||
string? SlotTitle,
|
||||
SlotKind? SlotKind,
|
||||
int? SlotWeekday,
|
||||
TimeOnly? SlotTargetStart,
|
||||
int? SlotDurationMinutes,
|
||||
string? GroupName,
|
||||
int? GroupItemCount,
|
||||
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
|
||||
string? CollectionName,
|
||||
SlotStrategyKind? Strategy,
|
||||
int? CooldownDays,
|
||||
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
||||
int? CandidatesAfterCooldown,
|
||||
int DriftMinutes,
|
||||
bool Snapped,
|
||||
string? JunctionName
|
||||
);
|
||||
|
||||
+152
-152
@@ -1,152 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
||||
{
|
||||
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public async Task<Result<EntryTraceDto>> Handle(
|
||||
GetEntryTraceQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var entry = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
||||
if (entry is null)
|
||||
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
||||
|
||||
var showName = entry.ShowId is { } showId
|
||||
? await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == showId)
|
||||
.Select(s => s.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var collectionName = entry.CollectionId is { } collectionId
|
||||
? await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => c.Id == collectionId)
|
||||
.Select(c => c.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var trace = Parse(entry.TraceJson);
|
||||
if (trace is null)
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
collectionName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
||||
// часть подписей окажется пустой.
|
||||
var slot = trace.SlotId is { } slotId
|
||||
? await dbContext
|
||||
.Slots.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
||||
: null;
|
||||
var layer = slot is null
|
||||
? null
|
||||
: await dbContext
|
||||
.GridLayers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
||||
|
||||
var group = slot?.GroupId is { } groupId
|
||||
? await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => g.Id == groupId)
|
||||
.Select(g => new { g.Name, g.ItemCount })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
||||
|
||||
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
||||
var junctionName = junctionId is { } id
|
||||
? await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Where(j => j.Id == id)
|
||||
.Select(j => j.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
layer?.Name,
|
||||
layer?.Priority,
|
||||
slot?.Title,
|
||||
trace.SlotKind,
|
||||
slot?.Weekday,
|
||||
slot?.TargetStart,
|
||||
slot?.TargetDurationMinutes,
|
||||
group?.Name,
|
||||
group?.ItemCount,
|
||||
collectionName,
|
||||
trace.Strategy,
|
||||
strategy?.CooldownDays,
|
||||
trace.CandidatesAfterCooldown,
|
||||
trace.DriftMinutes,
|
||||
trace.Snapped,
|
||||
junctionName
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanTrace? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
||||
{
|
||||
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public async Task<Result<EntryTraceDto>> Handle(
|
||||
GetEntryTraceQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var entry = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
||||
if (entry is null)
|
||||
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
||||
|
||||
var showName = entry.ShowId is { } showId
|
||||
? await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == showId)
|
||||
.Select(s => s.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var collectionName = entry.CollectionId is { } collectionId
|
||||
? await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => c.Id == collectionId)
|
||||
.Select(c => c.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var trace = Parse(entry.TraceJson);
|
||||
if (trace is null)
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
collectionName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
||||
// часть подписей окажется пустой.
|
||||
var slot = trace.SlotId is { } slotId
|
||||
? await dbContext
|
||||
.Slots.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
||||
: null;
|
||||
var layer = slot is null
|
||||
? null
|
||||
: await dbContext
|
||||
.GridLayers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
||||
|
||||
var group = slot?.GroupId is { } groupId
|
||||
? await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => g.Id == groupId)
|
||||
.Select(g => new { g.Name, g.ItemCount })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
||||
|
||||
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
||||
var junctionName = junctionId is { } id
|
||||
? await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Where(j => j.Id == id)
|
||||
.Select(j => j.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
layer?.Name,
|
||||
layer?.Priority,
|
||||
slot?.Title,
|
||||
trace.SlotKind,
|
||||
slot?.Weekday,
|
||||
slot?.TargetStart,
|
||||
slot?.TargetDurationMinutes,
|
||||
group?.Name,
|
||||
group?.ItemCount,
|
||||
collectionName,
|
||||
trace.Strategy,
|
||||
strategy?.CooldownDays,
|
||||
trace.CandidatesAfterCooldown,
|
||||
trace.DriftMinutes,
|
||||
trace.Snapped,
|
||||
junctionName
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanTrace? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+231
-231
@@ -1,231 +1,231 @@
|
||||
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.CopyTemplate;
|
||||
|
||||
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
||||
{
|
||||
public async Task<Result<CopyTemplateResultDto>> Handle(
|
||||
CopyTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var target = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||
if (target is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||
|
||||
var source = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
||||
if (source is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var sourceJunctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||
var bumperByName = target
|
||||
.BumperTemplates.GroupBy(t => t.Name)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
var sourceBumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == command.SourceChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.Select(t => new { t.Id, t.Name })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
||||
sourceJunctions,
|
||||
target.Id,
|
||||
sourceBumperNames,
|
||||
bumperByName
|
||||
);
|
||||
|
||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||
var existing = await dbContext
|
||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||
await dbContext
|
||||
.JunctionTemplates.Where(j =>
|
||||
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||
)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||
copyTemplate.SetRules(source.RulesJson);
|
||||
if (
|
||||
source.DefaultJunctionId is { } defaultJunction
|
||||
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
||||
)
|
||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||
|
||||
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
||||
|
||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||
target.SetTemplate(copyTemplate.Id);
|
||||
|
||||
return Result.Success(
|
||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
||||
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
||||
/// </summary>
|
||||
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
||||
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
||||
Guid targetChannelId,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<Guid, Guid>();
|
||||
var dropped = 0;
|
||||
|
||||
foreach (var junction in sourceJunctions)
|
||||
{
|
||||
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
||||
map[junction.Id] = copy.Id;
|
||||
|
||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||
{
|
||||
var bumperTemplateId = MapBumper(
|
||||
element,
|
||||
sourceBumperNames,
|
||||
targetBumperByName,
|
||||
ref dropped
|
||||
);
|
||||
copy.AddElement(element.Kind)
|
||||
.Update(
|
||||
element.Kind,
|
||||
element.GroupId,
|
||||
bumperTemplateId,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
element.ConditionsJson
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.JunctionTemplates.Add(copy);
|
||||
}
|
||||
|
||||
return (map, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
||||
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
||||
/// и это попадает в отчёт.
|
||||
/// </summary>
|
||||
private static Guid? MapBumper(
|
||||
JunctionElement element,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
||||
ref int dropped
|
||||
)
|
||||
{
|
||||
if (element.Kind != JunctionElementKind.Bumper)
|
||||
return null;
|
||||
|
||||
if (
|
||||
element.BumperTemplateId is { } sourceId
|
||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||
&& targetBumperByName.TryGetValue(name, out var mapped)
|
||||
)
|
||||
return mapped;
|
||||
|
||||
dropped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
||||
private static (int Layers, int Slots) CopyGrid(
|
||||
ScheduleTemplate source,
|
||||
ScheduleTemplate copyTemplate,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
)
|
||||
{
|
||||
var layers = 0;
|
||||
var slots = 0;
|
||||
|
||||
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
||||
{
|
||||
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
||||
var copyLayer = layer.IsBackground
|
||||
? copyTemplate.Background!
|
||||
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
||||
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
||||
if (!layer.IsBackground)
|
||||
layers++;
|
||||
|
||||
foreach (var slot in layer.Slots)
|
||||
{
|
||||
CopySlot(slot, copyLayer, junctionMap);
|
||||
slots++;
|
||||
}
|
||||
}
|
||||
|
||||
return (layers, slots);
|
||||
}
|
||||
|
||||
private static void CopySlot(
|
||||
Slot slot,
|
||||
GridLayer copyLayer,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
)
|
||||
{
|
||||
var copySlot = copyLayer.AddSlot(
|
||||
slot.Title,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.Weekday
|
||||
);
|
||||
copySlot.UpdateTiming(
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes
|
||||
);
|
||||
copySlot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.StrategyJson,
|
||||
slot.RepeatSourceJson,
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
Map(slot.JunctionBetweenId, junctionMap),
|
||||
Map(slot.JunctionAfterId, junctionMap)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||
}
|
||||
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.CopyTemplate;
|
||||
|
||||
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
||||
{
|
||||
public async Task<Result<CopyTemplateResultDto>> Handle(
|
||||
CopyTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var target = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||
if (target is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||
|
||||
var source = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
||||
if (source is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var sourceJunctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||
var bumperByName = target
|
||||
.BumperTemplates.GroupBy(t => t.Name)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
var sourceBumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == command.SourceChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.Select(t => new { t.Id, t.Name })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
||||
sourceJunctions,
|
||||
target.Id,
|
||||
sourceBumperNames,
|
||||
bumperByName
|
||||
);
|
||||
|
||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||
var existing = await dbContext
|
||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||
await dbContext
|
||||
.JunctionTemplates.Where(j =>
|
||||
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||
)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||
copyTemplate.SetRules(source.RulesJson);
|
||||
if (
|
||||
source.DefaultJunctionId is { } defaultJunction
|
||||
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
||||
)
|
||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||
|
||||
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
||||
|
||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||
target.SetTemplate(copyTemplate.Id);
|
||||
|
||||
return Result.Success(
|
||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
||||
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
||||
/// </summary>
|
||||
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
||||
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
||||
Guid targetChannelId,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<Guid, Guid>();
|
||||
var dropped = 0;
|
||||
|
||||
foreach (var junction in sourceJunctions)
|
||||
{
|
||||
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
||||
map[junction.Id] = copy.Id;
|
||||
|
||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||
{
|
||||
var bumperTemplateId = MapBumper(
|
||||
element,
|
||||
sourceBumperNames,
|
||||
targetBumperByName,
|
||||
ref dropped
|
||||
);
|
||||
copy.AddElement(element.Kind)
|
||||
.Update(
|
||||
element.Kind,
|
||||
element.GroupId,
|
||||
bumperTemplateId,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
element.ConditionsJson
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.JunctionTemplates.Add(copy);
|
||||
}
|
||||
|
||||
return (map, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
||||
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
||||
/// и это попадает в отчёт.
|
||||
/// </summary>
|
||||
private static Guid? MapBumper(
|
||||
JunctionElement element,
|
||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
||||
ref int dropped
|
||||
)
|
||||
{
|
||||
if (element.Kind != JunctionElementKind.Bumper)
|
||||
return null;
|
||||
|
||||
if (
|
||||
element.BumperTemplateId is { } sourceId
|
||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||
&& targetBumperByName.TryGetValue(name, out var mapped)
|
||||
)
|
||||
return mapped;
|
||||
|
||||
dropped++;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
||||
private static (int Layers, int Slots) CopyGrid(
|
||||
ScheduleTemplate source,
|
||||
ScheduleTemplate copyTemplate,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
)
|
||||
{
|
||||
var layers = 0;
|
||||
var slots = 0;
|
||||
|
||||
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
||||
{
|
||||
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
||||
var copyLayer = layer.IsBackground
|
||||
? copyTemplate.Background!
|
||||
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
||||
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
||||
if (!layer.IsBackground)
|
||||
layers++;
|
||||
|
||||
foreach (var slot in layer.Slots)
|
||||
{
|
||||
CopySlot(slot, copyLayer, junctionMap);
|
||||
slots++;
|
||||
}
|
||||
}
|
||||
|
||||
return (layers, slots);
|
||||
}
|
||||
|
||||
private static void CopySlot(
|
||||
Slot slot,
|
||||
GridLayer copyLayer,
|
||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||
)
|
||||
{
|
||||
var copySlot = copyLayer.AddSlot(
|
||||
slot.Title,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.Weekday
|
||||
);
|
||||
copySlot.UpdateTiming(
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes
|
||||
);
|
||||
copySlot.UpdateContent(
|
||||
new SlotContent(
|
||||
slot.Title,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.StrategyJson,
|
||||
slot.RepeatSourceJson,
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
Map(slot.JunctionBetweenId, junctionMap),
|
||||
Map(slot.JunctionAfterId, junctionMap)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||
}
|
||||
|
||||
+102
-102
@@ -1,102 +1,102 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
|
||||
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
|
||||
{
|
||||
public async Task<Result<ScheduleTemplateDto>> Handle(
|
||||
GetChannelTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
|
||||
var groupIds = template
|
||||
.Layers.SelectMany(l => l.Slots)
|
||||
.Select(s => s.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var groupNames = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var layers = template
|
||||
.Layers.OrderByDescending(l => l.Priority)
|
||||
.Select(layer => new GridLayerDto(
|
||||
layer.Id,
|
||||
layer.Name,
|
||||
layer.Priority,
|
||||
layer.IsEnabled,
|
||||
layer.IsBackground,
|
||||
LayerApplicability.FromJson(layer.ApplicabilityJson),
|
||||
layer
|
||||
.Slots.OrderBy(s => s.Weekday ?? -1)
|
||||
.ThenBy(s => s.TargetStart)
|
||||
.Select(slot => new SlotDto(
|
||||
slot.Id,
|
||||
slot.LayerId,
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Title,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
SlotStrategy.FromJson(slot.StrategyJson),
|
||||
RepeatSource.FromJson(slot.RepeatSourceJson),
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes,
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ScheduleTemplateDto(
|
||||
template.Id,
|
||||
template.ChannelId,
|
||||
template.Name,
|
||||
template.FallbackGroupId,
|
||||
template.DefaultJunctionId,
|
||||
PlanningRules.FromJson(template.RulesJson),
|
||||
template.Revision,
|
||||
template.AppliedRevision,
|
||||
template.HasPendingChanges,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
layers
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
|
||||
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
|
||||
{
|
||||
public async Task<Result<ScheduleTemplateDto>> Handle(
|
||||
GetChannelTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
|
||||
var groupIds = template
|
||||
.Layers.SelectMany(l => l.Slots)
|
||||
.Select(s => s.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var groupNames = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var layers = template
|
||||
.Layers.OrderByDescending(l => l.Priority)
|
||||
.Select(layer => new GridLayerDto(
|
||||
layer.Id,
|
||||
layer.Name,
|
||||
layer.Priority,
|
||||
layer.IsEnabled,
|
||||
layer.IsBackground,
|
||||
LayerApplicability.FromJson(layer.ApplicabilityJson),
|
||||
layer
|
||||
.Slots.OrderBy(s => s.Weekday ?? -1)
|
||||
.ThenBy(s => s.TargetStart)
|
||||
.Select(slot => new SlotDto(
|
||||
slot.Id,
|
||||
slot.LayerId,
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Title,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||
? gname
|
||||
: null,
|
||||
SlotStrategy.FromJson(slot.StrategyJson),
|
||||
RepeatSource.FromJson(slot.RepeatSourceJson),
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes,
|
||||
slot.JunctionBetweenId,
|
||||
slot.JunctionAfterId
|
||||
))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ScheduleTemplateDto(
|
||||
template.Id,
|
||||
template.ChannelId,
|
||||
template.Name,
|
||||
template.FallbackGroupId,
|
||||
template.DefaultJunctionId,
|
||||
PlanningRules.FromJson(template.RulesJson),
|
||||
template.Revision,
|
||||
template.AppliedRevision,
|
||||
template.HasPendingChanges,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
layers
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-139
@@ -1,139 +1,139 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateLayerCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByTemplateAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.NotFound);
|
||||
|
||||
var layer = template.AddLayer(command.Name, command.Priority);
|
||||
template.MarkChanged();
|
||||
return Result.Success(layer.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(
|
||||
dbContext,
|
||||
command.LayerId,
|
||||
cancellationToken
|
||||
);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
layer.Update(
|
||||
command.Name,
|
||||
command.Priority,
|
||||
command.Applicability?.ToJson(),
|
||||
command.IsEnabled
|
||||
);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(
|
||||
dbContext,
|
||||
command.LayerId,
|
||||
cancellationToken
|
||||
);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
if (layer.IsBackground)
|
||||
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
|
||||
|
||||
template.RemoveLayer(command.LayerId);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(TemplateErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.FallbackGroupId is { } groupId
|
||||
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
|
||||
template.Rename(command.Name);
|
||||
template.SetFallbackGroup(command.FallbackGroupId);
|
||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||
template.SetRules(command.Rules?.ToJson());
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
|
||||
internal static class LayerLoader
|
||||
{
|
||||
public static Task<ScheduleTemplate?> ByTemplateAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid templateId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||
|
||||
public static Task<ScheduleTemplate?> ByLayerAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateLayerCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByTemplateAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(TemplateErrors.NotFound);
|
||||
|
||||
var layer = template.AddLayer(command.Name, command.Priority);
|
||||
template.MarkChanged();
|
||||
return Result.Success(layer.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(
|
||||
dbContext,
|
||||
command.LayerId,
|
||||
cancellationToken
|
||||
);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
layer.Update(
|
||||
command.Name,
|
||||
command.Priority,
|
||||
command.Applicability?.ToJson(),
|
||||
command.IsEnabled
|
||||
);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteLayerCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteLayerCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await LayerLoader.ByLayerAsync(
|
||||
dbContext,
|
||||
command.LayerId,
|
||||
cancellationToken
|
||||
);
|
||||
var layer = template?.FindLayer(command.LayerId);
|
||||
if (template is null || layer is null)
|
||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||
|
||||
if (layer.IsBackground)
|
||||
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
|
||||
|
||||
template.RemoveLayer(command.LayerId);
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure(TemplateErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.FallbackGroupId is { } groupId
|
||||
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
|
||||
template.Rename(command.Name);
|
||||
template.SetFallbackGroup(command.FallbackGroupId);
|
||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||
template.SetRules(command.Rules?.ToJson());
|
||||
template.MarkChanged();
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
|
||||
internal static class LayerLoader
|
||||
{
|
||||
public static Task<ScheduleTemplate?> ByTemplateAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid templateId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||
|
||||
public static Task<ScheduleTemplate?> ByLayerAsync(
|
||||
IAppDbContext dbContext,
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
|
||||
: ICommand<Result<Guid>>;
|
||||
|
||||
public sealed record UpdateLayerCommand(
|
||||
Guid LayerId,
|
||||
string Name,
|
||||
int Priority,
|
||||
LayerApplicability? Applicability,
|
||||
bool IsEnabled
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||
|
||||
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
||||
public sealed record UpdateTemplateCommand(
|
||||
Guid TemplateId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
PlanningRules? Rules
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||
{
|
||||
public CreateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
|
||||
{
|
||||
public UpdateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||
{
|
||||
public UpdateTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
|
||||
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
||||
// всё равно ограничена глубиной хранения ленты.
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
||||
.InclusiveBetween(1, 1000)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
|
||||
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
||||
.Must(window => window.From != window.To)
|
||||
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
||||
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
||||
}
|
||||
}
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
|
||||
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
|
||||
: ICommand<Result<Guid>>;
|
||||
|
||||
public sealed record UpdateLayerCommand(
|
||||
Guid LayerId,
|
||||
string Name,
|
||||
int Priority,
|
||||
LayerApplicability? Applicability,
|
||||
bool IsEnabled
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||
|
||||
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
||||
public sealed record UpdateTemplateCommand(
|
||||
Guid TemplateId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
PlanningRules? Rules
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||
{
|
||||
public CreateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
|
||||
{
|
||||
public UpdateLayerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||
{
|
||||
public UpdateTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
|
||||
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
||||
// всё равно ограничена глубиной хранения ленты.
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
||||
.InclusiveBetween(1, 1000)
|
||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||
|
||||
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
||||
.Must(window => window.From != window.To)
|
||||
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
||||
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||
/// </summary>
|
||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||
///
|
||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||
/// </summary>
|
||||
public sealed record PlanningRules(
|
||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||
int? MaxBreakMinutesPerHour = null,
|
||||
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||
int? MaxGenreSharePercent = null,
|
||||
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||
int? MaxFallbackSharePercent = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||
{
|
||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||
return null;
|
||||
|
||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||
// отменяться более широким окном, случайно наложенным сверху.
|
||||
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
|
||||
return applicable.Count > 0 ? applicable.Min() : null;
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static PlanningRules? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||
/// </summary>
|
||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||
///
|
||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||
/// </summary>
|
||||
public sealed record PlanningRules(
|
||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||
int? MaxBreakMinutesPerHour = null,
|
||||
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||
int? MaxGenreSharePercent = null,
|
||||
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||
int? MaxFallbackSharePercent = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||
{
|
||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||
return null;
|
||||
|
||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||
// отменяться более широким окном, случайно наложенным сверху.
|
||||
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
|
||||
return applicable.Count > 0 ? applicable.Min() : null;
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static PlanningRules? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
using FluentValidation;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
|
||||
public sealed record SlotInput(
|
||||
string Title,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
/// <summary>Стык между единицами внутри блока.</summary>
|
||||
Guid? JunctionBetweenId = null,
|
||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||
Guid? JunctionAfterId = null
|
||||
);
|
||||
|
||||
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
|
||||
{
|
||||
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
|
||||
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
|
||||
|
||||
public SlotInputValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
|
||||
|
||||
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
|
||||
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
|
||||
RuleFor(x => x.BlockValue).GreaterThan(0);
|
||||
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
|
||||
|
||||
RuleFor(x => x.SnapToMinutes)
|
||||
.Must(value => value is null || AllowedSnap.Contains(value.Value))
|
||||
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
|
||||
|
||||
RuleFor(x => x.Strategy!.CooldownDays)
|
||||
.InclusiveBetween(0, 3650)
|
||||
.When(x => x.Strategy is not null);
|
||||
|
||||
RuleFor(x => x.RepeatSource!.DaysAgo)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
RuleFor(x => x.RepeatSource!.DurationMinutes)
|
||||
.InclusiveBetween(1, 24 * 60)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
}
|
||||
}
|
||||
using FluentValidation;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
|
||||
public sealed record SlotInput(
|
||||
string Title,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
/// <summary>Стык между единицами внутри блока.</summary>
|
||||
Guid? JunctionBetweenId = null,
|
||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||
Guid? JunctionAfterId = null
|
||||
);
|
||||
|
||||
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
|
||||
{
|
||||
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
|
||||
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
|
||||
|
||||
public SlotInputValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
|
||||
|
||||
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
|
||||
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
|
||||
RuleFor(x => x.BlockValue).GreaterThan(0);
|
||||
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
|
||||
|
||||
RuleFor(x => x.SnapToMinutes)
|
||||
.Must(value => value is null || AllowedSnap.Contains(value.Value))
|
||||
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
|
||||
|
||||
RuleFor(x => x.Strategy!.CooldownDays)
|
||||
.InclusiveBetween(0, 3650)
|
||||
.When(x => x.Strategy is not null);
|
||||
|
||||
RuleFor(x => x.RepeatSource!.DaysAgo)
|
||||
.InclusiveBetween(1, 365)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
RuleFor(x => x.RepeatSource!.DurationMinutes)
|
||||
.InclusiveBetween(1, 24 * 60)
|
||||
.When(x => x.RepeatSource is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||
/// им нужны соседние слоты и справочник групп.
|
||||
/// </summary>
|
||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
||||
/// созданием.
|
||||
/// </summary>
|
||||
public async Task<Result> ApplyAsync(
|
||||
GridLayer layer,
|
||||
Slot? slot,
|
||||
SlotInput input,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
|
||||
if (
|
||||
layer.HasOverlap(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
slot?.Id
|
||||
)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.SlotsOverlap);
|
||||
|
||||
if (input.SlotKind == SlotKind.Content)
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.GroupRequired);
|
||||
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
||||
return Result.Failure(TemplateErrors.RepeatSourceRequired);
|
||||
|
||||
var target =
|
||||
slot
|
||||
?? layer.AddSlot(
|
||||
input.Title,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.SlotKind,
|
||||
input.Weekday
|
||||
);
|
||||
|
||||
target.UpdateTiming(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.IsAnchor,
|
||||
input.MaxDriftMinutes,
|
||||
input.SnapToMinutes
|
||||
);
|
||||
target.UpdateContent(
|
||||
new SlotContent(
|
||||
input.Title,
|
||||
input.SlotKind,
|
||||
input.GroupId,
|
||||
input.Strategy?.ToJson(),
|
||||
input.RepeatSource?.ToJson(),
|
||||
input.BlockMode,
|
||||
input.BlockValue,
|
||||
input.OverflowPolicy,
|
||||
input.JunctionBetweenId,
|
||||
input.JunctionAfterId
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
|
||||
/// <summary>Загружает шаблон по слоту.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
|
||||
Guid slotId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(
|
||||
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||
/// им нужны соседние слоты и справочник групп.
|
||||
/// </summary>
|
||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
||||
/// созданием.
|
||||
/// </summary>
|
||||
public async Task<Result> ApplyAsync(
|
||||
GridLayer layer,
|
||||
Slot? slot,
|
||||
SlotInput input,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
|
||||
if (
|
||||
layer.HasOverlap(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
slot?.Id
|
||||
)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.SlotsOverlap);
|
||||
|
||||
if (input.SlotKind == SlotKind.Content)
|
||||
{
|
||||
if (input.GroupId is not { } groupId)
|
||||
return Result.Failure(TemplateErrors.GroupRequired);
|
||||
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
}
|
||||
|
||||
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
||||
return Result.Failure(TemplateErrors.RepeatSourceRequired);
|
||||
|
||||
var target =
|
||||
slot
|
||||
?? layer.AddSlot(
|
||||
input.Title,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.SlotKind,
|
||||
input.Weekday
|
||||
);
|
||||
|
||||
target.UpdateTiming(
|
||||
input.Weekday,
|
||||
input.TargetStart,
|
||||
input.TargetDurationMinutes,
|
||||
input.Daypart,
|
||||
input.IsAnchor,
|
||||
input.MaxDriftMinutes,
|
||||
input.SnapToMinutes
|
||||
);
|
||||
target.UpdateContent(
|
||||
new SlotContent(
|
||||
input.Title,
|
||||
input.SlotKind,
|
||||
input.GroupId,
|
||||
input.Strategy?.ToJson(),
|
||||
input.RepeatSource?.ToJson(),
|
||||
input.BlockMode,
|
||||
input.BlockValue,
|
||||
input.OverflowPolicy,
|
||||
input.JunctionBetweenId,
|
||||
input.JunctionAfterId
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
|
||||
Guid layerId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||
|
||||
/// <summary>Загружает шаблон по слоту.</summary>
|
||||
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
|
||||
Guid slotId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
dbContext
|
||||
.ScheduleTemplates.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(
|
||||
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public sealed record SlotDto(
|
||||
Guid Id,
|
||||
Guid LayerId,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
string Title,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
string? GroupName,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
Guid? JunctionBetweenId,
|
||||
Guid? JunctionAfterId
|
||||
);
|
||||
|
||||
public sealed record GridLayerDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int Priority,
|
||||
bool IsEnabled,
|
||||
bool IsBackground,
|
||||
LayerApplicability? Applicability,
|
||||
IReadOnlyList<SlotDto> Slots
|
||||
);
|
||||
|
||||
public sealed record ScheduleTemplateDto(
|
||||
Guid Id,
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
||||
PlanningRules? Rules,
|
||||
int Revision,
|
||||
int AppliedRevision,
|
||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||
bool HasPendingChanges,
|
||||
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
IReadOnlyList<GridLayerDto> Layers
|
||||
);
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public sealed record SlotDto(
|
||||
Guid Id,
|
||||
Guid LayerId,
|
||||
int? Weekday,
|
||||
TimeOnly TargetStart,
|
||||
int TargetDurationMinutes,
|
||||
string Title,
|
||||
Daypart Daypart,
|
||||
SlotKind SlotKind,
|
||||
Guid? GroupId,
|
||||
string? GroupName,
|
||||
SlotStrategy? Strategy,
|
||||
RepeatSource? RepeatSource,
|
||||
SlotBlockMode BlockMode,
|
||||
int BlockValue,
|
||||
OverflowPolicy OverflowPolicy,
|
||||
bool IsAnchor,
|
||||
int MaxDriftMinutes,
|
||||
int? SnapToMinutes,
|
||||
Guid? JunctionBetweenId,
|
||||
Guid? JunctionAfterId
|
||||
);
|
||||
|
||||
public sealed record GridLayerDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int Priority,
|
||||
bool IsEnabled,
|
||||
bool IsBackground,
|
||||
LayerApplicability? Applicability,
|
||||
IReadOnlyList<SlotDto> Slots
|
||||
);
|
||||
|
||||
public sealed record ScheduleTemplateDto(
|
||||
Guid Id,
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
||||
PlanningRules? Rules,
|
||||
int Revision,
|
||||
int AppliedRevision,
|
||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||
bool HasPendingChanges,
|
||||
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
IReadOnlyList<GridLayerDto> Layers
|
||||
);
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public static class TemplateErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Templates.NotFound",
|
||||
"Шаблон сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error LayerNotFound = Error.NotFound(
|
||||
"Templates.LayerNotFound",
|
||||
"Слой сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error SlotNotFound = Error.NotFound(
|
||||
"Templates.SlotNotFound",
|
||||
"Слот не найден."
|
||||
);
|
||||
|
||||
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
|
||||
"Templates.BackgroundLayerCannotBeDeleted",
|
||||
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
|
||||
);
|
||||
|
||||
public static readonly Error SlotsOverlap = Error.Conflict(
|
||||
"Templates.SlotsOverlap",
|
||||
"Слоты одного слоя пересекаются по времени."
|
||||
);
|
||||
|
||||
public static readonly Error GroupRequired = Error.Validation(
|
||||
"Templates.GroupRequired",
|
||||
"Слоту с контентом нужна группа."
|
||||
);
|
||||
|
||||
public static readonly Error GroupNotFound = Error.NotFound(
|
||||
"Templates.GroupNotFound",
|
||||
"Группа не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionNotFound = Error.NotFound(
|
||||
"Templates.JunctionNotFound",
|
||||
"Шаблон стыка не найден."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionElementNotFound = Error.NotFound(
|
||||
"Templates.JunctionElementNotFound",
|
||||
"Врезка не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionInUse = Error.Conflict(
|
||||
"Templates.JunctionInUse",
|
||||
"Стык используется слотами — сначала отвяжите его."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionGroupRequired = Error.Validation(
|
||||
"Templates.JunctionGroupRequired",
|
||||
"Врезке нужна группа, откуда брать ролики."
|
||||
);
|
||||
|
||||
public static readonly Error RepeatSourceRequired = Error.Validation(
|
||||
"Templates.RepeatSourceRequired",
|
||||
"Слоту-повтору нужно указать, что повторять."
|
||||
);
|
||||
}
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
public static class TemplateErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Templates.NotFound",
|
||||
"Шаблон сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error LayerNotFound = Error.NotFound(
|
||||
"Templates.LayerNotFound",
|
||||
"Слой сетки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error SlotNotFound = Error.NotFound(
|
||||
"Templates.SlotNotFound",
|
||||
"Слот не найден."
|
||||
);
|
||||
|
||||
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
|
||||
"Templates.BackgroundLayerCannotBeDeleted",
|
||||
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
|
||||
);
|
||||
|
||||
public static readonly Error SlotsOverlap = Error.Conflict(
|
||||
"Templates.SlotsOverlap",
|
||||
"Слоты одного слоя пересекаются по времени."
|
||||
);
|
||||
|
||||
public static readonly Error GroupRequired = Error.Validation(
|
||||
"Templates.GroupRequired",
|
||||
"Слоту с контентом нужна группа."
|
||||
);
|
||||
|
||||
public static readonly Error GroupNotFound = Error.NotFound(
|
||||
"Templates.GroupNotFound",
|
||||
"Группа не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionNotFound = Error.NotFound(
|
||||
"Templates.JunctionNotFound",
|
||||
"Шаблон стыка не найден."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionElementNotFound = Error.NotFound(
|
||||
"Templates.JunctionElementNotFound",
|
||||
"Врезка не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionInUse = Error.Conflict(
|
||||
"Templates.JunctionInUse",
|
||||
"Стык используется слотами — сначала отвяжите его."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionGroupRequired = Error.Validation(
|
||||
"Templates.JunctionGroupRequired",
|
||||
"Врезке нужна группа, откуда брать ролики."
|
||||
);
|
||||
|
||||
public static readonly Error RepeatSourceRequired = Error.Validation(
|
||||
"Templates.RepeatSourceRequired",
|
||||
"Слоту-повтору нужно указать, что повторять."
|
||||
);
|
||||
}
|
||||
|
||||
+328
-328
@@ -1,328 +1,328 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||
|
||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||
{
|
||||
private const int MinutesInDay = 24 * 60;
|
||||
|
||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||
ValidateTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||
var groupIds = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Select(s => s.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||
|
||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||
var dayStart = channel.DayStartTime;
|
||||
|
||||
var issues = new List<TemplateIssueDto>();
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||
|
||||
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||
}
|
||||
|
||||
issues.AddRange(FindGaps(layers, dayStart));
|
||||
|
||||
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||
}
|
||||
|
||||
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||
|
||||
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||
GridLayer layer,
|
||||
Slot slot,
|
||||
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||
PlanningRules? rules
|
||||
)
|
||||
{
|
||||
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupMissing,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (group.ItemCount == 0)
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupEmpty,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var perWeek = OccurrencesPerWeek(slot);
|
||||
if (group.ItemCount < perWeek)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupTooSmall,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||
+ "повторы пойдут чаще, чем раз в неделю."
|
||||
);
|
||||
|
||||
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||
// и если это больше состава группы, отсекать будет некого.
|
||||
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||
if (
|
||||
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.CooldownUnreachable,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||
+ $"в группе «{group.Name}»."
|
||||
);
|
||||
|
||||
if (
|
||||
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||
&& groupAudience > maxAudience
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.AudienceConflict,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||
{
|
||||
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||
|
||||
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
|
||||
// и «телом» внешнего цикла его читает только компилятор.
|
||||
for (var i = 0; i < slots.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < slots.Count; j++)
|
||||
{
|
||||
var a = slots[i];
|
||||
var b = slots[j];
|
||||
if (!SameDays(a, b))
|
||||
continue;
|
||||
|
||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||
if (
|
||||
aFrom < bFrom + b.TargetDurationMinutes
|
||||
&& bFrom < aFrom + a.TargetDurationMinutes
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.SlotOverlap,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
b.Id,
|
||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||
private static bool SameDays(Slot a, Slot b) =>
|
||||
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||
|
||||
/// <summary>
|
||||
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||
IReadOnlyList<GridLayer> layers,
|
||||
TimeOnly dayStart
|
||||
)
|
||||
{
|
||||
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||
{
|
||||
var intervals = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||
.Select(s =>
|
||||
{
|
||||
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||
return (From: from, To: from + s.TargetDurationMinutes);
|
||||
})
|
||||
.OrderBy(i => i.From)
|
||||
.ToList();
|
||||
|
||||
var cursor = 0;
|
||||
foreach (var interval in intervals)
|
||||
{
|
||||
if (interval.From > cursor)
|
||||
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||
}
|
||||
|
||||
if (cursor < MinutesInDay)
|
||||
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||
{
|
||||
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
|
||||
var interval =
|
||||
to - from >= MinutesInDay
|
||||
? "весь день"
|
||||
: $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";
|
||||
|
||||
return new TemplateIssueDto(
|
||||
TemplateIssueKind.GridGap,
|
||||
TemplateIssueSeverity.Error,
|
||||
null,
|
||||
null,
|
||||
$"Не покрыто: {day} {interval}."
|
||||
);
|
||||
}
|
||||
|
||||
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||
{
|
||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||
}
|
||||
|
||||
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||
{
|
||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
|
||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
||||
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (groupIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var items = await dbContext
|
||||
.GroupItems.AsNoTracking()
|
||||
.Where(i => groupIds.Contains(i.GroupId))
|
||||
.Select(i => new
|
||||
{
|
||||
i.GroupId,
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
var collectionIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
|
||||
var partsByCollection = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
|
||||
// выражений заставляет EF пересобирать её на каждый вызов.
|
||||
var neededShowIds = showIds
|
||||
.Concat(partsByCollection.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var audiences = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => neededShowIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Audience })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||
|
||||
var result = new Dictionary<Guid, ShowAudience>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var candidates =
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? [item.ElementId]
|
||||
: partsByCollection
|
||||
.Where(p => p.CollectionId == item.ElementId)
|
||||
.Select(p => p.ShowId)
|
||||
.ToList();
|
||||
|
||||
foreach (var showId in candidates)
|
||||
{
|
||||
if (audiences.GetValueOrDefault(showId) is not { } audience)
|
||||
continue;
|
||||
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||
result[item.GroupId] = audience;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||
|
||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||
{
|
||||
private const int MinutesInDay = 24 * 60;
|
||||
|
||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||
ValidateTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||
var groupIds = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Select(s => s.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||
|
||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||
var dayStart = channel.DayStartTime;
|
||||
|
||||
var issues = new List<TemplateIssueDto>();
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||
|
||||
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||
}
|
||||
|
||||
issues.AddRange(FindGaps(layers, dayStart));
|
||||
|
||||
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||
}
|
||||
|
||||
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||
|
||||
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||
GridLayer layer,
|
||||
Slot slot,
|
||||
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||
PlanningRules? rules
|
||||
)
|
||||
{
|
||||
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupMissing,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (group.ItemCount == 0)
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupEmpty,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var perWeek = OccurrencesPerWeek(slot);
|
||||
if (group.ItemCount < perWeek)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupTooSmall,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||
+ "повторы пойдут чаще, чем раз в неделю."
|
||||
);
|
||||
|
||||
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||
// и если это больше состава группы, отсекать будет некого.
|
||||
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||
if (
|
||||
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.CooldownUnreachable,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||
+ $"в группе «{group.Name}»."
|
||||
);
|
||||
|
||||
if (
|
||||
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||
&& groupAudience > maxAudience
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.AudienceConflict,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||
{
|
||||
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||
|
||||
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
|
||||
// и «телом» внешнего цикла его читает только компилятор.
|
||||
for (var i = 0; i < slots.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < slots.Count; j++)
|
||||
{
|
||||
var a = slots[i];
|
||||
var b = slots[j];
|
||||
if (!SameDays(a, b))
|
||||
continue;
|
||||
|
||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||
if (
|
||||
aFrom < bFrom + b.TargetDurationMinutes
|
||||
&& bFrom < aFrom + a.TargetDurationMinutes
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.SlotOverlap,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
b.Id,
|
||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||
private static bool SameDays(Slot a, Slot b) =>
|
||||
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||
|
||||
/// <summary>
|
||||
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||
IReadOnlyList<GridLayer> layers,
|
||||
TimeOnly dayStart
|
||||
)
|
||||
{
|
||||
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||
{
|
||||
var intervals = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||
.Select(s =>
|
||||
{
|
||||
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||
return (From: from, To: from + s.TargetDurationMinutes);
|
||||
})
|
||||
.OrderBy(i => i.From)
|
||||
.ToList();
|
||||
|
||||
var cursor = 0;
|
||||
foreach (var interval in intervals)
|
||||
{
|
||||
if (interval.From > cursor)
|
||||
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||
}
|
||||
|
||||
if (cursor < MinutesInDay)
|
||||
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||
{
|
||||
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
|
||||
var interval =
|
||||
to - from >= MinutesInDay
|
||||
? "весь день"
|
||||
: $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";
|
||||
|
||||
return new TemplateIssueDto(
|
||||
TemplateIssueKind.GridGap,
|
||||
TemplateIssueSeverity.Error,
|
||||
null,
|
||||
null,
|
||||
$"Не покрыто: {day} {interval}."
|
||||
);
|
||||
}
|
||||
|
||||
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||
{
|
||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||
}
|
||||
|
||||
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||
{
|
||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
|
||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
||||
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (groupIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var items = await dbContext
|
||||
.GroupItems.AsNoTracking()
|
||||
.Where(i => groupIds.Contains(i.GroupId))
|
||||
.Select(i => new
|
||||
{
|
||||
i.GroupId,
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
var collectionIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
|
||||
var partsByCollection = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
|
||||
// выражений заставляет EF пересобирать её на каждый вызов.
|
||||
var neededShowIds = showIds
|
||||
.Concat(partsByCollection.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var audiences = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => neededShowIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Audience })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||
|
||||
var result = new Dictionary<Guid, ShowAudience>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var candidates =
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? [item.ElementId]
|
||||
: partsByCollection
|
||||
.Where(p => p.CollectionId == item.ElementId)
|
||||
.Select(p => p.ShowId)
|
||||
.ToList();
|
||||
|
||||
foreach (var showId in candidates)
|
||||
{
|
||||
if (audiences.GetValueOrDefault(showId) is not { } audience)
|
||||
continue;
|
||||
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||
result[item.GroupId] = audience;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user