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; } }