using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Discovery.Application.Services;
///
/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump.
///
public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings)
{
///
/// 400 create: пустое название после Trim.
///
public const string NameRequiredDetail = "Укажите название задачи";
///
/// 400 start: у задачи нет ключевых слов.
///
public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу";
///
/// Все задачи, старые первыми.
///
/// Задачи в порядке создания.
public Task> ListAsync(CancellationToken ct)
{
return store.ListTasksAsync(ct);
}
///
/// Одна задача по id.
///
/// Id задачи (dt_...).
/// Задача или null (404 «Задача не найдена» у эндпоинта).
public Task GetAsync(string taskId, CancellationToken ct)
{
return store.GetTaskAsync(taskId, ct);
}
///
/// Создаёт задачу поиска.
///
/// Поля новой задачи (см. ).
/// Созданная задача (полный DTO).
/// Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан.
public async Task CreateAsync(DiscoveryTaskDraft draft, CancellationToken ct)
{
string name = (draft.Name ?? string.Empty).Trim();
if (name.Length == 0)
{
throw new DiscoveryValidationException(NameRequiredDetail);
}
int planJoins = draft.PlanJoins ?? 1;
await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false);
await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false);
string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru";
// Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание).
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
int threshold = Clamp(
settingsSnapshot.GetInt(SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
min: 1, max: 100);
int sampleSize = Math.Max(1,
settingsSnapshot.GetInt(SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample));
if (draft.Threshold is int requestedThreshold)
{
threshold = Clamp(requestedThreshold, min: 1, max: 100);
}
if (draft.SampleSize is int requestedSample)
{
sampleSize = Math.Max(1, requestedSample);
}
var row = new DiscoveryTaskRow
{
Id = DiscoveryIdPrefixes.NewTaskId(),
Name = name,
Description = draft.Description ?? string.Empty,
Keywords = CleanKeywords(draft.Keywords),
MinSubscribers = Math.Max(0, draft.MinSubscribers ?? 0),
Lang = lang,
Threshold = threshold,
SampleSize = sampleSize,
PlanJoins = planJoins,
AutoJoin = draft.AutoJoin ?? false,
};
await store.CreateTaskAsync(row, ct).ConfigureAwait(false);
DiscoveryTaskDto created = await store.GetTaskAsync(row.Id, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("create_task: строка не прочиталась после INSERT");
return created;
}
///
/// Обновляет поля задачи.
///
/// Id задачи (dt_...).
/// Изменяемые поля (null — не меняется).
/// Обновлённая задача либо null (404 «Задача не найдена»).
/// Новый план вне границ / бюджет исчерпан.
public async Task PatchAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch);
if (normalized.PlanJoins is int newPlan)
{
await planGuard.AssertPlanAsync(newPlan, ct).ConfigureAwait(false);
if (newPlan > current.PlanJoins)
{
await planGuard.AssertBudgetAsync(newPlan, excludeTaskId: taskId, ct).ConfigureAwait(false);
}
}
if (!normalized.HasChanges())
{
return current;
}
await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
///
/// Удаляет задачу вместе с кандидатами и логом.
///
/// Id задачи (dt_...).
/// True — задача удалена; false — строки нет (404 у эндпоинта).
public async Task DeleteAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return false;
}
return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
}
///
/// Запускает поиск
///
/// Id задачи (dt_...).
/// Задача в running либо null (404).
/// Ключевых слов нет.
public async Task StartAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Keywords.Count == 0)
{
throw new DiscoveryValidationException(NoKeywordsDetail);
}
bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status);
await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
///
/// Ставит задачу на паузу.
///
/// Id задачи (dt_...).
/// Задача в paused либо null (404).
public async Task PauseAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false);
return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
}
///
/// Увеличивает счётчик прогресса задачи
///
/// Id задачи (dt_...).
/// Счётчик (found/evaluated/joined/rejected).
/// Приращение (по умолчанию 1).
/// True — счётчик увеличен; false — задачи нет.
public Task BumpCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct)
{
return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct);
}
///
/// Продвигает индекс поиска
///
/// Id задачи (dt_...).
/// True — задача обновлена; false — строки нет.
public async Task AdvanceSearchAsync(string taskId, CancellationToken ct)
{
DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (current is null)
{
return false;
}
int nextIndex = current.SearchIdx + 1;
bool searchDone = nextIndex >= current.Keywords.Count;
return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false);
}
private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch)
{
int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null;
int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null;
int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null;
string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru";
string? name = patch.Name is null ? null : patch.Name.Trim();
string? description = patch.Description;
return new DiscoveryTaskPatch
{
Name = name,
Description = description,
Keywords = patch.Keywords is null ? null : CleanKeywords(patch.Keywords),
MinSubscribers = minSubscribers,
Lang = lang,
Threshold = threshold,
SampleSize = sampleSize,
PlanJoins = patch.PlanJoins,
AutoJoin = patch.AutoJoin,
};
}
private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords)
{
return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
.Select(keyword => keyword.Trim())
.ToList() ?? new List();
}
private static int Clamp(
int value,
int min,
int max)
{
return Math.Max(min, Math.Min(max, value));
}
}