diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index c118e90..e259e20 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -21,18 +21,12 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) return Result.Failure(GroupErrors.NotFound); // Слот и врезка стыка держат группу внешним ключом с Restrict — без этой проверки удаление - // упало бы исключением БД вместо управляемой ошибки. Запасная группа шаблона внешнего ключа - // не имеет, и там висячая ссылка проявилась бы только в эфире: генератор молча остался бы - // без замены для пустого слота. + // упало бы исключением БД вместо управляемой ошибки. var used = await dbContext.Slots.AnyAsync(s => s.GroupId == group.Id, cancellationToken) || await dbContext.JunctionElements.AnyAsync( e => e.GroupId == group.Id, cancellationToken - ) - || await dbContext.ScheduleTemplates.AnyAsync( - t => t.FallbackGroupId == group.Id, - cancellationToken ); // Сам факт занятости и адрес — разные вопросы: адрес собирается по цепочке слой → шаблон → @@ -42,11 +36,35 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) GroupErrors.InUse(await UsedByAsync(group.Id, cancellationToken)) ); + await ReleaseFallbackAsync(group.Id, cancellationToken); + dbContext.Groups.Remove(group); return Result.Success(); } - /// Кто держит группу: слоты каналов, стыки и аварийная группа шаблона. + /// + /// Снимает группу с шаблонов, где она стоит аварийной. Внешнего ключа у этой ссылки нет, и + /// висячая проявилась бы только в эфире — генератор молча остался бы без замены для пустого + /// слота. Блокировать удаление ею мы намеренно не будем: слот и врезка — это осознанная + /// настройка сетки, а аварийную группу проставляет и автосборка, и снять её было нечем. + /// + /// Ревизия поднимается: конфигурация эфира изменилась, и лента должна пересобраться — + /// как и от любой другой правки шаблона. + /// + private async Task ReleaseFallbackAsync(Guid groupId, CancellationToken cancellationToken) + { + var templates = await dbContext + .ScheduleTemplates.Where(t => t.FallbackGroupId == groupId) + .ToListAsync(cancellationToken); + + foreach (var template in templates) + { + template.SetFallbackGroup(null); + template.MarkChanged(); + } + } + + /// Кто держит группу: слоты каналов и врезки стыков. private async Task UsedByAsync(Guid groupId, CancellationToken cancellationToken) { var slots = await ( @@ -70,19 +88,9 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) .Distinct() .ToListAsync(cancellationToken); - var fallback = await ( - from template in dbContext.ScheduleTemplates - join channel in dbContext.Channels on template.ChannelId equals channel.Id - where template.FallbackGroupId == groupId - select channel.Name - ) - .Distinct() - .ToListAsync(cancellationToken); - return UsageText.Join( UsageText.Part("слоты каналов", slots), - UsageText.Part("врезки стыков", junctions), - UsageText.Part("аварийная группа каналов", fallback) + UsageText.Part("врезки стыков", junctions) ); } } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index 384b7f4..81b40ad 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -26,8 +26,7 @@ public static class GroupErrors "Groups.InUse", where.Length > 0 ? $"Группа используется в сетке ({where}) — сначала отвяжите её там." - : "Группа используется в сетке: на неё ссылается слот, врезка стыка " - + "или запасная группа шаблона." + : "Группа используется в сетке: на неё ссылается слот или врезка стыка." ); public static readonly Error FilterNotSet = Error.Validation( diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index a70f5a7..af24e92 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -225,14 +225,19 @@ public class DeleteGuardsTests Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); } - /// Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно. + /// + /// Запасная группа шаблона удаление не держит: слот и врезка — осознанная настройка сетки, + /// а её проставляет и автосборка, и снять её из интерфейса было нечем — группа оставалась + /// неудаляемой навсегда. Ссылка снимается, ревизия растёт: эфиру нужна пересборка. + /// [Fact] - public async Task DeleteGroup_UsedAsTemplateFallback_Fails() + public async Task DeleteGroup_UsedAsTemplateFallback_ReleasesReference() { var fixture = new TestDb(); var group = Group.Create("G"); var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); template.SetFallbackGroup(group.Id); + template.MarkApplied(); await using (var seed = fixture.New()) { @@ -241,14 +246,22 @@ public class DeleteGuardsTests await seed.SaveChangesAsync(CancellationToken.None); } - await using var db = fixture.New(); - var result = await new DeleteGroupCommandHandler(db).Handle( - new DeleteGroupCommand(group.Id), - CancellationToken.None - ); + await using (var db = fixture.New()) + { + var result = await new DeleteGroupCommandHandler(db).Handle( + new DeleteGroupCommand(group.Id), + CancellationToken.None + ); - Assert.False(result.IsSuccess); - Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var check = fixture.New(); + var stored = await check.ScheduleTemplates.FirstAsync(t => t.Id == template.Id); + Assert.Null(stored.FallbackGroupId); + Assert.True(stored.HasPendingChanges); + Assert.False(await check.Groups.AnyAsync(g => g.Id == group.Id)); } /// @@ -262,7 +275,20 @@ public class DeleteGuardsTests var group = Group.Create("G"); var channel = Channel.Create("Первый", $"first-{Guid.NewGuid():N}", T0); var template = ScheduleTemplate.Create(channel.Id, "Сетка"); - template.SetFallbackGroup(group.Id); + var layer = template.AddLayer("Базовый", 10); + var slot = layer.AddSlot("Блок", new TimeOnly(6, 0), 60); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); await using (var seed = fixture.New()) { diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index f763108..77648ff 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -351,6 +351,13 @@ Slot случай, когда пуст и фон. Только это и считается пост-проверкой «доля фона»: зацикленный филлер в эфире выглядит поломкой, а фоновый слот — ночным блоком. +Аварийная группа выбирается в карточке правил канала и снимается там же. Настройка обязана быть +видимой: её проставляет ещё и автосборка сетки (если своя не выбрана), а сетка без слотов отдаёт +эфир целиком ей — и в предпросмотре это выглядит так, будто пустая сетка что-то построила. +Удаление группы этой ссылкой **не блокируется**: слот и врезка — осознанная настройка сетки, а +аварийная снимается сама, шаблон помечается изменённым и после применения край опускается на +филлер канала. + ### 3.5. Стратегии Хранятся в слоте как JSON с полем `type`. Стратегия отвечает за выбор **элемента** группы; внутри diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx index 5a04ad2..e1a4c71 100644 --- a/frontend/src/features/admin/channels/components/RulesCard.tsx +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -1,7 +1,9 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { Plus, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { listGroups } from '@/features/admin/groups/api' +import { qk } from '@/shared/api/query-keys' import { SHOW_AUDIENCES, type AudienceWindow, @@ -55,6 +57,11 @@ export function RulesCard({ const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0) const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0) const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0) + const [fallbackGroupId, setFallbackGroupId] = useState(() => template.fallbackGroupId) + + // Аварийную группу проставляет и автосборка сетки, поэтому список нужен всегда: без него + // ссылка остаётся невидимой, а снять её нечем — и группа не удаляется. + const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) useEffect(() => { setWindows(toRows(template.rules?.maxAudienceByTime ?? [])) @@ -64,6 +71,7 @@ export function RulesCard({ setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0) setGenreCap(template.rules?.maxGenreSharePercent ?? 0) setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0) + setFallbackGroupId(template.fallbackGroupId) }, [template]) const save = useMutation({ @@ -78,7 +86,7 @@ export function RulesCard({ } return updateTemplate(template.id, { name: template.name, - fallbackGroupId: template.fallbackGroupId, + fallbackGroupId, defaultJunctionId: template.defaultJunctionId, rules, }) @@ -203,6 +211,25 @@ export function RulesCard({

