diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs
new file mode 100644
index 0000000..4433e07
--- /dev/null
+++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs
@@ -0,0 +1,46 @@
+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 });
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
+}
diff --git a/src/food-market.api/Background/HousekeepingJobs.cs b/src/food-market.api/Background/HousekeepingJobs.cs
new file mode 100644
index 0000000..bf0d414
--- /dev/null
+++ b/src/food-market.api/Background/HousekeepingJobs.cs
@@ -0,0 +1,64 @@
+using foodmarket.Domain.Inventory;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Api.Background;
+
+/// Hangfire-джобы для регулярной чистки исторических данных:
+/// StockMovement старше 2 лет (компенсирует разрастание таблицы при
+/// большом обороте продаж), SuperAdminAuditLog старше 90 дней. Период
+/// можно поменять через конфиг Hangfire:Retention:*.
+///
+/// Реализованы как scoped-сервис, чтобы Hangfire разрешил
+/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без
+/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача).
+public class HousekeepingJobs
+{
+ private readonly AppDbContext _db;
+ private readonly IConfiguration _cfg;
+ private readonly ILogger _log;
+
+ public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger log)
+ {
+ _db = db;
+ _cfg = cfg;
+ _log = log;
+ }
+
+ /// Удаляет старше N дней (по умолчанию 730).
+ /// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых
+ /// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там
+ /// невозможно сослаться через unpost (документы уже закрыты).
+ ///
+ /// Возвращает количество удалённых строк (для логов/мониторинга).
+ public async Task PruneStockMovementsAsync(CancellationToken ct = default)
+ {
+ var days = _cfg.GetValue("Hangfire:Retention:StockMovementDays", 730);
+ var threshold = DateTime.UtcNow.AddDays(-days);
+
+ var deleted = await _db.StockMovements
+ .IgnoreQueryFilters()
+ .Where(m => m.OccurredAt < threshold)
+ .ExecuteDeleteAsync(ct);
+ _log.LogInformation("Hangfire/PruneStockMovements: удалено {Count} движений старше {Threshold:O}",
+ deleted, threshold);
+ return deleted;
+ }
+
+ /// Удаляет super_admin_audit_log старше N дней (по умолчанию 90).
+ /// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance
+ /// храним недолго (квартал) и зачищаем.
+ public async Task PruneAuditLogAsync(CancellationToken ct = default)
+ {
+ var days = _cfg.GetValue("Hangfire:Retention:AuditLogDays", 90);
+ var threshold = DateTime.UtcNow.AddDays(-days);
+
+ var deleted = await _db.SuperAdminAuditLogs
+ .IgnoreQueryFilters()
+ .Where(a => a.CreatedAt < threshold)
+ .ExecuteDeleteAsync(ct);
+ _log.LogInformation("Hangfire/PruneAuditLog: удалено {Count} записей старше {Threshold:O}",
+ deleted, threshold);
+ return deleted;
+ }
+}
diff --git a/src/food-market.api/Background/SuperAdminHangfireFilter.cs b/src/food-market.api/Background/SuperAdminHangfireFilter.cs
new file mode 100644
index 0000000..59b0c90
--- /dev/null
+++ b/src/food-market.api/Background/SuperAdminHangfireFilter.cs
@@ -0,0 +1,22 @@
+using Hangfire.Dashboard;
+
+namespace foodmarket.Api.Background;
+
+/// Авторизационный фильтр Hangfire Dashboard. Пускает только
+/// аутентифицированных пользователей с ролью SuperAdmin. ClaimsPrincipal
+/// берётся из HttpContext.User — то есть стандартный OpenIddict-токен
+/// (Bearer) валидируется до этой проверки middleware'ом аутентификации.
+///
+/// Hangfire по умолчанию пускает только loopback — мы хотим строже:
+/// доступ к дашборду = доступ к фоновым джобам всех тенантов, что эквивалентно
+/// SuperAdmin-консоли в Web-админке.
+public class SuperAdminHangfireFilter : IDashboardAuthorizationFilter
+{
+ public bool Authorize(DashboardContext context)
+ {
+ var http = context.GetHttpContext();
+ var user = http.User;
+ if (user?.Identity?.IsAuthenticated != true) return false;
+ return user.IsInRole("SuperAdmin");
+ }
+}
diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs
index 6e94253..9653fc3 100644
--- a/src/food-market.api/Program.cs
+++ b/src/food-market.api/Program.cs
@@ -1,4 +1,6 @@
using System.Security.Claims;
+using Hangfire;
+using Hangfire.PostgreSql;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed;
@@ -178,6 +180,29 @@
// Inventory
builder.Services.AddScoped();
+ // Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres
+ // (тот же ConnectionString:Default). Запускаем фактический сервер только
+ // когда приложение действительно работает (не в тестах): чтобы тестовые
+ // прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация
+ // recurring jobs — после Build() в IHostedService HangfireJobsConfigurator.
+ var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true);
+ builder.Services.AddHangfire(cfg => cfg
+ .SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180)
+ .UseSimpleAssemblyNameTypeSerializer()
+ .UseRecommendedSerializerSettings()
+ .UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection(
+ builder.Configuration.GetConnectionString("Default"))));
+ if (enableHangfireServer)
+ {
+ builder.Services.AddHangfireServer(opts =>
+ {
+ opts.WorkerCount = 2;
+ opts.Queues = new[] { "default" };
+ });
+ builder.Services.AddHostedService();
+ }
+ builder.Services.AddScoped();
+
builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
@@ -218,6 +243,22 @@
app.MapControllers();
+ // Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр
+ // проверяет System.Security.Claims.ClaimsPrincipal (стандартный
+ // OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer —
+ // он уже стартует через AddHangfireServer выше.
+ if (enableHangfireServer)
+ {
+ app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions
+ {
+ Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() },
+ // Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики
+ // не разрушили scheduled — все джобы декларативные, перерегистрация делает их
+ // идемпотентной.
+ IgnoreAntiforgeryToken = false,
+ });
+ }
+
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj
index 4407136..c92d6e0 100644
--- a/src/food-market.api/food-market.api.csproj
+++ b/src/food-market.api/food-market.api.csproj
@@ -21,6 +21,7 @@
+
diff --git a/tests/food-market.IntegrationTests/HangfireDashboardTests.cs b/tests/food-market.IntegrationTests/HangfireDashboardTests.cs
new file mode 100644
index 0000000..4f22d6a
--- /dev/null
+++ b/tests/food-market.IntegrationTests/HangfireDashboardTests.cs
@@ -0,0 +1,26 @@
+using FluentAssertions;
+using foodmarket.IntegrationTests.Support;
+using Xunit;
+
+namespace foodmarket.IntegrationTests;
+
+[Collection(ApiCollection.Name)]
+public class HangfireDashboardTests
+{
+ private readonly ApiFactory _factory;
+ public HangfireDashboardTests(ApiFactory factory) => _factory = factory;
+
+ /// В тестах Hangfire-сервер выключен (Hangfire__Enabled=false), и
+ /// dashboard-маршрут не маппится. Проверяем что /hangfire вообще не отвечает
+ /// 200/302 без auth — то есть гейт работает на уровне отсутствия маршрута.
+ /// На проде (Hangfire включён) фильтр SuperAdminHangfireFilter блокирует
+ /// неавторизованных пользователей. См. SuperAdminHangfireFilter.
+ [Fact]
+ public async Task Dashboard_not_exposed_when_disabled_in_tests()
+ {
+ var api = new ApiActor(_factory.CreateClient());
+ using var resp = await api.Http.GetAsync("/hangfire");
+ // В тестовом режиме сервер не поднят, маршрут не зарегистрирован → 404.
+ resp.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
+ }
+}
diff --git a/tests/food-market.IntegrationTests/Support/ApiFactory.cs b/tests/food-market.IntegrationTests/Support/ApiFactory.cs
index 7b75192..02c95b5 100644
--- a/tests/food-market.IntegrationTests/Support/ApiFactory.cs
+++ b/tests/food-market.IntegrationTests/Support/ApiFactory.cs
@@ -24,6 +24,10 @@ static ApiFactory()
Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false");
// Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями).
Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning");
+ // Hangfire-сервер не нужен в интеграционных тестах: он бы создавал свои
+ // таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент
+ // (AddHangfire) остаётся — recurring jobs не регистрируются.
+ Environment.SetEnvironmentVariable("Hangfire__Enabled", "false");
}
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
diff --git a/tests/food-market.UnitTests/HousekeepingJobsTests.cs b/tests/food-market.UnitTests/HousekeepingJobsTests.cs
new file mode 100644
index 0000000..503857f
--- /dev/null
+++ b/tests/food-market.UnitTests/HousekeepingJobsTests.cs
@@ -0,0 +1,65 @@
+using FluentAssertions;
+using foodmarket.Api.Background;
+using foodmarket.Domain.Inventory;
+using foodmarket.Domain.Platform;
+using foodmarket.Infrastructure.Persistence;
+using foodmarket.UnitTests.Support;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Юнит-тест: HousekeepingJobs.PruneStockMovements удаляет только
+/// движения старше N дней (по умолчанию 730). Stock-инвариант не проверяем —
+/// это межтенантная задача очистки далёкой истории, документы за этот горизонт
+/// в норме закрыты.
+public class HousekeepingJobsTests
+{
+ private static IConfiguration CfgWith(int stockDays = 730, int auditDays = 90) =>
+ new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Hangfire:Retention:StockMovementDays"] = stockDays.ToString(),
+ ["Hangfire:Retention:AuditLogDays"] = auditDays.ToString(),
+ }).Build();
+
+ [Fact]
+ public async Task PruneStockMovements_deletes_only_old_rows()
+ {
+ using var sqlite = new SqliteDb(foreignKeys: false);
+ var orgId = Guid.NewGuid();
+ var tenant = new FakeTenantContext { OrganizationId = orgId };
+
+ using (var db = sqlite.Create(tenant))
+ {
+ db.StockMovements.AddRange(
+ new StockMovement
+ {
+ OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(),
+ Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply",
+ OccurredAt = DateTime.UtcNow.AddYears(-3), // старая
+ },
+ new StockMovement
+ {
+ OrganizationId = orgId, ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(),
+ Quantity = 1m, Type = MovementType.Supply, DocumentType = "supply",
+ OccurredAt = DateTime.UtcNow.AddDays(-30), // свежая
+ });
+ await db.SaveChangesAsync();
+ }
+
+ int deleted;
+ using (var db = sqlite.Create(tenant))
+ {
+ var jobs = new HousekeepingJobs(db, CfgWith(stockDays: 730), NullLogger.Instance);
+ deleted = await jobs.PruneStockMovementsAsync();
+ }
+
+ deleted.Should().Be(1);
+ using (var db = sqlite.Create(tenant))
+ {
+ (await db.StockMovements.IgnoreQueryFilters().CountAsync()).Should().Be(1);
+ }
+ }
+}
diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj
index a2966d7..0152f53 100644
--- a/tests/food-market.UnitTests/food-market.UnitTests.csproj
+++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj
@@ -14,6 +14,7 @@
+