enabled) { return []; } $results = []; $run = $existingRun; $started = now(); $startedHr = microtime(true); // high-resolution start for accurate duration if (! $run) { $run = ArchiveRun::create([ 'archive_setting_id' => $setting->id, 'user_id' => $userId, 'status' => 'running', 'context' => $context, 'started_at' => $started, ]); } $entities = $setting->entities ?? []; if (! is_array($entities)) { return []; } // Flatten entities: UI stores a single focus entity with a 'related' array. $flat = []; foreach ($entities as $entityDef) { if (! is_array($entityDef)) { continue; } if (! empty($entityDef['table'])) { // Mark first / focus explicitly if not set if (! array_key_exists('focus', $entityDef)) { // Consider focus if its table matches a known focus (contracts, client_cases) $entityDef['focus'] = in_array($entityDef['table'], ['contracts', 'client_cases']); } $flat[] = $entityDef; } if (! empty($entityDef['related']) && is_array($entityDef['related'])) { foreach ($entityDef['related'] as $rel) { if (! is_string($rel) || $rel === $entityDef['table']) { continue; } $flat[] = [ 'table' => $rel, 'focus' => false, ]; } } } if (! empty($flat)) { $entities = $flat; } foreach ($entities as $entityDef) { $rawTable = $entityDef['table'] ?? null; if (! $rawTable) { continue; } $chain = explode('.', $rawTable); $table = end($chain); // physical table name assumed last segment $singularToPlural = (array) config('archiving.singular_plural', []); if (isset($singularToPlural[$table]) && Schema::hasTable($singularToPlural[$table])) { $table = $singularToPlural[$table]; } if (! $table || ! Schema::hasTable($table)) { continue; } // Conditions ignored (simplified mode) $soft = (bool) $setting->soft; // soft flag remains relevant for archive $reactivate = (bool) ($setting->reactivate ?? false); $batchSize = (int) ($setting->options['batch_size'] ?? 500); if ($batchSize < 1) { $batchSize = 500; } $affectedTotal = 0; // Process in batches to avoid locking large tables while (true) { $query = DB::table($table)->whereNull('deleted_at'); if (Schema::hasColumn($table, 'active')) { $query->where('active', 1); } // Apply context filters or chain derived filters $filterApplied = $this->applyContextFilters($query, $context, $table, (bool) ($entityDef['focus'] ?? false), $chain, $rawTable); // If context provided but no filter could be applied and this is not the focus entity, skip to avoid whole-table archiving. if ($context && ! $filterApplied && empty($entityDef['focus'])) { break; } $ids = $query->limit($batchSize)->pluck('id'); if ($ids->isEmpty()) { break; } DB::transaction(function () use ($table, $ids, $soft, $reactivate, &$affectedTotal) { if ($reactivate) { // Reactivation path if (Schema::hasColumn($table, 'active')) { DB::table($table) ->whereIn('id', $ids) ->update(['active' => 1, 'updated_at' => now(), 'deleted_at' => null]); $affectedTotal += $ids->count(); } elseif (Schema::hasColumn($table, 'deleted_at')) { DB::table($table) ->whereIn('id', $ids) ->update(['deleted_at' => null, 'updated_at' => now()]); $affectedTotal += $ids->count(); } } else { // Archiving path if ($soft && Schema::hasColumn($table, 'active')) { DB::table($table) ->whereIn('id', $ids) ->update([ 'active' => 0, 'updated_at' => now(), ]); $affectedTotal += $ids->count(); } elseif ($soft && Schema::hasColumn($table, 'deleted_at')) { DB::table($table) ->whereIn('id', $ids) ->update([ 'deleted_at' => now(), 'updated_at' => now(), ]); $affectedTotal += $ids->count(); } else { // Hard delete $affectedTotal += DB::table($table)->whereIn('id', $ids)->delete(); } } }); if ($ids->count() < $batchSize) { break; // last batch } } if ($affectedTotal > 0) { $results[$table] = $affectedTotal; } } try { if (! empty($results)) { Log::info('ArchiveExecutor results', [ 'setting_id' => $setting->id, 'results' => $results, ]); } $finished = now(); $durationMs = (int) max(0, round((microtime(true) - $startedHr) * 1000)); $run->update([ 'status' => 'success', 'counts' => $results, 'finished_at' => $finished, 'duration_ms' => $durationMs, ]); } catch (\Throwable $e) { $durationMs = (int) max(0, round((microtime(true) - $startedHr) * 1000)); try { $run->update([ 'status' => 'error', 'message' => $e->getMessage(), 'finished_at' => now(), 'duration_ms' => $durationMs, ]); } catch (\Throwable $ignored) { // swallow secondary failure to avoid masking original exception } throw $e; } return $results; } /** * Context filters allow scoping execution (e.g., only a given contract id) during manual per-record archive. * Expected keys in $context: contract_id, client_case_id, account_id etc. */ protected function applyContextFilters(Builder $query, ?array $context, string $table, bool $isFocus, array $chain = [], ?string $raw = null): bool { $applied = false; if (! $context) { return $applied; } foreach ($context as $key => $value) { if ($value === null) { continue; } if (Schema::hasColumn($query->from, $key)) { $query->where($key, $value); $applied = true; } } // Support polymorphic documents (documentable_id/type) for contract context if (! $applied && isset($context['contract_id']) && Schema::hasColumn($table, 'documentable_type') && Schema::hasColumn($table, 'documentable_id')) { $query->where('documentable_type', \App\Models\Contract::class)->where('documentable_id', $context['contract_id']); $applied = true; } // Fallback: for the focus entity contracts table using contract_id context if (! $applied && $isFocus && isset($context['contract_id']) && $table === 'contracts') { $query->where('id', $context['contract_id']); $applied = true; } // Chain-based inference (dot notation) limited strictly to declared chain segments. // Examples: // - account.payments => resolve payments by account_id from context (if available via contract->account) // - account.bookings => same pattern // - contracts.documents => already handled by polymorphic logic above if (! $applied && ! empty($chain) && count($chain) > 1) { // We only support a limited mapping derived from context keys, no dynamic relationship traversal. // Supported patterns: // account.payments => requires account_id (contracts focus) // account.bookings => requires account_id (contracts focus) // contracts.account => requires contract_id, maps to accounts.contract_id // contracts.account.payments => requires contract_id then account_id (pre-provided in context) // contracts.account.bookings => same as above // Additional patterns can be appended cautiously. $pattern = implode('.', $chain); switch ($pattern) { case 'account.payments': case 'account.bookings': if (isset($context['account_id']) && Schema::hasColumn($table, 'account_id')) { $query->where('account_id', $context['account_id']); $applied = true; } break; case 'contracts.account': if (isset($context['contract_id']) && $table === 'accounts' && Schema::hasColumn('accounts', 'contract_id')) { $query->where('contract_id', $context['contract_id']); $applied = true; } break; case 'contracts.account.payments': case 'contracts.account.bookings': // Prefer direct account_id context if present; if not, we cannot safely infer without querying if (isset($context['account_id']) && Schema::hasColumn($table, 'account_id')) { $query->where('account_id', $context['account_id']); $applied = true; } elseif (isset($context['contract_id']) && Schema::hasColumn($table, 'account_id') && Schema::hasTable('accounts')) { // Derive account ids for this contract in a subquery (limited, safe scope) $accountIds = DB::table('accounts')->where('contract_id', $context['contract_id'])->pluck('id'); if ($accountIds->isNotEmpty()) { $query->whereIn('account_id', $accountIds); $applied = true; } } break; case 'contracts.documents': // already covered by polymorphic; if not yet applied, mimic if (isset($context['contract_id']) && Schema::hasColumn($table, 'documentable_type') && Schema::hasColumn($table, 'documentable_id')) { $query->where('documentable_type', \App\Models\Contract::class) ->where('documentable_id', $context['contract_id']); $applied = true; } break; } } return $applied; } }