updates to UI and add archiving option

This commit is contained in:
Simon Pocrnjič
2025-10-05 19:45:49 +02:00
parent fe91c7e4bc
commit bab9d6561f
50 changed files with 3337 additions and 416 deletions
+286
View File
@@ -0,0 +1,286 @@
<?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;
}
}
+128 -11
View File
@@ -215,6 +215,16 @@ public function process(Import $import, ?Authenticatable $user = null): array
$rawAssoc = $this->buildRowAssoc($row, $header);
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
// Determine row-level reactivation intent: precedence row > import > template
$rowReactivate = false;
$rawReactivateVal = $rawAssoc['reactivate'] ?? null; // direct column named 'reactivate'
if (! is_null($rawReactivateVal)) {
$rowReactivate = filter_var($rawReactivateVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
$importReactivate = (bool) ($import->reactivate ?? false);
$templateReactivate = (bool) (optional($import->template)->reactivate ?? false);
$reactivateMode = $rowReactivate || $importReactivate || $templateReactivate;
// Do not auto-derive or fallback values; only use explicitly mapped fields
$rawSha1 = sha1(json_encode($rawAssoc));
@@ -230,6 +240,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
// Contracts
$contractResult = null;
$reactivatedThisRow = false;
if (isset($mapped['contract'])) {
// In payments-import with contract_key_mode=reference, treat contract.reference as a keyref only
if ($paymentsImport && $contractKeyMode === 'reference') {
@@ -248,6 +259,29 @@ public function process(Import $import, ?Authenticatable $user = null): array
$found = $q->first();
if ($found) {
$contractResult = ['action' => 'resolved', 'contract' => $found];
// Reactivation branch for resolved existing contract
if ($reactivateMode && ($found->active == 0 || $found->deleted_at)) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$imported++;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import.',
'context' => ['contract_id' => $found->id],
]);
// Do NOT continue; allow postContractActions + account processing below.
}
}
} else {
$contractResult = null; // let requireContract logic flag invalid later
}
@@ -256,6 +290,31 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
} else {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
// If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow.
if ($reactivateMode && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$found = $contractResult['contract'];
if ($found->active == 0 || $found->deleted_at) {
$reactivationApplied = $this->attemptContractReactivation($found, $user);
if ($reactivationApplied['reactivated']) {
$reactivatedThisRow = true;
$importRow->update([
'status' => 'imported',
'entity_type' => Contract::class,
'entity_id' => $found->id,
]);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'contract_reactivated',
'level' => 'info',
'message' => 'Contract reactivated via import (post-upsert).',
'context' => ['contract_id' => $found->id],
]);
// Do not continue; allow post actions + account handling.
}
}
}
}
if ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta
@@ -315,17 +374,19 @@ public function process(Import $import, ?Authenticatable $user = null): array
]);
// Post-contract actions from template/import meta
try {
$this->postContractActions($import, $contractResult['contract']);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
if (! $reactivateMode || $reactivatedThisRow) { // run post actions also for reactivated contracts
try {
$this->postContractActions($import, $contractResult['contract']);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
}
}
} else {
$invalid++;
@@ -1073,6 +1134,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$existing = Account::query()
->where('contract_id', $contractId)
->where('reference', $reference)
->where('active', 1)
->first();
// Build applyable data based on apply_mode
@@ -2032,4 +2094,59 @@ private function postContractActions(Import $import, Contract $contract): void
]);
}
}
/**
* Attempt to reactivate a single archived contract via the latest enabled reactivate ArchiveSetting.
* Returns array{reactivated: bool}.
*/
protected function attemptContractReactivation(Contract $contract, ?Authenticatable $user = null): array
{
try {
// Skip if already active
if ($contract->active && ! $contract->deleted_at) {
return ['reactivated' => false];
}
$setting = \App\Models\ArchiveSetting::query()
->where('enabled', true)
->where('reactivate', true)
->orderByDesc('id')
->first();
if (! $setting) {
return ['reactivated' => false];
}
$context = [
'contract_id' => $contract->id,
'client_case_id' => $contract->client_case_id,
];
if ($contract->account) {
$context['account_id'] = $contract->account->id;
}
$executor = app(\App\Services\Archiving\ArchiveExecutor::class);
$executor->executeSetting($setting, $context, $user?->getAuthIdentifier());
// Ensure contract flagged active (safety)
$contract->forceFill(['active' => 1, 'deleted_at' => null])->save();
// Activity from archive setting (if action/decision present) handled inside executor path or we can optionally create here
if ($setting->action_id || $setting->decision_id) {
try {
Activity::create([
'due_date' => null,
'amount' => null,
'note' => 'Ponovna aktivacija pogodba '.$contract->reference,
'action_id' => $setting->action_id,
'decision_id' => $setting->decision_id,
'client_case_id' => $contract->client_case_id,
'contract_id' => $contract->id,
'user_id' => $user?->getAuthIdentifier(),
]);
} catch (\Throwable $e) {
// Non-fatal
}
}
return ['reactivated' => true];
} catch (\Throwable $e) {
return ['reactivated' => false];
}
}
}
+29 -2
View File
@@ -71,6 +71,18 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
$assoc = $this->associateRow($columns, $rawValues);
$rowEntities = [];
// Reactivation intent detection (row > import > template)
$rowReactivate = false;
if (array_key_exists('reactivate', $assoc)) {
$rawReactivateVal = $assoc['reactivate'];
if (! is_null($rawReactivateVal) && $rawReactivateVal !== '') {
$rowReactivate = filter_var($rawReactivateVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
}
$importReactivate = (bool) ($import->reactivate ?? false);
$templateReactivate = (bool) (optional($import->template)->reactivate ?? false);
$reactivateMode = $rowReactivate || $importReactivate || $templateReactivate;
// Helper closure to resolve mapping value (with normalization fallbacks)
$val = function (string $tf) use ($assoc, $targetToSource) {
// Direct hit
@@ -95,6 +107,15 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false
// Contract
if (isset($entityRoots['contract'])) {
[$contractEntity, $summaries, $contractCache] = $this->simulateContract($val, $summaries, $contractCache, $val('contract.reference'));
// If reactivation requested and contract exists but is inactive / soft-deleted, mark action as reactivate for UI clarity
if ($reactivateMode && ($contractEntity['action'] === 'update') && (
(isset($contractEntity['active']) && $contractEntity['active'] === 0) ||
(! empty($contractEntity['deleted_at']))
)) {
$contractEntity['original_action'] = $contractEntity['action'];
$contractEntity['action'] = 'reactivate';
$contractEntity['reactivation'] = true;
}
$rowEntities['contract'] = $contractEntity + [
'action_label' => $translatedActions[$contractEntity['action']] ?? $contractEntity['action'],
];
@@ -628,7 +649,7 @@ private function simulateContract(callable $val, array $summaries, array $cache,
if (array_key_exists($reference, $cache)) {
$contract = $cache[$reference];
} else {
$contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id']);
$contract = Contract::query()->where('reference', $reference)->first(['id', 'reference', 'client_case_id', 'active', 'deleted_at']);
$cache[$reference] = $contract; // may be null
}
}
@@ -637,6 +658,8 @@ private function simulateContract(callable $val, array $summaries, array $cache,
'id' => $contract?->id,
'exists' => (bool) $contract,
'client_case_id' => $contract?->client_case_id,
'active' => $contract?->active,
'deleted_at' => $contract?->deleted_at,
'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'),
];
$summaries['contract']['total_rows']++;
@@ -658,7 +681,10 @@ private function simulateAccount(callable $val, array $summaries, array $cache,
if (array_key_exists($reference, $cache)) {
$account = $cache[$reference];
} else {
$account = Account::query()->where('reference', $reference)->first(['id', 'reference', 'balance_amount']);
$account = Account::query()
->where('reference', $reference)
->where('active', 1)
->first(['id', 'reference', 'balance_amount']);
$cache[$reference] = $account;
}
}
@@ -1156,6 +1182,7 @@ private function actionTranslations(): array
'update' => 'posodobi',
'skip' => 'preskoči',
'implicit' => 'posredno',
'reactivate' => 'reaktiviraj',
];
}