diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 5c9aa10..f13250f 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -11,6 +11,7 @@ using TeleWave.Application.Programming.Templates.DeleteSlot; using TeleWave.Application.Programming.Templates.Generate; using TeleWave.Application.Programming.Templates.GetTemplate; using TeleWave.Application.Programming.Templates.Layers; +using TeleWave.Application.Programming.Templates.Reset; using TeleWave.Application.Programming.Templates.Restore; using TeleWave.Application.Programming.Templates.Transfer; using TeleWave.Application.Programming.Templates.UpdateSlot; @@ -50,6 +51,10 @@ public static class TemplateEndpoints admin .MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate) .Produces(); + // Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются. + admin + .MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate) + .Produces(); // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров. admin .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate) @@ -165,6 +170,16 @@ public static class TemplateEndpoints return result.ToHttpResult(); } + private static async Task ResetTemplate( + Guid channelId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task PreviewTemplate( Guid channelId, ISender sender, diff --git a/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommand.cs b/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommand.cs new file mode 100644 index 0000000..a4f624b --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommand.cs @@ -0,0 +1,17 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Reset; + +/// +/// Полный сброс сетки канала и собранного по ней эфира: слоты и слои снимаются, будущий хвост +/// ленты удаляется. Настройки шаблона (аварийная группа, стык по умолчанию, правила) остаются — +/// они переживают перекройку сетки, и терять их вместе со слотами админ не просит. +/// +public sealed record ResetTemplateCommand(Guid ChannelId) + : ICommand>; + +/// Сколько слотов снято. +/// Сколько слоёв удалено — фоновый в счёт не идёт, он остаётся всегда. +/// Сколько будущих записей эфира удалено. +public sealed record ResetTemplateResultDto(int Slots, int Layers, int Entries); diff --git a/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommandHandler.cs new file mode 100644 index 0000000..4754687 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Reset/ResetTemplateCommandHandler.cs @@ -0,0 +1,74 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Templates.Reset; + +/// +/// Сброс сетки и собранного по ней эфира — «начать канал заново», не пересоздавая его. +/// +/// Прошлое и идущая сейчас запись не трогаются: инвариант общий с генератором — у зрителя нельзя +/// вырезать программу из-под носа, а история показов нужна остыванию и потолку повторов. +/// Курсоры слотов уходят каскадом вместе со слотами, поэтому сериалы после сброса начинаются +/// с начала — это и просят словом «полностью». +/// +/// Снимок последнего применения остаётся: он единственная точка отката, и после сброса «Откатить» +/// вернёт сетку. Ленту это не вернёт — её пересоберёт применение. +/// +public sealed class ResetTemplateCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + ResetTemplateCommand command, + CancellationToken cancellationToken + ) + { + var template = await dbContext + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + var now = DateTimeOffset.UtcNow; + + // Удаление ленты идёт мимо change-tracker (ExecuteDelete), снятие слотов — через него: + // связывает их в одну операцию только явная транзакция. Лок канала — тот же, что берёт + // генератор: фоновый тик не должен дописывать хвост в момент сброса. + await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); + await dbContext.AcquireChannelLockAsync(command.ChannelId, cancellationToken); + + var entries = await dbContext + .ScheduleEntries.Where(e => e.ChannelId == command.ChannelId && e.StartsAtUtc >= now) + .ExecuteDeleteAsync(cancellationToken); + + var slots = 0; + var layers = 0; + + foreach (var layer in template.Layers.ToList()) + { + slots += layer.Slots.Count; + + // Нефоновый слой уходит целиком — слоты за ним каскадом. Фоновый удалить нельзя, + // поэтому его чистим послотно. + if (template.RemoveLayer(layer.Id)) + { + layers++; + continue; + } + + foreach (var slot in layer.Slots.ToList()) + layer.RemoveSlot(slot.Id); + } + + template.MarkChanged(); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return Result.Success(new ResetTemplateResultDto(slots, layers, entries)); + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index a45d774..4dabc80 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -3,6 +3,7 @@ using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.CopyTemplate; using TeleWave.Application.Programming.Templates.CreateTemplate; +using TeleWave.Application.Programming.Templates.Reset; using TeleWave.Application.Programming.Templates.Validate; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Programming; @@ -136,6 +137,84 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground)); } + /// + /// Сброс: сетка снимается целиком, будущий эфир уходит, прошлое и идущая запись остаются. + /// Проверяется на настоящей БД — команда держит транзакцию с advisory-локом канала и сносит + /// ленту через ExecuteDelete, а этого InMemory не умеет. + /// + [SkippableFact] + public async Task Reset_ClearsGridAndFutureTape_KeepingPastAndCurrentEntry() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seedDb = fixture.CreateContext(); + var (channelId, _, _) = await SeedPairAsync(seedDb); + + var now = DateTimeOffset.UtcNow; + var slotId = seedDb + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .Single(t => t.ChannelId == channelId) + .Layers.SelectMany(l => l.Slots) + .Single() + .Id; + + // Курсор слота: он обязан уйти вместе со слотом, иначе сериал продолжится с той же серии. + var state = SlotState.Create(slotId); + state.MoveTo(GroupElementKind.Show, Guid.NewGuid(), 3); + seedDb.SlotStates.Add(state); + + Entry(seedDb, channelId, now.AddHours(-2), now.AddHours(-1)); + Entry(seedDb, channelId, now.AddMinutes(-10), now.AddMinutes(20)); + Entry(seedDb, channelId, now.AddHours(1), now.AddHours(2)); + await seedDb.SaveChangesAsync(); + + await using var db = fixture.CreateContext(); + var result = await new ResetTemplateCommandHandler(db).Handle( + new ResetTemplateCommand(channelId), + default + ); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value.Slots); + Assert.Equal(1, result.Value.Layers); + Assert.Equal(1, result.Value.Entries); + + await using var verify = fixture.CreateContext(); + var template = verify + .ScheduleTemplates.Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .Single(t => t.ChannelId == channelId); + + // Фоновый слой остаётся всегда — удалить его нельзя, и без него сетку нечем закрывать. + var layer = Assert.Single(template.Layers); + Assert.True(layer.IsBackground); + Assert.Empty(layer.Slots); + Assert.True(template.HasPendingChanges); + Assert.False(verify.SlotStates.Any(s => s.SlotId == slotId)); + + var entries = verify.ScheduleEntries.Where(e => e.ChannelId == channelId).ToList(); + Assert.Equal(2, entries.Count); + Assert.All(entries, e => Assert.True(e.StartsAtUtc < now)); + } + + private static void Entry( + AppDbContext db, + Guid channelId, + DateTimeOffset from, + DateTimeOffset to + ) => + db.ScheduleEntries.Add( + ScheduleEntry.FromSlot( + channelId, + Guid.NewGuid(), + ScheduleEntryKind.Program, + from, + to, + new ScheduleEntryOrigin(null, null, null, null, null) + ) + ); + /// Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка. private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync( AppDbContext db diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 77648ff..cf13707 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -1014,6 +1014,23 @@ seed = hash(channelId, date, slotId, occurrenceInDay) Эфир при откате не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен — `Revision` приравнивается к `AppliedRevision`, плашка гаснет. +### 6.6.2. Очистка сетки + +Кнопка «Очистить сетку» на вкладке сетки — начать канал заново, не пересоздавая его: снимаются все +слоты и все слои кроме фонового (его удалить нельзя), удаляется будущий хвост ленты. Курсоры слотов +уходят каскадом вместе со слотами, поэтому сериалы после очистки начинаются с начала. + +Прошлое и идущая сейчас запись остаются — инвариант общий с генератором: у зрителя нельзя вырезать +программу из-под носа, а история показов нужна остыванию и потолку повторов. Настройки шаблона +(аварийная группа, стык по умолчанию, правила) переживают очистку: они не принадлежат конкретной +раскладке слотов. Снимок последнего применения тоже остаётся — после очистки «Сбросить изменения» +вернёт сетку (но не ленту, её пересоберёт применение). + +Команда держит транзакцию с тем же advisory-локом канала, что и генератор: фоновый тик не должен +дописывать хвост в момент очистки. **Пустая сетка эфир не останавливает** — включённый канал +достроит горизонт аварийной группой (см. 3.4), и в интерфейсе об этом предупреждают прямо после +очистки. + ### 6.9. Автосборка сетки по профилю Собрать неделю руками — это несколько десятков слотов, и до первого эфира новый канал не доживает. diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 2f0bd02..27aba3e 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -16,6 +16,7 @@ import type { GridProfileKind, LayerApplicability, PlanningRules, + ResetTemplateResultDto, RestoreTemplateResultDto, ScheduleDiffDto, ScheduleEntryDto, @@ -89,6 +90,16 @@ export function restoreChannelTemplate(channelId: string) { }) } +/** + * Полный сброс: снимает сетку и удаляет будущий эфир, собранный по ней. Прошлое и идущая сейчас + * запись остаются — их нельзя вырезать из-под зрителя. + */ +export function resetChannelTemplate(channelId: string) { + return apiRequest(`/admin/channels/${channelId}/template/reset`, { + method: 'POST', + }) +} + /** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */ export function getTemplateIssues(channelId: string) { return apiRequest(`/admin/channels/${channelId}/template/issues`) diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx index eb6425f..75ecd9b 100644 --- a/frontend/src/features/admin/channels/components/GridTab.tsx +++ b/frontend/src/features/admin/channels/components/GridTab.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { FileJson, Plus, Wand2 } from 'lucide-react' +import { Eraser, FileJson, Plus, Wand2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' @@ -16,6 +16,7 @@ import { createSlot, deleteLayer, listChannels, + resetChannelTemplate, toSlotBody, updateLayer, updateSlot, @@ -142,6 +143,28 @@ export function GridTab({ onError, }) + /** + * Сброс сетки и собранного по ней эфира. Прошлое остаётся, поэтому «полностью» здесь — про + * будущее: то, что уже показано зрителю, не переписывается. + */ + const resetMutation = useMutation({ + mutationFn: () => resetChannelTemplate(channelId), + onSuccess: (result) => { + toast.success( + t('admin.channels.resetDone', { + slots: result.slots, + layers: result.layers, + entries: result.entries, + }), + ) + // Пустая сетка отдаёт эфир аварийной группе целиком: без предупреждения админ решит, что + // сброс не сработал, увидев в предпросмотре те же шоу через минуту после него. + if (template?.fallbackGroupId) toast.message(t('admin.channels.resetHint')) + onChanged() + }, + onError, + }) + const moveSlotMutation = useMutation({ mutationFn: ({ slot, @@ -316,6 +339,20 @@ export function GridTab({ {t('admin.channels.transfer.action')} + {t('admin.channels.showForDate')}