287 lines
12 KiB
PHP
287 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Archiving;
|
|
|
|
use App\Models\ArchiveRun;
|
|
use App\Models\ArchiveSetting;
|
|
use App\Models\Contract;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class ArchiveExecutor
|
|
{
|
|
/**
|
|
* Execute a single archive setting. Returns summary counts keyed by table.
|
|
*/
|
|
public function executeSetting(ArchiveSetting $setting, ?array $context = null, ?int $userId = null, ?ArchiveRun $existingRun = null): array
|
|
{
|
|
if (! $setting->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;
|
|
}
|
|
}
|