{t('admin.channels.repeatLimitHint')}

+
+
+ + +
+

{t('admin.channels.fallbackGroupHint')}

+
+

{t('admin.channels.postChecks')} diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 9d5d9ae..779669a 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -666,6 +666,10 @@ export const en = { 'This channel grid will be replaced with the chosen channel grid. Continue?', templateCopied: 'Copied: {{layers}} layers, {{slots}} slots', copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand', + fallbackGroup: 'Fallback group', + fallbackGroupNone: 'None — channel filler only', + fallbackGroupHint: + 'What fills a pause when even the background layer is empty. An empty grid plays it for the whole air. Set by grid auto-build unless you pick your own.', postChecks: 'Post-checks', breakLimit: 'Breaks per hour cap, min', genreShare: 'Genre share per day cap, %', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 88d15d9..ebd35a6 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -661,6 +661,10 @@ export const ru = { 'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?', templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}', copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками', + fallbackGroup: 'Аварийная группа', + fallbackGroupNone: 'Нет — только филлер канала', + fallbackGroupHint: + 'Чем закрывается пауза, если пуст и фоновый слой. Её же играет пустая сетка — весь эфир, пока слотов нет. Проставляется автосборкой сетки, если своя не выбрана.', postChecks: 'Пост-проверки', breakLimit: 'Потолок врезок в час, мин', genreShare: 'Потолок доли жанра за сутки, %',