diff --git a/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs b/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs
new file mode 100644
index 000000000..cf012423f
--- /dev/null
+++ b/src/JoinRpg.Dal.Impl/Repositories/KogdaIgraMissingGamesPredicate.cs
@@ -0,0 +1,24 @@
+namespace JoinRpg.Dal.Impl.Repositories;
+
+internal static class KogdaIgraMissingGamesPredicate
+{
+ ///
+ /// Возвращает Expression-предикат для фильтрации проектов, нуждающихся в привязке к КогдаИгра.
+ /// Используется с LinqKit (.AsExpandable() + .Invoke()) для трансляции запроса в SQL.
+ /// См. docs/linq-queries.md
+ ///
+ /// Проект попадает в выборку если:
+ /// 1. Проект активен и не отключена привязка к КогдаИгра
+ /// 2. Нет активных КИ-привязок вообще, ИЛИ
+ /// все привязанные КИ-игры уже завершились (End < now) И проект недавно обновлялся (<60 дней)
+ ///
+ public static Expression> GetPredicate(DateTime now)
+ {
+ var lastUpdateMax = now.AddDays(-60);
+ return (project, lastUpdated) =>
+ project.Active &&
+ !project.Details.DisableKogdaIgraMapping &&
+ !project.KogdaIgraGames.Any(g => g.Active && g.End >= now) &&
+ (!project.KogdaIgraGames.Any(g => g.Active) || lastUpdated > lastUpdateMax);
+ }
+}
diff --git a/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs b/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs
index 85656fb6a..89c8fe3d8 100644
--- a/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs
+++ b/src/JoinRpg.Dal.Impl/Repositories/ProjectRepository.cs
@@ -232,29 +232,17 @@ async Task IProjectRepository.GetProjectsBySpecification(Pro
private async Task GetKogdaIgraMissingGames()
{
- var kogdaIgraStaleExpression60 = ProjectPredicates.KogdaIgraIsStaleFor(TimeSpan.FromDays(60));
- Expression> hasNonStaleKogdaIgra = project => project.KogdaIgraGames.Count(e => kogdaIgraStaleExpression60.Invoke(e)) > 0;
- var filterPredicate = PredicateBuilder.New()
- .And(project => project.Active)
- .And(project => !hasNonStaleKogdaIgra.Invoke(project))
- .And(project => !project.Details.DisableKogdaIgraMapping);
var projection = GetProjectListProjection();
-
- var lastUpdateMax = DateTime.Now.AddDays(-60);
+ var predicate = KogdaIgraMissingGamesPredicate.GetPredicate(DateTime.Now);
var query = from project in AllProjects
join update in GetProjectWithLastUpdateQuery() on project.ProjectId equals update.ProjectId
- where filterPredicate.Invoke(project)
- let item = projection.Invoke(project, update)
- where item.KogdaIgraGames.Count() == 0 || item.LastUpdated > lastUpdateMax
- select item;
-
-
+ where predicate.Invoke(project, update.LastUpdated)
+ select projection.Invoke(project, update);
var result = await query.ToListAsync();
return [.. result.Select(BuildProjectShortInfo)];
-
}
Task IProjectRepository.GetProjectsByIds(UserIdentification? userId, ProjectIdentification[] ids)
diff --git a/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs b/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs
new file mode 100644
index 000000000..25c0622f3
--- /dev/null
+++ b/src/JoinRpg.DataModel.Mocks/KogdaIgraMissingGamesPredicateTest.cs
@@ -0,0 +1,162 @@
+using JoinRpg.Dal.Impl.Repositories;
+using JoinRpg.DataModel.Projects;
+using Shouldly;
+using Xunit;
+
+namespace JoinRpg.DataModel.Mocks;
+
+public class KogdaIgraMissingGamesPredicateTest
+{
+ private static readonly DateTime Now = new DateTime(2025, 6, 1, 12, 0, 0);
+ private static readonly DateTime RecentlyUpdated = Now.AddDays(-10);
+ private static readonly DateTime StaleUpdated = Now.AddDays(-90);
+
+ private static KogdaIgraGame GameEndingIn(int days) => new()
+ {
+ KogdaIgraGameId = 1,
+ Active = true,
+ End = Now.AddDays(days),
+ Name = "Test Game",
+ };
+
+ private static KogdaIgraGame GameWithNullEnd() => new()
+ {
+ KogdaIgraGameId = 2,
+ Active = true,
+ End = null,
+ Name = "Test Game No End",
+ };
+
+ private static KogdaIgraGame InactiveGame(int endDaysOffset) => new()
+ {
+ KogdaIgraGameId = 3,
+ Active = false,
+ End = Now.AddDays(endDaysOffset),
+ Name = "Inactive Game",
+ };
+
+ private static Project CreateProject(
+ IEnumerable? games = null,
+ bool active = true,
+ bool disableKogdaIgraMapping = false) => new()
+ {
+ Active = active,
+ Details = new ProjectDetails { DisableKogdaIgraMapping = disableKogdaIgraMapping },
+ KogdaIgraGames = [.. (games ?? [])],
+ };
+
+ private static bool TestPredicate(Project project, DateTime lastUpdated)
+ => KogdaIgraMissingGamesPredicate.GetPredicate(Now).Compile()(project, lastUpdated);
+
+ // --- Нет привязок ---
+
+ [Fact]
+ public void NoGames_RecentlyUpdated_ShouldNeedBinding()
+ => TestPredicate(CreateProject(), RecentlyUpdated).ShouldBeTrue();
+
+ [Fact]
+ public void NoGames_StaleProject_ShouldNeedBinding()
+ // Даже устаревший проект без привязки — кандидат (нужна привязка)
+ => TestPredicate(CreateProject(), StaleUpdated).ShouldBeTrue();
+
+ // --- Будущие игры (ещё не прошли) ---
+
+ [Fact]
+ public void FutureGame_RecentlyUpdated_ShouldNotNeedBinding()
+ => TestPredicate(CreateProject([GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse();
+
+ [Fact]
+ public void FutureGame_StaleProject_ShouldNotNeedBinding()
+ // Привязан к будущей игре — не нужна новая привязка
+ => TestPredicate(CreateProject([GameEndingIn(30)]), StaleUpdated).ShouldBeFalse();
+
+ [Fact]
+ public void GameEndingToday_ShouldNotNeedBinding()
+ // End == Now: граница, игра «ещё не закончилась»
+ => TestPredicate(CreateProject([GameEndingIn(0)]), RecentlyUpdated).ShouldBeFalse();
+
+ // --- Прошедшие игры ---
+
+ [Fact]
+ public void PastGame_RecentlyUpdated_ShouldNeedBinding()
+ // Сериал: игра прошла, проект активен → надо привязать следующую
+ => TestPredicate(CreateProject([GameEndingIn(-1)]), RecentlyUpdated).ShouldBeTrue();
+
+ [Fact]
+ public void PastGame_StaleProject_ShouldNotNeedBinding()
+ // Игра прошла, проект неактивен — не показывать в выборке
+ => TestPredicate(CreateProject([GameEndingIn(-1)]), StaleUpdated).ShouldBeFalse();
+
+ [Fact]
+ public void PastGame_UpdatedExactlyAtBoundary_ShouldNotNeedBinding()
+ {
+ // Граница: обновлено ровно 60 дней назад — не попадает в выборку
+ var updatedAt60DaysAgo = Now.AddDays(-60);
+ TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt60DaysAgo).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void PastGame_UpdatedJustUnderBoundary_ShouldNeedBinding()
+ {
+ // Граница: обновлено 59 дней назад — попадает в выборку
+ var updatedAt59DaysAgo = Now.AddDays(-59);
+ TestPredicate(CreateProject([GameEndingIn(-1)]), updatedAt59DaysAgo).ShouldBeTrue();
+ }
+
+ // --- Неактивные привязки ---
+
+ [Fact]
+ public void OnlyInactiveGame_RecentlyUpdated_ShouldNeedBinding()
+ // Привязка помечена как неактивная — игнорируется, как нет привязки
+ => TestPredicate(CreateProject([InactiveGame(30)]), RecentlyUpdated).ShouldBeTrue();
+
+ [Fact]
+ public void OnlyInactiveGame_StaleProject_ShouldNeedBinding()
+ => TestPredicate(CreateProject([InactiveGame(30)]), StaleUpdated).ShouldBeTrue();
+
+ // --- Несколько игр (сериал) ---
+
+ [Fact]
+ public void MixedGames_OnePastOneFuture_ShouldNotNeedBinding()
+ // Есть и прошедшая и будущая игра — привязан, не нуждается
+ => TestPredicate(CreateProject([GameEndingIn(-30), GameEndingIn(30)]), RecentlyUpdated).ShouldBeFalse();
+
+ [Fact]
+ public void MultiplePastGames_RecentlyUpdated_ShouldNeedBinding()
+ // Все игры прошли, проект активен → нужна новая привязка
+ => TestPredicate(
+ CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]),
+ RecentlyUpdated).ShouldBeTrue();
+
+ [Fact]
+ public void MultiplePastGames_StaleProject_ShouldNotNeedBinding()
+ => TestPredicate(
+ CreateProject([GameEndingIn(-60), GameEndingIn(-30), GameEndingIn(-1)]),
+ StaleUpdated).ShouldBeFalse();
+
+ // --- Граничный случай: End = null ---
+
+ [Fact]
+ public void GameWithNullEnd_RecentlyUpdated_ShouldNeedBinding()
+ // Игра без даты окончания: End=null → null >= now = false → hasActiveOrFutureGame=false
+ // Но hasAnyGame=true, поэтому проверяется активность проекта
+ => TestPredicate(CreateProject([GameWithNullEnd()]), RecentlyUpdated).ShouldBeTrue();
+
+ [Fact]
+ public void GameWithNullEnd_StaleProject_ShouldNotNeedBinding()
+ => TestPredicate(CreateProject([GameWithNullEnd()]), StaleUpdated).ShouldBeFalse();
+
+ // --- Проект с DisableKogdaIgraMapping ---
+
+ [Fact]
+ public void DisableKogdaIgraMapping_ShouldNotNeedBinding()
+ // Проект с отключённой привязкой к КогдаИгра — не попадает в выборку
+ => TestPredicate(CreateProject(disableKogdaIgraMapping: true), RecentlyUpdated).ShouldBeFalse();
+
+ // --- Неактивный проект ---
+
+ [Fact]
+ public void InactiveProject_ShouldNotNeedBinding()
+ // Неактивный проект — не попадает в выборку
+ => TestPredicate(CreateProject(active: false), RecentlyUpdated).ShouldBeFalse();
+}