diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs
index 08994d7..1ce2e1a 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs
@@ -20,10 +20,14 @@ public static class BumperErrors
"Нельзя удалить последний подблок — нужен хотя бы один."
);
- public static readonly Error TemplateInUse = Error.Conflict(
- "Bumpers.TemplateInUse",
- "Блок заставки используется во врезках стыков."
- );
+ /// Блок общий: держат его врезки конкретных стыков, их и перечисляем.
+ public static Error TemplateInUse(string where) =>
+ Error.Conflict(
+ "Bumpers.TemplateInUse",
+ where.Length > 0
+ ? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда."
+ : "Блок заставки используется во врезках стыков."
+ );
public static readonly Error VariantInUse = Error.Conflict(
"Bumpers.VariantInUse",
diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs
index 0e4feb0..35205cf 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs
@@ -29,7 +29,22 @@ public sealed class DeleteBumperTemplateCommandHandler(
cancellationToken
);
if (used)
- return Result.Failure(BumperErrors.TemplateInUse);
+ {
+ // Имя стыка — по связи врезки с ним; факт использования проверен отдельно и раньше.
+ var junctions = await (
+ from element in dbContext.JunctionElements
+ join junction in dbContext.JunctionTemplates
+ on element.JunctionTemplateId equals junction.Id
+ where element.BumperTemplateId == template.Id
+ select junction.Name
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ return Result.Failure(
+ BumperErrors.TemplateInUse(UsageText.Part("стыки", junctions) ?? string.Empty)
+ );
+ }
dbContext.BumperTemplates.Remove(template);
await storage.DeleteAudioAsync(template.Id, cancellationToken);
diff --git a/backend/src/TeleWave.Application/Common/Models/UsageText.cs b/backend/src/TeleWave.Application/Common/Models/UsageText.cs
new file mode 100644
index 0000000..ee45d86
--- /dev/null
+++ b/backend/src/TeleWave.Application/Common/Models/UsageText.cs
@@ -0,0 +1,28 @@
+namespace TeleWave.Application.Common.Models;
+
+///
+/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят»
+/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку
+/// в инструкцию: видно, что именно отвязать.
+///
+public static class UsageText
+{
+ /// Сколько имён показываем: длинный список в тосте всё равно не читается.
+ private const int MaxNames = 5;
+
+ /// Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается.
+ public static string? Part(string what, IReadOnlyCollection names)
+ {
+ if (names.Count == 0)
+ return null;
+
+ var shown = string.Join(", ", names.Take(MaxNames));
+ return names.Count > MaxNames
+ ? $"{what}: {shown} и ещё {names.Count - MaxNames}"
+ : $"{what}: {shown}";
+ }
+
+ /// Собирает разделы в одну строку; пустые отбрасываются.
+ public static string Join(params string?[] parts) =>
+ string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
+}
diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs
index 18b29fb..e3c5c4c 100644
--- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs
@@ -32,8 +32,11 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
c => c.FillerAssetId == asset.Id,
cancellationToken
);
+
if (inSchedule || isFiller)
- return Result.Failure(MediaErrors.InUse);
+ return Result.Failure(
+ MediaErrors.InUse(await UsedByAsync(asset.Id, cancellationToken))
+ );
// Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске
// безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты.
@@ -45,4 +48,27 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
return Result.Success();
}
+
+ /// Чем занят ассет: расписанием каких каналов и где он выбран заполнителем.
+ private async Task UsedByAsync(Guid assetId, CancellationToken cancellationToken)
+ {
+ var inSchedule = await (
+ from entry in dbContext.ScheduleEntries
+ join channel in dbContext.Channels on entry.ChannelId equals channel.Id
+ where entry.MediaAssetId == assetId
+ select channel.Name
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ var asFiller = await dbContext
+ .Channels.Where(c => c.FillerAssetId == assetId)
+ .Select(c => c.Name)
+ .ToListAsync(cancellationToken);
+
+ return UsageText.Join(
+ UsageText.Part("расписание каналов", inSchedule),
+ UsageText.Part("заполнитель каналов", asFiller)
+ );
+ }
}
diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs
index 89e39a9..bbda962 100644
--- a/backend/src/TeleWave.Application/Media/MediaErrors.cs
+++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs
@@ -34,10 +34,14 @@ public static class MediaErrors
"Исходный файл не найден в хранилище."
);
- public static readonly Error InUse = Error.Conflict(
- "Media.InUse",
- "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира."
- );
+ /// Чем именно занят ассет: расписанием какого канала или ролью заполнителя.
+ public static Error InUse(string where) =>
+ Error.Conflict(
+ "Media.InUse",
+ where.Length > 0
+ ? $"Ассет занят ({where}) — удалить его нельзя."
+ : "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира."
+ );
public static readonly Error DuplicateFileName = Error.Conflict(
"Media.DuplicateFileName",
diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs
index 0810d0c..c118e90 100644
--- a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs
@@ -34,10 +34,55 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
t => t.FallbackGroupId == group.Id,
cancellationToken
);
+
+ // Сам факт занятости и адрес — разные вопросы: адрес собирается по цепочке слой → шаблон →
+ // канал, и если она неполна, имени не будет, а запрещать удаление всё равно надо.
if (used)
- return Result.Failure(GroupErrors.InUse);
+ return Result.Failure(
+ GroupErrors.InUse(await UsedByAsync(group.Id, cancellationToken))
+ );
dbContext.Groups.Remove(group);
return Result.Success();
}
+
+ /// Кто держит группу: слоты каналов, стыки и аварийная группа шаблона.
+ private async Task UsedByAsync(Guid groupId, CancellationToken cancellationToken)
+ {
+ var slots = await (
+ from slot in dbContext.Slots
+ join layer in dbContext.GridLayers on slot.LayerId equals layer.Id
+ join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id
+ join channel in dbContext.Channels on template.ChannelId equals channel.Id
+ where slot.GroupId == groupId
+ select channel.Name
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ var junctions = await (
+ from element in dbContext.JunctionElements
+ join junction in dbContext.JunctionTemplates
+ on element.JunctionTemplateId equals junction.Id
+ where element.GroupId == groupId
+ select junction.Name
+ )
+ .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)
+ );
+ }
}
diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs
index f248158..384b7f4 100644
--- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs
+++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs
@@ -16,10 +16,19 @@ public static class GroupErrors
"Шоу или коллекция не найдены."
);
- public static readonly Error InUse = Error.Conflict(
- "Groups.InUse",
- "Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона."
- );
+ ///
+ /// Где именно занята группа: без адреса «используется в сетке» отправляет обходить все каналы.
+ /// Пустой — связи неполны (слой без шаблона, шаблон без канала),
+ /// и тогда честнее общий текст, чем пустые скобки.
+ ///
+ public static Error InUse(string where) =>
+ Error.Conflict(
+ "Groups.InUse",
+ where.Length > 0
+ ? $"Группа используется в сетке ({where}) — сначала отвяжите её там."
+ : "Группа используется в сетке: на неё ссылается слот, врезка стыка "
+ + "или запасная группа шаблона."
+ );
public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet",
diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
index d988061..4ce6ce0 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs
@@ -30,10 +30,44 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
t => t.DefaultJunctionId == junction.Id,
cancellationToken
);
+
+ // Факт и адрес — разные вопросы: имя канала берётся по цепочке связей, и её неполнота
+ // не должна превращать запрет в разрешение.
if (usedBySlot || usedByDefault)
- return Result.Failure(TemplateErrors.JunctionInUse);
+ return Result.Failure(
+ TemplateErrors.JunctionInUse(await UsedByAsync(junction.Id, cancellationToken))
+ );
dbContext.JunctionTemplates.Remove(junction);
return Result.Success();
}
+
+ /// Кто держит стык: слоты каналов и каналы, где он выбран стыком по умолчанию.
+ private async Task UsedByAsync(Guid junctionId, CancellationToken cancellationToken)
+ {
+ var slots = await (
+ from slot in dbContext.Slots
+ join layer in dbContext.GridLayers on slot.LayerId equals layer.Id
+ join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id
+ join channel in dbContext.Channels on template.ChannelId equals channel.Id
+ where slot.JunctionBetweenId == junctionId || slot.JunctionAfterId == junctionId
+ select channel.Name
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ var byDefault = await (
+ from template in dbContext.ScheduleTemplates
+ join channel in dbContext.Channels on template.ChannelId equals channel.Id
+ where template.DefaultJunctionId == junctionId
+ select channel.Name
+ )
+ .Distinct()
+ .ToListAsync(cancellationToken);
+
+ return UsageText.Join(
+ UsageText.Part("слоты каналов", slots),
+ UsageText.Part("стык по умолчанию у каналов", byDefault)
+ );
+ }
}
diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs
index 16ad3ac..ebf0b13 100644
--- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs
+++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs
@@ -49,10 +49,14 @@ public static class TemplateErrors
"Врезка не найдена."
);
- public static readonly Error JunctionInUse = Error.Conflict(
- "Templates.JunctionInUse",
- "Стык используется слотами — сначала отвяжите его."
- );
+ /// Стык общий, поэтому держать его может слот чужого канала — называем какого.
+ public static Error JunctionInUse(string where) =>
+ Error.Conflict(
+ "Templates.JunctionInUse",
+ where.Length > 0
+ ? $"Стык используется ({where}) — сначала отвяжите его там."
+ : "Стык используется слотами — сначала отвяжите его."
+ );
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs
index 71ac6fa..bbd7566 100644
--- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs
@@ -259,7 +259,7 @@ public class BumperTemplateHandlersTests
new DeleteBumperTemplateCommand(template.Id),
CancellationToken.None
);
- Assert.Equal(BumperErrors.TemplateInUse, used.Error);
+ Assert.Equal(BumperErrors.TemplateInUse(string.Empty).Code, used.Error.Code);
}
await using (var db = fixture.New())
diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs
index d6dc7c8..a70f5a7 100644
--- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs
@@ -155,7 +155,7 @@ public class DeleteGuardsTests
);
Assert.False(result.IsSuccess);
- Assert.Equal(MediaErrors.InUse, result.Error);
+ Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code);
// Файлы не тронуты: до чистки хранилища дело не дошло.
await storage
.DidNotReceiveWithAnyArgs()
@@ -184,7 +184,7 @@ public class DeleteGuardsTests
).Handle(new DeleteMediaAssetCommand(asset.Id), CancellationToken.None);
Assert.False(result.IsSuccess);
- Assert.Equal(MediaErrors.InUse, result.Error);
+ Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code);
}
[Fact]
@@ -222,7 +222,7 @@ public class DeleteGuardsTests
);
Assert.False(result.IsSuccess);
- Assert.Equal(GroupErrors.InUse, result.Error);
+ Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code);
}
/// Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно.
@@ -248,7 +248,38 @@ public class DeleteGuardsTests
);
Assert.False(result.IsSuccess);
- Assert.Equal(GroupErrors.InUse, result.Error);
+ Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code);
+ }
+
+ ///
+ /// Отказ должен называть адрес: «используется в сетке» без имени канала отправляет обходить
+ /// их все руками. Связи полные — значит и канал в тексте есть.
+ ///
+ [Fact]
+ public async Task DeleteGroup_UsedByChannel_NamesIt()
+ {
+ var fixture = new TestDb();
+ var group = Group.Create("G");
+ var channel = Channel.Create("Первый", $"first-{Guid.NewGuid():N}", T0);
+ var template = ScheduleTemplate.Create(channel.Id, "Сетка");
+ template.SetFallbackGroup(group.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Groups.Add(group);
+ seed.Channels.Add(channel);
+ seed.ScheduleTemplates.Add(template);
+ await seed.SaveChangesAsync(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.Contains("Первый", result.Error.Message, StringComparison.Ordinal);
}
[Fact]
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs
index 617a99f..37ed9b5 100644
--- a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs
+++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs
@@ -156,7 +156,7 @@ public class JunctionHandlersTests
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
- Assert.Equal(TemplateErrors.JunctionInUse, used.Error);
+ Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, used.Error.Code);
}
// Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя.
@@ -189,7 +189,7 @@ public class JunctionHandlersTests
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
- Assert.Equal(TemplateErrors.JunctionInUse, asDefault.Error);
+ Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, asDefault.Error.Code);
}
}