diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs index 9a95259..30b2abd 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs @@ -1,306 +1,312 @@ -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>> -{ - private const int MinutesInDay = 24 * 60; - - public async Task>> 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>(ChannelErrors.NotFound); - - var template = await dbContext - .ScheduleTemplates.AsNoTracking() - .Include(t => t.Layers) - .ThenInclude(l => l.Slots) - .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken); - if (template is null) - return Result.Failure>(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(); - - 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>(issues); - } - - private sealed record GroupStats(Guid Id, string Name, int ItemCount); - - private static IEnumerable CheckSlot( - GridLayer layer, - Slot slot, - IReadOnlyDictionary groups, - IReadOnlyDictionary 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}»." - ); - } - - /// Сколько раз слот выходит в неделю: без дня недели — каждый день. - private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1; - - /// - /// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый - /// по времени, а второй молча пропадёт — это стоит показать до генерации. - /// - private static IEnumerable FindOverlaps(GridLayer layer, TimeOnly dayStart) - { - var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList(); - - 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}»." - ); - } - } - - /// Слот без дня недели идёт каждый день, поэтому пересекается с любым. - private static bool SameDays(Slot a, Slot b) => - a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday; - - /// - /// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели: - /// дыра во вторник ночью не видна, если смотреть на неделю целиком. - /// - private static IEnumerable FindGaps( - IReadOnlyList 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]; - return new TemplateIssueDto( - TemplateIssueKind.GridGap, - TemplateIssueSeverity.Error, - null, - null, - $"Не покрыто: {day} {Clock(from, dayStart)}–{Clock(to, dayStart)}." - ); - } - - 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; - } - - /// - /// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем. - /// Коллекция берётся по строжайшей части — франшиза идёт целиком. - /// - private async Task> LoadStrictestAudienceAsync( - IReadOnlyCollection 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); - - var audiences = await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id)) - .Select(s => new { s.Id, s.Audience }) - .ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken); - - var result = new Dictionary(); - 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.TryGetValue(showId, out var 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>> +{ + private const int MinutesInDay = 24 * 60; + + public async Task>> 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>(ChannelErrors.NotFound); + + var template = await dbContext + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken); + if (template is null) + return Result.Failure>(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(); + + 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>(issues); + } + + private sealed record GroupStats(Guid Id, string Name, int ItemCount); + + private static IEnumerable CheckSlot( + GridLayer layer, + Slot slot, + IReadOnlyDictionary groups, + IReadOnlyDictionary 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}»." + ); + } + + /// Сколько раз слот выходит в неделю: без дня недели — каждый день. + private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1; + + /// + /// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый + /// по времени, а второй молча пропадёт — это стоит показать до генерации. + /// + private static IEnumerable FindOverlaps(GridLayer layer, TimeOnly dayStart) + { + var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList(); + + 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}»." + ); + } + } + + /// Слот без дня недели идёт каждый день, поэтому пересекается с любым. + private static bool SameDays(Slot a, Slot b) => + a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday; + + /// + /// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели: + /// дыра во вторник ночью не видна, если смотреть на неделю целиком. + /// + private static IEnumerable FindGaps( + IReadOnlyList 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; + } + + /// + /// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем. + /// Коллекция берётся по строжайшей части — франшиза идёт целиком. + /// + private async Task> LoadStrictestAudienceAsync( + IReadOnlyCollection 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); + + var audiences = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id)) + .Select(s => new { s.Id, s.Audience }) + .ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken); + + var result = new Dictionary(); + 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.TryGetValue(showId, out var audience)) + continue; + if (!result.TryGetValue(item.GroupId, out var current) || audience > current) + result[item.GroupId] = audience; + } + } + + return result; + } +} diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index d07edf6..99ccaf8 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -58,7 +58,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) { const [copyTargets, setCopyTargets] = useState([]) const [applyOpen, setApplyOpen] = useState(false) const [traceEntryId, setTraceEntryId] = useState(null) - const [copyToChannel, setCopyToChannel] = useState('') + const [copyFromChannel, setCopyFromChannel] = useState('') const [tab, setTab] = useState('settings') const { data: channel, isLoading } = useQuery({ @@ -163,9 +163,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) { }) const copyTemplateMutation = useMutation({ - mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId), + mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId), onSuccess: (result) => { - setCopyToChannel('') + setCopyFromChannel('') toast.success( t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }), ) @@ -345,10 +345,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {