Implement dynamic group management features with exclusions and mode handling
Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
This commit is contained in:
@@ -4,6 +4,7 @@ using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.AddGroupElements;
|
||||
using TeleWave.Application.Programming.Groups.CreateGroup;
|
||||
using TeleWave.Application.Programming.Groups.DeleteGroup;
|
||||
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
using TeleWave.Application.Programming.Groups.GetGroup;
|
||||
using TeleWave.Application.Programming.Groups.ListGroups;
|
||||
@@ -12,6 +13,7 @@ using TeleWave.Application.Programming.Groups.ReorderGroup;
|
||||
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
@@ -50,6 +52,9 @@ public static class GroupEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Поправки к вычисленному составу динамической группы.
|
||||
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -122,7 +127,7 @@ public static class GroupEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter),
|
||||
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
@@ -194,6 +199,20 @@ public static class GroupEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Exclude(
|
||||
Guid id,
|
||||
ExcludeElementBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reorder(
|
||||
Guid id,
|
||||
ReorderGroupBody body,
|
||||
@@ -209,7 +228,19 @@ public static class GroupEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter);
|
||||
public sealed record UpdateGroupBody(
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupFilter? Filter,
|
||||
GroupMode? Mode = null
|
||||
);
|
||||
|
||||
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
|
||||
public sealed record ExcludeElementBody(
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
bool Excluded
|
||||
);
|
||||
|
||||
public sealed record FindCandidatesBody(GroupFilter? Filter);
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<BumperSpecLoader>();
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
services.AddScoped<DynamicGroupResolver>();
|
||||
services.AddScoped<GroupStatsService>();
|
||||
services.AddScoped<GroupSuggestionBuilder>();
|
||||
services.AddScoped<GroupMembershipCleaner>();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>Элемент вычисленного состава: чем он там оказался и с какими порядком/весом идёт в эфир.</summary>
|
||||
/// <param name="Pinned">Закреплён руками (иначе — найден правилом).</param>
|
||||
public sealed record ResolvedElement(
|
||||
GroupElementKind Kind,
|
||||
Guid Id,
|
||||
int Position,
|
||||
int Weight,
|
||||
bool Pinned
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Считает состав группы. Статическая отдаёт свой список как есть; динамическая — то, что нашло
|
||||
/// правило, плюс закреплённое и минус исключённое.
|
||||
///
|
||||
/// Порядок динамической группы: сначала закреплённое (в своём порядке — его задавали руками),
|
||||
/// затем найденное правилом по алфавиту. Последовательным стратегиям нужен детерминированный
|
||||
/// порядок, а у найденного правилом своего <c>Position</c> нет и быть не может: позиция живёт
|
||||
/// на строке таблицы, а строки для него нет. Вес у найденного — единица: взвешивать имеет смысл
|
||||
/// то, что человек выбрал руками.
|
||||
/// </summary>
|
||||
public sealed class DynamicGroupResolver(
|
||||
GroupFilterMatcher matcher,
|
||||
GroupElementResolver elementResolver
|
||||
)
|
||||
{
|
||||
public async Task<IReadOnlyList<ResolvedElement>> ResolveAsync(
|
||||
Group group,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (group.Mode != GroupMode.Dynamic)
|
||||
return
|
||||
[
|
||||
.. group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(i => new ResolvedElement(
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
i.Position,
|
||||
i.Weight,
|
||||
false
|
||||
)),
|
||||
];
|
||||
|
||||
var pinned = group
|
||||
.Items.Where(i => i.Role == GroupItemRole.Pinned)
|
||||
.OrderBy(i => i.Position)
|
||||
.ToList();
|
||||
var excluded = group
|
||||
.Items.Where(i => i.Role == GroupItemRole.Excluded)
|
||||
.Select(i => (i.ElementKind, i.ElementId))
|
||||
.ToHashSet();
|
||||
|
||||
var result = new List<ResolvedElement>();
|
||||
var position = 0;
|
||||
foreach (var item in pinned)
|
||||
{
|
||||
if (excluded.Contains((item.ElementKind, item.ElementId)))
|
||||
continue;
|
||||
result.Add(
|
||||
new ResolvedElement(item.ElementKind, item.ElementId, position++, item.Weight, true)
|
||||
);
|
||||
}
|
||||
|
||||
// Без правила динамическая группа сводится к своим закреплённым — это не ошибка, а честное
|
||||
// «правило ещё не задано»; пустоту потом покажет постпроверка расписания.
|
||||
if (GroupFilter.FromJson(group.FilterJson) is not { } filter)
|
||||
return result;
|
||||
|
||||
var taken = result.Select(r => (r.Kind, r.Id)).ToHashSet();
|
||||
var matched = await matcher.MatchAsync(filter, cancellationToken);
|
||||
var fresh = matched.Where(e => !excluded.Contains(e) && !taken.Contains(e)).ToList();
|
||||
if (fresh.Count == 0)
|
||||
return result;
|
||||
|
||||
// Разворот нужен и для отсева по длительности единицы, и для алфавитного порядка.
|
||||
var info = await elementResolver.ResolveAsync(fresh, cancellationToken);
|
||||
var ordered = fresh
|
||||
.Where(e =>
|
||||
info.TryGetValue(e, out var element)
|
||||
&& GroupFilterMatcher.MatchesUnitDuration(filter, element)
|
||||
)
|
||||
.OrderBy(e => info[e].Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var (kind, id) in ordered)
|
||||
result.Add(new ResolvedElement(kind, id, position++, GroupItem.DefaultWeight, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||
|
||||
/// <summary>
|
||||
/// Исключить элемент из динамической группы (<paramref name="Excluded"/> = true) либо вернуть его
|
||||
/// правилу. Удалить найденное правилом «насовсем» нельзя — на следующем обращении оно вернулось бы;
|
||||
/// именно поэтому поправка хранится, а не применяется разово.
|
||||
/// </summary>
|
||||
public sealed record ExcludeGroupElementCommand(
|
||||
Guid GroupId,
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
bool Excluded
|
||||
) : ICommand<Result>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||
|
||||
public sealed class ExcludeGroupElementCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<ExcludeGroupElementCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ExcludeGroupElementCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
if (group.Mode != GroupMode.Dynamic)
|
||||
return Result.Failure(GroupErrors.ExclusionsForDynamicOnly);
|
||||
|
||||
if (command.Excluded)
|
||||
group.Exclude(command.ElementKind, command.ElementId);
|
||||
else
|
||||
group.RemoveExclusion(command.ElementKind, command.ElementId);
|
||||
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+3
-82
@@ -2,12 +2,12 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
|
||||
public sealed class FindGroupCandidatesQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupFilterMatcher matcher,
|
||||
GroupElementResolver resolver
|
||||
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
|
||||
{
|
||||
@@ -27,36 +27,7 @@ public sealed class FindGroupCandidatesQueryHandler(
|
||||
if (filter is null)
|
||||
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
|
||||
|
||||
var wantsShows =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Show);
|
||||
var wantsCollections =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
|
||||
|
||||
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
|
||||
|
||||
var elements = new List<(GroupElementKind Kind, Guid Id)>();
|
||||
if (wantsShows)
|
||||
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
|
||||
|
||||
if (wantsCollections)
|
||||
{
|
||||
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
|
||||
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var matching = matchingShowIds.ToHashSet();
|
||||
elements.AddRange(
|
||||
collections
|
||||
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
|
||||
.Select(c => (GroupElementKind.Collection, c.Id))
|
||||
);
|
||||
}
|
||||
|
||||
var elements = await matcher.MatchAsync(filter, cancellationToken);
|
||||
var info = await resolver.ResolveAsync(elements, cancellationToken);
|
||||
var inGroup = group.Items.Select(i => (i.ElementKind, i.ElementId)).ToHashSet();
|
||||
|
||||
@@ -65,7 +36,7 @@ public sealed class FindGroupCandidatesQueryHandler(
|
||||
{
|
||||
if (!info.TryGetValue((kind, id), out var element))
|
||||
continue;
|
||||
if (!MatchesUnitDuration(filter, element))
|
||||
if (!GroupFilterMatcher.MatchesUnitDuration(filter, element))
|
||||
continue;
|
||||
|
||||
result.Add(
|
||||
@@ -87,54 +58,4 @@ public sealed class FindGroupCandidatesQueryHandler(
|
||||
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> MatchShowIdsAsync(
|
||||
GroupFilter filter,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = dbContext.Shows.AsNoTracking();
|
||||
|
||||
if (filter.ShowKinds is { Count: > 0 } kinds)
|
||||
shows = shows.Where(s => kinds.Contains(s.Kind));
|
||||
|
||||
// Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
|
||||
// Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники
|
||||
// проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы
|
||||
// от фильтра «мультики не строже PG» горстку голливудского.
|
||||
if (filter.MaxAudience is { } maxAudience)
|
||||
shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience);
|
||||
|
||||
if (filter.YearMin is { } yearMin)
|
||||
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
|
||||
if (filter.YearMax is { } yearMax)
|
||||
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
|
||||
|
||||
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
|
||||
if (filter.GenreIds is { Count: > 0 } genreIds)
|
||||
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
|
||||
|
||||
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
|
||||
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
|
||||
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
|
||||
/// обрабатывается.
|
||||
/// </summary>
|
||||
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
|
||||
{
|
||||
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
|
||||
return true;
|
||||
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
|
||||
return true;
|
||||
|
||||
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
|
||||
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
|
||||
return false;
|
||||
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-22
@@ -2,11 +2,15 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.GetGroup;
|
||||
|
||||
public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver)
|
||||
: IQueryHandler<GetGroupQuery, Result<GroupDto>>
|
||||
public sealed class GetGroupQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupElementResolver resolver,
|
||||
DynamicGroupResolver dynamicResolver
|
||||
) : IQueryHandler<GetGroupQuery, Result<GroupDto>>
|
||||
{
|
||||
public async Task<Result<GroupDto>> Handle(
|
||||
GetGroupQuery query,
|
||||
@@ -20,47 +24,98 @@ public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementRe
|
||||
if (group is null)
|
||||
return Result.Failure<GroupDto>(GroupErrors.NotFound);
|
||||
|
||||
// Состав считаем на месте: у динамической группы он живёт в правиле, и показать список
|
||||
// позиций вместо него значило бы показать одни поправки.
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
var info = await resolver.ResolveAsync(
|
||||
group.Items.Select(i => (i.ElementKind, i.ElementId)),
|
||||
composition.Select(e => (e.Kind, e.Id)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(i =>
|
||||
// Идентификатор позиции есть только у ручных поправок: найденному правилом строки в таблице
|
||||
// не отвечает, и вес с порядком ему не правят — у него их нет.
|
||||
var itemIds = group
|
||||
.Items.Where(i => i.Role != GroupItemRole.Excluded)
|
||||
.ToDictionary(i => (i.ElementKind, i.ElementId), i => i.Id);
|
||||
|
||||
var items = composition
|
||||
.Select(element =>
|
||||
{
|
||||
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
|
||||
info.TryGetValue((element.Kind, element.Id), out var resolved);
|
||||
return new GroupItemDto(
|
||||
i.Id,
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
itemIds.TryGetValue((element.Kind, element.Id), out var itemId) ? itemId : null,
|
||||
element.Kind,
|
||||
element.Id,
|
||||
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк,
|
||||
// а не роняем весь экран группы.
|
||||
element?.Name
|
||||
resolved?.Name
|
||||
?? "—",
|
||||
i.Weight,
|
||||
i.Position,
|
||||
element?.UnitCount ?? 0,
|
||||
element?.ShowKind,
|
||||
element?.Audience,
|
||||
element?.Year,
|
||||
element?.PosterImageId
|
||||
element.Weight,
|
||||
element.Position,
|
||||
resolved?.UnitCount ?? 0,
|
||||
resolved?.ShowKind,
|
||||
resolved?.Audience,
|
||||
resolved?.Year,
|
||||
resolved?.PosterImageId,
|
||||
element.Pinned
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var excluded = await ExcludedAsync(group, cancellationToken);
|
||||
|
||||
return Result.Success(
|
||||
new GroupDto(
|
||||
group.Id,
|
||||
group.Name,
|
||||
group.Description,
|
||||
group.Mode,
|
||||
GroupFilter.FromJson(group.FilterJson),
|
||||
group.ItemCount,
|
||||
group.UnitCount,
|
||||
group.TotalDuration.TotalSeconds,
|
||||
items.Count,
|
||||
items.Sum(i => i.UnitCount),
|
||||
// Цифры карточки считаем по свежему составу, а не по кэшу: кэш пишут команды,
|
||||
// запросу состояние менять нельзя, а показывать устаревшее рядом с актуальным
|
||||
// списком — вводить в заблуждение.
|
||||
composition
|
||||
.Select(e =>
|
||||
info.TryGetValue((e.Kind, e.Id), out var r)
|
||||
? r.TotalDuration
|
||||
: TimeSpan.Zero
|
||||
)
|
||||
.Aggregate(TimeSpan.Zero, (sum, d) => sum + d)
|
||||
.TotalSeconds,
|
||||
group.StatsComputedAt,
|
||||
items
|
||||
items,
|
||||
excluded
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Исключённое показываем отдельным списком: иначе непонятно, почему шоу нет в составе.</summary>
|
||||
private async Task<IReadOnlyList<GroupExcludedDto>> ExcludedAsync(
|
||||
Domain.Programming.Group group,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var excluded = group
|
||||
.Items.Where(i => i.Role == GroupItemRole.Excluded)
|
||||
.OrderBy(i => i.Position)
|
||||
.ToList();
|
||||
if (excluded.Count == 0)
|
||||
return [];
|
||||
|
||||
var info = await resolver.ResolveAsync(
|
||||
excluded.Select(i => (i.ElementKind, i.ElementId)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return
|
||||
[
|
||||
.. excluded.Select(i =>
|
||||
{
|
||||
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
|
||||
return new GroupExcludedDto(i.ElementKind, i.ElementId, element?.Name ?? "—");
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,22 @@ public sealed record GroupSummaryDto(
|
||||
int UnitCount,
|
||||
double TotalDurationSeconds,
|
||||
bool HasFilter,
|
||||
GroupMode Mode,
|
||||
DateTimeOffset? StatsComputedAt,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Позиция группы с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
|
||||
/// Позиция состава с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
|
||||
/// она даёт: у фильма одна, у сериала и коллекции больше.
|
||||
/// </summary>
|
||||
/// <param name="Id">
|
||||
/// Идентификатор строки в таблице позиций; null — элемент найден правилом динамической группы,
|
||||
/// своей строки у него нет (а значит, нет и веса с порядком, которые можно править).
|
||||
/// </param>
|
||||
/// <param name="Pinned">Закреплён руками в динамической группе — правило его не выбрасывает.</param>
|
||||
public sealed record GroupItemDto(
|
||||
Guid Id,
|
||||
Guid? Id,
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
string ElementName,
|
||||
@@ -31,19 +37,29 @@ public sealed record GroupItemDto(
|
||||
ShowKind? ShowKind,
|
||||
ShowAudience? Audience,
|
||||
int? Year,
|
||||
Guid? PosterImageId
|
||||
Guid? PosterImageId,
|
||||
bool Pinned
|
||||
);
|
||||
|
||||
/// <summary>Элемент, исключённый из динамической группы вручную.</summary>
|
||||
public sealed record GroupExcludedDto(
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
string ElementName
|
||||
);
|
||||
|
||||
public sealed record GroupDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupMode Mode,
|
||||
GroupFilter? Filter,
|
||||
int ItemCount,
|
||||
int UnitCount,
|
||||
double TotalDurationSeconds,
|
||||
DateTimeOffset? StatsComputedAt,
|
||||
IReadOnlyList<GroupItemDto> Items
|
||||
IReadOnlyList<GroupItemDto> Items,
|
||||
IReadOnlyList<GroupExcludedDto> Excluded
|
||||
);
|
||||
|
||||
/// <summary>Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр.</summary>
|
||||
|
||||
@@ -26,6 +26,11 @@ public static class GroupErrors
|
||||
"У группы не задано правило набора."
|
||||
);
|
||||
|
||||
public static readonly Error ExclusionsForDynamicOnly = Error.Validation(
|
||||
"Groups.ExclusionsForDynamicOnly",
|
||||
"Исключения есть только у динамической группы — в статической состав правится списком."
|
||||
);
|
||||
|
||||
public static readonly Error SuggestionNotFound = Error.NotFound(
|
||||
"Groups.SuggestionNotFound",
|
||||
"Предложение устарело — библиотека изменилась. Обновите список предложений."
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Применяет правило набора к библиотеке. Одно место на всех: по нему ищет кандидатов подбор в
|
||||
/// карточке группы и по нему же считается состав динамической группы — расхождение между «что
|
||||
/// показал подбор» и «что ушло в эфир» было бы неотлаживаемым.
|
||||
///
|
||||
/// Длительность единицы правило проверяет уже по развёрнутым данным
|
||||
/// (<see cref="GroupElementResolver"/>), поэтому этот шаг вынесен в <see cref="MatchesUnitDuration"/>
|
||||
/// и применяется вызывающей стороной после разворота.
|
||||
/// </summary>
|
||||
public sealed class GroupFilterMatcher(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>Элементы (шоу и коллекции), подходящие под правило, — без проверки длительности.</summary>
|
||||
public async Task<IReadOnlyList<(GroupElementKind Kind, Guid Id)>> MatchAsync(
|
||||
GroupFilter filter,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var wantsShows =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Show);
|
||||
var wantsCollections =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
|
||||
|
||||
var showIds = await MatchShowIdsAsync(filter, cancellationToken);
|
||||
|
||||
var elements = new List<(GroupElementKind, Guid)>();
|
||||
if (wantsShows)
|
||||
elements.AddRange(showIds.Select(id => (GroupElementKind.Show, id)));
|
||||
|
||||
if (wantsCollections)
|
||||
{
|
||||
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
|
||||
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var matching = showIds.ToHashSet();
|
||||
elements.AddRange(
|
||||
collections
|
||||
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
|
||||
.Select(c => (GroupElementKind.Collection, c.Id))
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> MatchShowIdsAsync(
|
||||
GroupFilter filter,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = dbContext.Shows.AsNoTracking();
|
||||
|
||||
if (filter.ShowKinds is { Count: > 0 } kinds)
|
||||
shows = shows.Where(s => kinds.Contains(s.Kind));
|
||||
|
||||
// Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
|
||||
// Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники
|
||||
// проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы
|
||||
// от фильтра «мультики не строже PG» горстку голливудского.
|
||||
if (filter.MaxAudience is { } maxAudience)
|
||||
shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience);
|
||||
|
||||
if (filter.YearMin is { } yearMin)
|
||||
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
|
||||
if (filter.YearMax is { } yearMax)
|
||||
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
|
||||
|
||||
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
|
||||
if (filter.GenreIds is { Count: > 0 } genreIds)
|
||||
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
|
||||
|
||||
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
|
||||
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
|
||||
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
|
||||
/// обрабатывается.
|
||||
/// </summary>
|
||||
public static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
|
||||
{
|
||||
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
|
||||
return true;
|
||||
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
|
||||
return true;
|
||||
|
||||
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
|
||||
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
|
||||
return false;
|
||||
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,26 +8,33 @@ namespace TeleWave.Application.Programming.Groups;
|
||||
/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции ·
|
||||
/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие.
|
||||
/// </summary>
|
||||
public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver)
|
||||
public sealed class GroupStatsService(
|
||||
IAppDbContext dbContext,
|
||||
GroupElementResolver resolver,
|
||||
DynamicGroupResolver dynamicResolver
|
||||
)
|
||||
{
|
||||
public async Task RecomputeAsync(Group group, CancellationToken cancellationToken)
|
||||
{
|
||||
// Считаем по вычисленному составу, а не по списку позиций: у динамической группы список —
|
||||
// это только поправки, и «342 позиции» из него получились бы неправдой.
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
var info = await resolver.ResolveAsync(
|
||||
group.Items.Select(i => (i.ElementKind, i.ElementId)),
|
||||
composition.Select(e => (e.Kind, e.Id)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var unitCount = 0;
|
||||
var totalDuration = TimeSpan.Zero;
|
||||
foreach (var item in group.Items)
|
||||
foreach (var element in composition)
|
||||
{
|
||||
if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element))
|
||||
if (!info.TryGetValue((element.Kind, element.Id), out var resolved))
|
||||
continue;
|
||||
unitCount += element.UnitCount;
|
||||
totalDuration += element.TotalDuration;
|
||||
unitCount += resolved.UnitCount;
|
||||
totalDuration += resolved.TotalDuration;
|
||||
}
|
||||
|
||||
group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
|
||||
group.UpdateStats(composition.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет.</summary>
|
||||
|
||||
+5
-1
@@ -12,7 +12,10 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава.
|
||||
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава и при
|
||||
// открытии карточки. У динамической группы кэш поэтому может отставать от библиотеки —
|
||||
// рядом с цифрами показывается время расчёта (StatsComputedAt), и это честнее, чем гонять
|
||||
// правила всех групп на каждый показ списка.
|
||||
return await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.OrderBy(g => g.Name)
|
||||
@@ -24,6 +27,7 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
|
||||
g.UnitCount,
|
||||
g.TotalDuration.TotalSeconds,
|
||||
g.FilterJson != null,
|
||||
g.Mode,
|
||||
g.StatsComputedAt,
|
||||
g.CreatedAt
|
||||
))
|
||||
|
||||
+7
-3
@@ -1,13 +1,17 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
|
||||
/// <summary>Имя, описание и правило набора. <paramref name="Filter"/> = null снимает правило;
|
||||
/// состав группы при этом не меняется.</summary>
|
||||
/// <summary>
|
||||
/// Имя, описание, правило набора и режим. <paramref name="Filter"/> = null снимает правило;
|
||||
/// состав группы при этом не меняется. <paramref name="Mode"/> = null оставляет режим как есть.
|
||||
/// </summary>
|
||||
public sealed record UpdateGroupCommand(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupFilter? Filter
|
||||
GroupFilter? Filter,
|
||||
GroupMode? Mode = null
|
||||
) : ICommand<Result>;
|
||||
|
||||
+37
-6
@@ -2,26 +2,57 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
|
||||
public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateGroupCommand, Result>
|
||||
public sealed class UpdateGroupCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
DynamicGroupResolver dynamicResolver,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<UpdateGroupCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateGroupCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext.Groups.FirstOrDefaultAsync(
|
||||
g => g.Id == command.GroupId,
|
||||
cancellationToken
|
||||
);
|
||||
var group = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
group.Rename(command.Name, command.Description);
|
||||
group.SetFilter(command.Filter?.ToJson());
|
||||
|
||||
if (command.Mode is { } mode && mode != group.Mode)
|
||||
await SwitchModeAsync(group, mode, cancellationToken);
|
||||
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Переход в статический режим материализует вычисленный состав: правило домен не понимает,
|
||||
/// а группа не должна опустеть от смены режима — на неё уже ссылаются слоты. Обратный переход
|
||||
/// домен делает сам (список становится закреплённым).
|
||||
/// </summary>
|
||||
private async Task SwitchModeAsync(
|
||||
Domain.Programming.Group group,
|
||||
GroupMode mode,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (mode == GroupMode.Dynamic)
|
||||
{
|
||||
group.SetMode(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
group.SetMode(mode);
|
||||
foreach (var element in composition)
|
||||
group.AddElement(element.Kind, element.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
@@ -16,7 +17,7 @@ namespace TeleWave.Application.Programming.Planning;
|
||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||
/// обрабатывается, значит получить дыру в раздаче.
|
||||
/// </summary>
|
||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
public sealed class GroupExpander(IAppDbContext dbContext, DynamicGroupResolver dynamicResolver)
|
||||
{
|
||||
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||
/// null — история не нужна и не загружается.</param>
|
||||
@@ -37,10 +38,17 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
// Состав считается здесь, перед самым разворотом: у динамической группы он живёт не в
|
||||
// списке позиций, а в правиле, и должен быть свежим на момент прогона.
|
||||
var composition = new Dictionary<Guid, IReadOnlyList<ResolvedElement>>();
|
||||
foreach (var group in groups)
|
||||
composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
|
||||
var elements = composition.Values.SelectMany(c => c).ToList();
|
||||
|
||||
var collectionIds = elements
|
||||
.Where(e => e.Kind == GroupElementKind.Collection)
|
||||
.Select(e => e.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -51,10 +59,9 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = groups
|
||||
.SelectMany(g => g.Items)
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
var showIds = elements
|
||||
.Where(e => e.Kind == GroupElementKind.Show)
|
||||
.Select(e => e.Id)
|
||||
.Concat(collectionParts.Select(p => p.ShowId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -96,15 +103,17 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
||||
);
|
||||
|
||||
foreach (var group in groups)
|
||||
result[group.Id] = group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(item =>
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? data.ShowElement(item)
|
||||
: data.CollectionElement(item)
|
||||
)
|
||||
.ToList();
|
||||
foreach (var (groupId, resolved) in composition)
|
||||
result[groupId] =
|
||||
[
|
||||
.. resolved
|
||||
.OrderBy(e => e.Position)
|
||||
.Select(element =>
|
||||
element.Kind == GroupElementKind.Show
|
||||
? data.ShowElement(element)
|
||||
: data.CollectionElement(element)
|
||||
),
|
||||
];
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -125,16 +134,16 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
ILookup<Guid, Guid> CollectionParts
|
||||
)
|
||||
{
|
||||
public PlanningElement ShowElement(GroupItem item) =>
|
||||
public PlanningElement ShowElement(ResolvedElement element) =>
|
||||
new(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
UnitsOf(item.ElementId),
|
||||
LastPlayedOf(item.ElementId),
|
||||
AudienceOf(item.ElementId),
|
||||
RecentPlaysOf(item.ElementId)
|
||||
element.Kind,
|
||||
element.Id,
|
||||
element.Weight,
|
||||
element.Position,
|
||||
UnitsOf(element.Id),
|
||||
LastPlayedOf(element.Id),
|
||||
AudienceOf(element.Id),
|
||||
RecentPlaysOf(element.Id)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -142,14 +151,14 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
||||
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
||||
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
||||
/// </summary>
|
||||
public PlanningElement CollectionElement(GroupItem item)
|
||||
public PlanningElement CollectionElement(ResolvedElement element)
|
||||
{
|
||||
var parts = CollectionParts[item.ElementId].ToList();
|
||||
var parts = CollectionParts[element.Id].ToList();
|
||||
return new PlanningElement(
|
||||
item.ElementKind,
|
||||
item.ElementId,
|
||||
item.Weight,
|
||||
item.Position,
|
||||
element.Kind,
|
||||
element.Id,
|
||||
element.Weight,
|
||||
element.Position,
|
||||
parts.SelectMany(UnitsOf).ToList(),
|
||||
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
|
||||
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
|
||||
|
||||
@@ -4,10 +4,20 @@ namespace TeleWave.Domain.Programming;
|
||||
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
|
||||
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
|
||||
///
|
||||
/// Состав — всегда явный список (<see cref="Items"/>), а <see cref="FilterJson"/> — лишь правило
|
||||
/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками.
|
||||
/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку
|
||||
/// перетасовывало бы всё будущее расписание всех каналов.
|
||||
/// Откуда берётся состав, решает <see cref="Mode"/>:
|
||||
///
|
||||
/// • <see cref="GroupMode.Static"/> — состав это явный список (<see cref="Items"/>), а
|
||||
/// <see cref="FilterJson"/> лишь помогает его наполнить: «применить фильтр» добавляет найденное
|
||||
/// в список, дальше он правится руками.
|
||||
///
|
||||
/// • <see cref="GroupMode.Dynamic"/> — состав вычисляет правило в момент обращения, а список хранит
|
||||
/// только ручные поправки к нему: закреплённое (<see cref="GroupItemRole.Pinned"/>) и исключённое
|
||||
/// (<see cref="GroupItemRole.Excluded"/>). Само правило домен по-прежнему не интерпретирует —
|
||||
/// вычисление живёт в Application.
|
||||
///
|
||||
/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится
|
||||
/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку
|
||||
/// перетасовывало бы будущий эфир всех каналов.
|
||||
/// </summary>
|
||||
public class Group
|
||||
{
|
||||
@@ -20,6 +30,9 @@ public class Group
|
||||
/// <summary>Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.</summary>
|
||||
public string? FilterJson { get; private set; }
|
||||
|
||||
/// <summary>Откуда берётся состав: явный список либо вычисление правилом.</summary>
|
||||
public GroupMode Mode { get; private set; }
|
||||
|
||||
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
|
||||
|
||||
/// <summary>Число позиций в группе.</summary>
|
||||
@@ -59,6 +72,33 @@ public class Group
|
||||
public void SetFilter(string? filterJson) =>
|
||||
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
|
||||
|
||||
/// <summary>
|
||||
/// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав
|
||||
/// переносится, а не теряется: при переходе в динамический список позиций становится
|
||||
/// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое
|
||||
/// становится обычными позициями, а исключения снимаются — исключать больше нечего.
|
||||
///
|
||||
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
|
||||
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
|
||||
/// </summary>
|
||||
public void SetMode(GroupMode mode)
|
||||
{
|
||||
if (Mode == mode)
|
||||
return;
|
||||
|
||||
Mode = mode;
|
||||
if (mode == GroupMode.Dynamic)
|
||||
{
|
||||
foreach (var item in _items)
|
||||
item.SetRole(GroupItemRole.Pinned);
|
||||
return;
|
||||
}
|
||||
|
||||
_items.RemoveAll(i => i.Role == GroupItemRole.Excluded);
|
||||
foreach (var item in _items)
|
||||
item.SetRole(GroupItemRole.Member);
|
||||
}
|
||||
|
||||
/// <summary>Записать пересчитанную статистику.</summary>
|
||||
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
|
||||
{
|
||||
@@ -71,18 +111,55 @@ public class Group
|
||||
public bool Contains(GroupElementKind kind, Guid elementId) =>
|
||||
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
|
||||
|
||||
/// <summary>Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null.</summary>
|
||||
public GroupItem? AddElement(GroupElementKind kind, Guid elementId)
|
||||
/// <summary>
|
||||
/// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по
|
||||
/// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить»,
|
||||
/// иначе правило вымело бы позицию на первом же обращении.
|
||||
/// </summary>
|
||||
public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null)
|
||||
{
|
||||
if (Contains(kind, elementId))
|
||||
return null;
|
||||
|
||||
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
|
||||
var item = GroupItem.Create(Id, kind, elementId, nextPosition);
|
||||
var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole);
|
||||
_items.Add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private GroupItemRole DefaultRole =>
|
||||
Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member;
|
||||
|
||||
/// <summary>
|
||||
/// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать
|
||||
/// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа
|
||||
/// статическая — там состав правится списком, а исключать из списка нечего.
|
||||
/// </summary>
|
||||
public bool Exclude(GroupElementKind kind, Guid elementId)
|
||||
{
|
||||
if (Mode != GroupMode.Dynamic)
|
||||
return false;
|
||||
|
||||
var existing = _items.FirstOrDefault(i =>
|
||||
i.ElementKind == kind && i.ElementId == elementId
|
||||
);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.SetRole(GroupItemRole.Excluded);
|
||||
return true;
|
||||
}
|
||||
|
||||
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
|
||||
_items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Снять исключение — элемент вернётся в состав, если его находит правило.</summary>
|
||||
public bool RemoveExclusion(GroupElementKind kind, Guid elementId) =>
|
||||
_items.RemoveAll(i =>
|
||||
i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId
|
||||
) > 0;
|
||||
|
||||
public bool RemoveItem(Guid itemId)
|
||||
{
|
||||
var item = _items.FirstOrDefault(i => i.Id == itemId);
|
||||
|
||||
@@ -13,6 +13,9 @@ public class GroupItem
|
||||
public GroupElementKind ElementKind { get; private set; }
|
||||
public Guid ElementId { get; private set; }
|
||||
|
||||
/// <summary>Чем эта позиция является для группы — см. <see cref="GroupItemRole"/>.</summary>
|
||||
public GroupItemRole Role { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вес при случайном выборе внутри группы (по умолчанию 1). Не конфликтует с остыванием, если
|
||||
/// применять их в правильном порядке: сначала остывание отсекает недавно показанное, затем
|
||||
@@ -31,7 +34,8 @@ public class GroupItem
|
||||
Guid groupId,
|
||||
GroupElementKind elementKind,
|
||||
Guid elementId,
|
||||
int position
|
||||
int position,
|
||||
GroupItemRole role = GroupItemRole.Member
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
@@ -39,10 +43,13 @@ public class GroupItem
|
||||
GroupId = groupId,
|
||||
ElementKind = elementKind,
|
||||
ElementId = elementId,
|
||||
Role = role,
|
||||
Weight = DefaultWeight,
|
||||
Position = position,
|
||||
};
|
||||
|
||||
internal void SetRole(GroupItemRole role) => Role = role;
|
||||
|
||||
internal void SetPosition(int position) => Position = position;
|
||||
|
||||
internal void SetWeight(int weight) => Weight = Math.Max(0, weight);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>
|
||||
/// Роль позиции в группе. У статической группы все позиции — <see cref="Member"/>: список и есть
|
||||
/// состав. У динамической состав считает правило, а позиции хранят ручные поправки к нему.
|
||||
/// </summary>
|
||||
public enum GroupItemRole
|
||||
{
|
||||
/// <summary>Обычная позиция статической группы.</summary>
|
||||
Member = 0,
|
||||
|
||||
/// <summary>Всегда в составе динамической группы, даже если правило её не находит.</summary>
|
||||
Pinned = 1,
|
||||
|
||||
/// <summary>Никогда не в составе динамической группы, даже если правило её находит.</summary>
|
||||
Excluded = 2,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeleWave.Domain.Programming;
|
||||
|
||||
/// <summary>Откуда группа берёт состав.</summary>
|
||||
public enum GroupMode
|
||||
{
|
||||
/// <summary>Явный список позиций. Правило набора — лишь помощник наполнения.</summary>
|
||||
Static = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Состав вычисляется правилом в момент обращения (генерация расписания, показ карточки).
|
||||
/// Позиции при этом хранят только ручные поправки: закреплённое и исключённое.
|
||||
/// </summary>
|
||||
Dynamic = 1,
|
||||
}
|
||||
+1399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DynamicGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Mode",
|
||||
table: "Groups",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0
|
||||
);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Role",
|
||||
table: "GroupItems",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "Mode", table: "Groups");
|
||||
|
||||
migrationBuilder.DropColumn(name: "Role", table: "GroupItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -803,6 +803,11 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("ItemCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
@@ -839,6 +844,11 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ public class GroupConfiguration : IEntityTypeConfiguration<Group>
|
||||
// Правило набора хранится как jsonb: схему знает Application, а домен передаёт строку как есть.
|
||||
builder.Property(x => x.FilterJson).HasColumnType("jsonb");
|
||||
|
||||
builder.Property(x => x.Mode).HasDefaultValue(GroupMode.Static);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Items)
|
||||
.WithOne()
|
||||
@@ -42,5 +44,9 @@ public class GroupItemConfiguration : IEntityTypeConfiguration<GroupItem>
|
||||
// По этому индексу чистятся позиции при удалении шоу/коллекции: внешнего ключа на
|
||||
// полиморфную ссылку нет, удаление идёт командой.
|
||||
builder.HasIndex(x => new { x.ElementKind, x.ElementId });
|
||||
|
||||
// Роль читается при каждом развороте динамической группы — отдельным индексом не выделяем,
|
||||
// выборка всегда идёт по группе целиком.
|
||||
builder.Property(x => x.Role).HasDefaultValue(GroupItemRole.Member);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user