using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
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;
}
/// Sprint 20: удаляет OrgAuditLog старше N дней
/// (по умолчанию 90). Per-tenant журнал мутаций отчётности — растёт
/// быстрее SuperAdmin-аудита, поэтому отдельный prune.
public async Task PruneOrgAuditLogAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:OrgAuditLogDays", 90);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.OrgAuditLogs
.IgnoreQueryFilters()
.Where(a => a.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneOrgAuditLog: удалено {Count} записей старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// Sprint 20: удаляет draft-документы старше N дней (по умолчанию 30).
/// Что чистим: Supply / RetailSale / Demand в статусе Draft, без PostedAt.
/// Каскадно зацепляются связанные lines (FK ON DELETE CASCADE — настроено
/// в configurations).
///
/// Безопасность: draft = пользователь его не провёл, движений не было,
/// удаление не нарушает Stock-инвариант. Per-tenant фильтр НЕ применяется
/// (cross-tenant cleanup), но в WHERE добавляется проверка status.
public async Task<(int Supplies, int Sales, int Demands)> PruneDraftsAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:DraftDays", 30);
var threshold = DateTime.UtcNow.AddDays(-days);
var supplies = await _db.Supplies.IgnoreQueryFilters()
.Where(s => s.Status == SupplyStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var sales = await _db.RetailSales.IgnoreQueryFilters()
.Where(s => s.Status == RetailSaleStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var demands = await _db.Demands.IgnoreQueryFilters()
.Where(d => d.Status == DemandStatus.Draft && d.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneDrafts: supplies={S} sales={Sa} demands={D} (старше {Threshold:O})",
supplies, sales, demands, threshold);
return (supplies, sales, demands);
}
/// Sprint 20: удаляет refresh-токены OpenIddict со статусом
/// revoked старше N дней (по умолчанию 7). Активные / неревокированные
/// не трогаем — у них собственный TTL.
///
/// OpenIddict таблица — OpenIddictTokens. Status = "revoked" |
/// "redeemed" | "valid". Type = "refresh_token" | "access_token".
/// Удаляем только refresh-токены: access-токены протухают быстро (15
/// минут), их Hangfire-cleanup'a в OpenIddict уже есть.
public async Task PruneRevokedRefreshTokensAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:RevokedRefreshTokenDays", 7);
var threshold = DateTime.UtcNow.AddDays(-days);
// OpenIddict ED таблица: тип `OpenIddictEntityFrameworkCoreToken`.
// Используем raw SQL чтобы не тащить лишних using-ов; колонки
// прибиты конвенцией OpenIddict.
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync($@"
DELETE FROM ""OpenIddictTokens""
WHERE ""Type"" = 'refresh_token'
AND ""Status"" IN ('revoked', 'redeemed')
AND ""CreationDate"" < {threshold}", ct);
_log.LogInformation("Hangfire/PruneRevokedRefreshTokens: удалено {Count} токенов старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// Sprint 25: hard-delete тестовых организаций, которые
/// создаёт ~/quality-watchdog.sh по конвенции
/// quality-{epoch}-A/B. Stage-БД иначе разрастается на
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
/// никогда не задеть реальные орги (даже архивные).
///
/// Реализация: динамически находим все таблицы с колонкой
/// OrganizationId через information_schema, и DELETE'им из
/// них строки для match'ащих org-id. Заказ в одной транзакции через
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
/// если делать DELETE в правильном порядке (сначала дочерние,
/// потом родительские); используем общий приём «удаляем по списку
/// org-id из всех таблиц, последний — Organizations».
///
/// Порог давности — Cleanup:QualityTestOrgHours (default 24)
/// чтобы свежие диагностические прогоны не зачищались до того, как
/// инженер посмотрит лог. Возвращает количество удалённых
/// организаций.
public async Task PruneQualityTestOrgsAsync(CancellationToken ct = default)
{
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
var threshold = DateTime.UtcNow.AddHours(-minHours);
var ids = await _db.Organizations
.IgnoreQueryFilters()
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
.Select(o => o.Id)
.ToListAsync(ct);
if (ids.Count == 0)
{
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
return 0;
}
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
//
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
// (depth FK графа гораздо меньше).
var sql = $@"
DO $do$
DECLARE
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
tbl text;
pass int := 0;
remaining int;
BEGIN
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
-- Пропускаем organizations и users — отдельные шаги ниже.
FOR pass IN 1..10 LOOP
remaining := 0;
FOR tbl IN
SELECT c.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE c.table_schema = 'public'
AND c.column_name = 'OrganizationId'
AND t.table_type = 'BASE TABLE'
AND c.table_name NOT IN ('organizations', 'users')
LOOP
BEGIN
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
EXCEPTION WHEN foreign_key_violation THEN
remaining := remaining + 1;
END;
END LOOP;
EXIT WHEN remaining = 0;
END LOOP;
IF remaining > 0 THEN
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
END IF;
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
-- 3. AppUser (public.users) — привязан через OrganizationId, чистим по нему.
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
-- 4. Сама organizations.
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
END $do$;
";
await _db.Database.ExecuteSqlRawAsync(sql, ct);
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
ids.Count, minHours);
return ids.Count;
}
/// Sprint 30: расширение cleanup'а на ВСЕ автотестовые орги/юзеры
/// (не только quality-*): e2e, perf, smoke, a11y, ratetest, mt6*,
/// cat3*, visual-*, factory-smoke-*, auth2*, doc4*, rep5*, rt8*, wiz9*,
/// i18n*, twofa*, iso*, mob14*, mob15*, axdet-*, probe-*, load-*,
/// s19-test-* и всё с суффиксом из ≥10 цифр.
///
/// Запускается тем же механизмом (Hangfire recurring) что
/// . Порог давности
/// Cleanup:AutoTestOrgDays (default 3) — чтобы живые e2e-прогоны
/// успевали создать-использовать org и завершиться.
///
/// Защищены от удаления:
///
/// - Demo Market и Демо-магазин (seeded) + любые орги с именем
/// ровно Demo Market / Демо-магазин;
/// - demo-admin@/demo-cashier@/demo-keeper@ юзеры;
/// - SuperAdmin (без OrganizationId) — не удаляется ни при каких
/// условиях (условие OrganizationId IS NOT NULL в юзер-DELETE);
/// - market@email.com — явное исключение по email.
///
public async Task PruneAutoTestEntitiesAsync(CancellationToken ct = default)
{
var minDays = _cfg.GetValue("Cleanup:AutoTestOrgDays", 3);
var threshold = DateTime.UtcNow.AddDays(-minDays);
// Regex в PG: '~*' = case-insensitive match. Собираем выражение по
// «автотестовым» префиксам/признакам, аналогично QuickLoginController.
// Условие на имя орги. ВАЖНО: даже «просто цифры в конце» ловит почти
// любой e2e-прогон, который суффиксит имя timestamp'ом.
const string autoTestOrgRx =
@"[0-9]{10,}" + // любой длинный цифровой суффикс
@"|^quality-" +
@"|^soak-" +
@"|^(ui-)?a11y-" +
@"|^smokeorg[ab]?-" +
@"|^perfbaseline-" +
@"|^reg-" +
@"|^rt-ratetest-" +
@"|^probe\s" + // "Probe Org"
@"|^cat3[0-9]-" +
@"|^visual-" +
@"|^factory-smoke-" +
@"|^mt6[0-9]-" +
@"|^doc4[0-9]-" +
@"|^auth2[0-9]-" +
@"|^rep5[0-9]-" +
@"|^rt8[0-9]-" +
@"|^wiz9[0-9]-" +
@"|^i18n[0-9]+-" +
@"|^twofa[0-9]+-" +
@"|^iso[a-z]*[0-9]*-" +
@"|^mob1[0-9]-" +
@"|^axdet-" +
@"|^load-sales-" +
@"|^load-" +
@"|^s19-test-";
// SqlQueryRaw — удобный способ вытащить только Id без mapping'а
// к entity. Параметры через FormattableString небезопасно соединять
// (regex из конфига → потенциальный inject), поэтому используем Npgsql-
// параметры напрямую.
var orgIdSql = @"
SELECT ""Id"" FROM public.organizations
WHERE ""Name"" <> 'Demo Market'
AND ""Name"" <> 'Демо-магазин'
AND ""Name"" ~* @rx
AND ""CreatedAt"" < @thr
LIMIT 20000";
var ids = await _db.Database.SqlQueryRaw(orgIdSql,
new Npgsql.NpgsqlParameter("@rx", autoTestOrgRx),
new Npgsql.NpgsqlParameter("@thr", threshold))
.ToListAsync(ct);
if (ids.Count == 0)
{
_log.LogInformation("Hangfire/PruneAutoTestEntities: нет org-кандидатов старше {Days}д", minDays);
// Юзеры без org (orphan-autotest) всё равно могут остаться — обрабатываем ниже.
}
// Тот же трюк с итеративным DELETE по information_schema, что в
// PruneQualityTestOrgsAsync. Юзеры дополнительно вычищаются по
// регулярке email'а (широкий set autotest-паттернов).
var sql = $@"
DO $do$
DECLARE
org_ids uuid[] := ARRAY[{(ids.Count == 0 ? "NULL::uuid" : string.Join(",", ids.Select(i => $"'{i}'::uuid")))}];
tbl text;
pass int := 0;
remaining int;
autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)';
autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$';
threshold_ts timestamp with time zone := '{threshold:O}'::timestamp with time zone;
BEGIN
IF array_length(org_ids, 1) IS NOT NULL AND org_ids[1] IS NOT NULL THEN
-- 1. Итеративная очистка tenant-таблиц.
FOR pass IN 1..10 LOOP
remaining := 0;
FOR tbl IN
SELECT c.table_name
FROM information_schema.columns c
JOIN information_schema.tables t
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
WHERE c.table_schema = 'public'
AND c.column_name = 'OrganizationId'
AND t.table_type = 'BASE TABLE'
AND c.table_name NOT IN ('organizations', 'users')
LOOP
BEGIN
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
EXCEPTION WHEN foreign_key_violation THEN
remaining := remaining + 1;
END;
END LOOP;
EXIT WHEN remaining = 0;
END LOOP;
-- 2. AspNet* / OpenIddict для юзеров этих org.
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids)
);
-- 3. Юзеры этих org.
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
-- 4. Сами organizations.
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
END IF;
-- 5. Orphan-юзеры: autotest-паттерн email + не SuperAdmin (OrganizationId
-- IS NOT NULL) + не demo-* + не market@email.com + создан раньше threshold.
-- Эти юзеры могут остаться когда org уже удалена, но AspNetUserRoles —
-- нет (если был cascade NOT), либо если тест не создал org вообще.
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts
);
DELETE FROM public.users u
WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}')
AND u.""Email"" NOT LIKE 'demo-%@food-market.local'
AND u.""Email"" <> 'admin@food-market.local'
AND u.""Email"" <> 'admin@demo-market.local'
AND u.""Email"" <> 'market@email.com'
AND u.""OrganizationId"" IS NOT NULL
AND u.""CreatedAt"" < threshold_ts;
END $do$;
";
await _db.Database.ExecuteSqlRawAsync(sql, ct);
_log.LogInformation("Hangfire/PruneAutoTestEntities: удалено {Count} org (старше {Days}д) + orphan autotest-юзеры",
ids.Count, minDays);
return ids.Count;
}
}