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: 'Потолок доли жанра за сутки, %',