using Hangfire; namespace foodmarket.Api.Background; /// Регистрирует recurring jobs Hangfire при старте приложения. /// Идемпотентно: RecurringJob.AddOrUpdate перетирает существующее /// определение, так что миграции job'ов проходят без ручной очистки таблицы /// hangfire.recurring_jobs. /// /// Cron-выражения берутся из конфига (Hangfire:Cron:*) с дефолтом /// «каждый день в 03:30 UTC» для cleanup-джобов — за 30 минут до бэкапа, /// чтобы бэкап не цеплял временные блокировки PruneStockMovements. public class HangfireJobsConfigurator : IHostedService { private readonly IRecurringJobManager _jobs; private readonly IConfiguration _cfg; public HangfireJobsConfigurator(IRecurringJobManager jobs, IConfiguration cfg) { _jobs = jobs; _cfg = cfg; } public Task StartAsync(CancellationToken ct) { // Cron в Hangfire — стандартный 5-полевой (UTC по умолчанию). var cronStock = _cfg["Hangfire:Cron:PruneStockMovements"] ?? "30 3 * * *"; var cronAudit = _cfg["Hangfire:Cron:PruneAuditLog"] ?? "45 3 * * *"; _jobs.AddOrUpdate( recurringJobId: "prune-stock-movements", methodCall: j => j.PruneStockMovementsAsync(CancellationToken.None), cronExpression: cronStock, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); _jobs.AddOrUpdate( recurringJobId: "prune-audit-log", methodCall: j => j.PruneAuditLogAsync(CancellationToken.None), cronExpression: cronAudit, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 20: per-tenant audit-log + drafts + revoked refresh-tokens. // Default 03:00 UTC group (можно переопределить через конфиг). var cronOrgAudit = _cfg["Hangfire:Cron:PruneOrgAuditLog"] ?? "0 3 * * *"; var cronDrafts = _cfg["Hangfire:Cron:PruneDrafts"] ?? "15 3 * * *"; var cronTokens = _cfg["Hangfire:Cron:PruneRevokedRefreshTokens"] ?? "20 3 * * *"; _jobs.AddOrUpdate( recurringJobId: "prune-org-audit-log", methodCall: j => j.PruneOrgAuditLogAsync(CancellationToken.None), cronExpression: cronOrgAudit, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); _jobs.AddOrUpdate( recurringJobId: "prune-drafts", methodCall: j => j.PruneDraftsAsync(CancellationToken.None), cronExpression: cronDrafts, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); _jobs.AddOrUpdate( recurringJobId: "prune-revoked-refresh-tokens", methodCall: j => j.PruneRevokedRefreshTokensAsync(CancellationToken.None), cronExpression: cronTokens, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 25: чистка quality-{epoch}-* test-org, созданных // ~/quality-watchdog.sh hourly. Default 02:30 UTC, до утреннего prune-блока. var cronQualityOrgs = _cfg["Hangfire:Cron:PruneQualityTestOrgs"] ?? "30 2 * * *"; _jobs.AddOrUpdate( recurringJobId: "prune-quality-test-orgs", methodCall: j => j.PruneQualityTestOrgsAsync(CancellationToken.None), cronExpression: cronQualityOrgs, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 30: широкий cleanup всех autotest-органа/юзеров (e2e, perf, // smoke, a11y, ratetest, mt6*, cat3*, ...). Default 02:45 UTC // (между prune-quality 02:30 и prune-stock 03:30). var cronAutoTest = _cfg["Hangfire:Cron:PruneAutoTestEntities"] ?? "45 2 * * *"; _jobs.AddOrUpdate( recurringJobId: "prune-autotest-entities", methodCall: j => j.PruneAutoTestEntitiesAsync(CancellationToken.None), cronExpression: cronAutoTest, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor. var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час _jobs.AddOrUpdate( recurringJobId: "vacuum-top-tables", methodCall: j => j.VacuumTopTablesAsync(CancellationToken.None), cronExpression: cronVacuum, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); _jobs.AddOrUpdate( recurringJobId: "disk-monitor", methodCall: j => j.CheckAsync(CancellationToken.None), cronExpression: cronDisk, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 22: weekly DB schema documentation generation. var cronSchema = _cfg["Hangfire:Cron:DbSchemaDocs"] ?? "0 5 * * 0"; // Воскресенье 05:00 UTC _jobs.AddOrUpdate( recurringJobId: "db-schema-docs", methodCall: j => j.GenerateAsync(CancellationToken.None), cronExpression: cronSchema, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Sprint 24: weekly API endpoint reference generation. var cronApiRef = _cfg["Hangfire:Cron:ApiReferenceDocs"] ?? "30 5 * * 0"; // Воскресенье 05:30 UTC _jobs.AddOrUpdate( recurringJobId: "api-reference-docs", methodCall: j => j.GenerateAsync(CancellationToken.None), cronExpression: cronApiRef, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Email-уведомления: weekly-summary в понедельник 07:00 UTC, // low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить // если оба совпадают). Cron-ы конфигурируются — на тестовом стенде // имеет смысл сдвинуть/отключить через "off" специальное значение // (не реализовано, пока просто кастомные cron). var cronWeekly = _cfg["Hangfire:Cron:WeeklySummary"] ?? "0 7 * * 1"; var cronLowStock = _cfg["Hangfire:Cron:LowStockAlert"] ?? "0 8 * * *"; _jobs.AddOrUpdate( recurringJobId: "weekly-summary", methodCall: j => j.SendWeeklySummariesAsync(CancellationToken.None), cronExpression: cronWeekly, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); _jobs.AddOrUpdate( recurringJobId: "low-stock-alert", methodCall: j => j.SendLowStockAlertsAsync(CancellationToken.None), cronExpression: cronLowStock, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Telegram-сводка владельцу — 09:00 МСК = 06:00 UTC. По вкусу // переопределяется через Hangfire:Cron:TelegramOwnerDailySummary. var cronTgDaily = _cfg["Hangfire:Cron:TelegramOwnerDailySummary"] ?? "0 6 * * *"; _jobs.AddOrUpdate( recurringJobId: "telegram-owner-daily-summary", methodCall: j => j.RunAsync(CancellationToken.None), cronExpression: cronTgDaily, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Уведомления об отрицательных остатках: агрегированная рассылка + эскалация. // Бежит часто (каждые 5 минут), сам дебаунсит через NotifiedAt и настройку // интервала/тихих часов организации. var cronNegStock = _cfg["Hangfire:Cron:NegativeStockNotify"] ?? "*/5 * * * *"; _jobs.AddOrUpdate( recurringJobId: "negative-stock-notify", methodCall: j => j.RunAsync(CancellationToken.None), cronExpression: cronNegStock, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Смены открытые >24ч — предупреждение кассиру/админам (мягкое правило). var cronShiftWatch = _cfg["Hangfire:Cron:ShiftWatch"] ?? "7 * * * *"; // раз в час _jobs.AddOrUpdate( recurringJobId: "shift-watch-24h", methodCall: j => j.RunAsync(CancellationToken.None), cronExpression: cronShiftWatch, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // «Проверка данных» — ночной полный проход (низкий приоритет, не в часы продаж). var cronDataQuality = _cfg["Hangfire:Cron:DataQuality"] ?? "20 2 * * *"; // 02:20 UTC = 07:20 МСК/Алматы _jobs.AddOrUpdate( recurringJobId: "data-quality-scan", methodCall: j => j.RunAsync(CancellationToken.None), cronExpression: cronDataQuality, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); // Разовый прогон при старте: чтобы после деплоя/пересева Демо (и любой новой // организации) счётчики «Проверки данных» были посчитаны сразу, не дожидаясь // ночного cron (иначе панель/бейдж показывают 0 и lastCheckedAt=null). BackgroundJob.Enqueue(j => j.RunAsync(CancellationToken.None)); return Task.CompletedTask; } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; }