From bab9d6561fe16b2147eacf8367d53c937edb7ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sun, 5 Oct 2025 19:45:49 +0200 Subject: [PATCH] updates to UI and add archiving option --- app/Console/Commands/RunArchive.php | 57 ++ .../Controllers/ArchiveSettingController.php | 76 +++ app/Http/Controllers/ClientCaseContoller.php | 249 +++++++- .../Controllers/ImportTemplateController.php | 5 + app/Http/Requests/ArchiveSettingRequest.php | 39 ++ app/Models/ArchiveEntity.php | 27 + app/Models/ArchiveRun.php | 43 ++ app/Models/ArchiveSetting.php | 66 ++ app/Models/Contract.php | 1 + app/Models/Import.php | 3 +- app/Models/ImportTemplate.php | 3 +- app/Policies/ArchiveSettingPolicy.php | 40 ++ app/Services/Archiving/ArchiveExecutor.php | 286 +++++++++ app/Services/ImportProcessor.php | 139 +++- app/Services/ImportSimulationService.php | 31 +- config/archiving.php | 20 + database/factories/ArchiveSettingFactory.php | 43 ++ ...5_000000_create_archive_settings_table.php | 61 ++ ...5_121500_create_archive_entities_table.php | 33 + ...d_active_columns_for_archivable_tables.php | 63 ++ ...d_active_column_to_payments_if_missing.php | 28 + ...10_05_140000_create_archive_runs_table.php | 31 + ...ivate_column_to_archive_settings_table.php | 28 + ...tivate_to_import_templates_and_imports.php | 36 ++ ...e_accounts_reference_unique_add_active.php | 35 ++ database/seeders/ArchiveEntitySeeder.php | 59 ++ database/seeders/ArchiveSettingSeeder.php | 16 + database/seeders/DatabaseSeeder.php | 1 + lang/sl/contracts.php | 14 + resources/js/Components/DatePickerField.vue | 53 +- resources/js/Components/DocumentsTable.vue | 12 +- resources/js/Components/PersonDetailPhone.vue | 372 ++++++++--- resources/js/Layouts/AppLayout.vue | 189 +----- .../Pages/Cases/Partials/ActivityDrawer.vue | 13 +- .../js/Pages/Cases/Partials/ActivityTable.vue | 12 +- .../js/Pages/Cases/Partials/ContractTable.vue | 70 ++- .../Imports/Partials/SimulationModal.vue | 32 +- .../js/Pages/Imports/Templates/Create.vue | 5 + resources/js/Pages/Imports/Templates/Edit.vue | 9 +- resources/js/Pages/Phone/Case/Index.vue | 324 ++++++++-- resources/js/Pages/Settings/Archive/Index.vue | 591 ++++++++++++++++++ resources/js/Pages/Settings/Index.vue | 117 ++-- routes/web.php | 8 + .../ArchiveContractAccountChainTest.php | 56 ++ .../ArchiveContractChainedEntitiesTest.php | 84 +++ tests/Feature/ArchiveContractSegmentTest.php | 66 ++ tests/Feature/ArchiveContractTest.php | 48 ++ tests/Feature/ArchiveRunNowTest.php | 48 ++ tests/Feature/ArchiveSettingCrudTest.php | 61 ++ tests/Feature/ReactivateContractTest.php | 50 ++ 50 files changed, 3337 insertions(+), 416 deletions(-) create mode 100644 app/Console/Commands/RunArchive.php create mode 100644 app/Http/Controllers/ArchiveSettingController.php create mode 100644 app/Http/Requests/ArchiveSettingRequest.php create mode 100644 app/Models/ArchiveEntity.php create mode 100644 app/Models/ArchiveRun.php create mode 100644 app/Models/ArchiveSetting.php create mode 100644 app/Policies/ArchiveSettingPolicy.php create mode 100644 app/Services/Archiving/ArchiveExecutor.php create mode 100644 config/archiving.php create mode 100644 database/factories/ArchiveSettingFactory.php create mode 100644 database/migrations/2025_10_05_000000_create_archive_settings_table.php create mode 100644 database/migrations/2025_10_05_121500_create_archive_entities_table.php create mode 100644 database/migrations/2025_10_05_123000_add_active_columns_for_archivable_tables.php create mode 100644 database/migrations/2025_10_05_124500_add_active_column_to_payments_if_missing.php create mode 100644 database/migrations/2025_10_05_140000_create_archive_runs_table.php create mode 100644 database/migrations/2025_10_05_170000_add_reactivate_column_to_archive_settings_table.php create mode 100644 database/migrations/2025_10_05_180000_add_reactivate_to_import_templates_and_imports.php create mode 100644 database/migrations/2025_10_05_190500_update_accounts_reference_unique_add_active.php create mode 100644 database/seeders/ArchiveEntitySeeder.php create mode 100644 database/seeders/ArchiveSettingSeeder.php create mode 100644 lang/sl/contracts.php create mode 100644 resources/js/Pages/Settings/Archive/Index.vue create mode 100644 tests/Feature/ArchiveContractAccountChainTest.php create mode 100644 tests/Feature/ArchiveContractChainedEntitiesTest.php create mode 100644 tests/Feature/ArchiveContractSegmentTest.php create mode 100644 tests/Feature/ArchiveContractTest.php create mode 100644 tests/Feature/ArchiveRunNowTest.php create mode 100644 tests/Feature/ArchiveSettingCrudTest.php create mode 100644 tests/Feature/ReactivateContractTest.php diff --git a/app/Console/Commands/RunArchive.php b/app/Console/Commands/RunArchive.php new file mode 100644 index 0000000..306c78d --- /dev/null +++ b/app/Console/Commands/RunArchive.php @@ -0,0 +1,57 @@ +option('setting')) + ->filter() + ->map(fn ($v) => (int) $v) + ->filter(); + + $query = ArchiveSetting::query() + ->where('enabled', true) + // Manual strategies are never auto-run via the job + ->where('strategy', '!=', 'manual'); + if ($ids->isNotEmpty()) { + $query->whereIn('id', $ids); + } + + $settings = $query->get(); + if ($settings->isEmpty()) { + $this->info('No enabled archive settings found.'); + + return self::SUCCESS; + } + + $executor = app(ArchiveExecutor::class); + $overall = []; + foreach ($settings as $setting) { + $this->line("Processing setting #{$setting->id} (".($setting->name ?? 'unnamed').')'); + $res = $executor->executeSetting($setting); + foreach ($res as $table => $count) { + $overall[$table] = ($overall[$table] ?? 0) + $count; + $this->line(" - {$table}: {$count} affected"); + } + } + + $this->info('Archive run complete.'); + if (empty($overall)) { + $this->info('No rows matched any archive setting.'); + } else { + $this->table(['Table', 'Affected'], collect($overall)->map(fn ($c, $t) => [$t, $c])->values()->all()); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/ArchiveSettingController.php b/app/Http/Controllers/ArchiveSettingController.php new file mode 100644 index 0000000..ed91fbc --- /dev/null +++ b/app/Http/Controllers/ArchiveSettingController.php @@ -0,0 +1,76 @@ +latest() + ->paginate(25) + ->withQueryString(); + + $archiveEntities = ArchiveEntity::query() + ->where('enabled', true) + ->orderBy('focus') + ->get(['id', 'focus', 'name', 'related']); + + return Inertia::render('Settings/Archive/Index', [ + 'settings' => $settings, + 'archiveEntities' => $archiveEntities, + 'actions' => Action::query()->with('decisions:id,name')->orderBy('name')->get(['id', 'name', 'segment_id']), + 'segments' => Segment::query()->orderBy('name')->get(['id', 'name']), + 'chainPatterns' => config('archiving.chains'), + ]); + } + + public function store(ArchiveSettingRequest $request): RedirectResponse + { + $data = $request->validated(); + $data['created_by'] = $request->user()?->id; + ArchiveSetting::create($data); + + return redirect()->back()->with('flash.banner', 'Archive rule created'); + } + + public function update(ArchiveSettingRequest $request, ArchiveSetting $archiveSetting): RedirectResponse + { + $data = $request->validated(); + $data['updated_by'] = $request->user()?->id; + $archiveSetting->update($data); + + return redirect()->back()->with('flash.banner', 'Archive rule updated'); + } + + public function destroy(ArchiveSetting $archiveSetting): RedirectResponse + { + $archiveSetting->delete(); + + return redirect()->back()->with('flash.banner', 'Archive rule deleted'); + } + + public function run(ArchiveSetting $archiveSetting, Request $request): RedirectResponse + { + // Allow manual triggering even if strategy is manual or disabled? We'll require enabled. + if (! $archiveSetting->enabled) { + return back()->with('flash.banner', 'Rule is disabled'); + } + $executor = app(ArchiveExecutor::class); + $results = $executor->executeSetting($archiveSetting, context: null, userId: $request->user()?->id); + $summary = empty($results) ? 'No rows matched.' : collect($results)->map(fn ($c, $t) => "$t:$c")->implode(', '); + + return back()->with('flash.banner', 'Archive run complete: '.$summary); + } +} diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index a9a25a0..8de288c 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -159,6 +159,9 @@ public function storeContract(ClientCase $clientCase, StoreContractRequest $requ public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) { $contract = Contract::where('uuid', $uuid)->firstOrFail(); + if (! $contract->active) { + return back()->with('warning', __('contracts.edit_not_allowed_archived')); + } \DB::transaction(function () use ($request, $contract) { $contract->update([ @@ -243,6 +246,10 @@ public function storeActivity(ClientCase $clientCase, Request $request) if (! empty($attributes['contract_uuid'])) { $contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id'); if ($contract) { + // Prevent attaching a new activity specifically to an archived contract + if (! $contract->active) { + return back()->with('warning', __('contracts.activity_not_allowed_archived')); + } $contractId = $contract->id; } } @@ -315,6 +322,11 @@ public function updateContractSegment(ClientCase $clientCase, string $uuid, Requ $contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(); + // Safety: Disallow segment change if contract archived (inactive) + if (! $contract->active) { + return back()->with('warning', __('contracts.segment_change_not_allowed_archived')); + } + \DB::transaction(function () use ($contract, $validated) { // Deactivate current active relation(s) \DB::table('contract_segment') @@ -365,6 +377,10 @@ public function attachSegment(ClientCase $clientCase, Request $request) // Optionally make it active for a specific contract if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail(); + if (! $contract->active) { + // Prevent segment activation for archived contract + return; // Silent; we still attach to case but do not alter archived contract + } \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('active', true) @@ -402,6 +418,9 @@ public function storeDocument(ClientCase $clientCase, Request $request) $contract = null; if (! empty($validated['contract_uuid'])) { $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); + if ($contract && ! $contract->active) { + return back()->with('warning', __('contracts.document_not_allowed_archived')); + } } $directory = $contract ? ('contracts/'.$contract->uuid.'/documents') @@ -1000,16 +1019,49 @@ public function show(ClientCase $clientCase) 'phone_types' => \App\Models\Person\PhoneType::all(), ]; + // $active = false; + // Optional segment filter from query string $segmentId = request()->integer('segment'); - // Prepare contracts and a reference map + // Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables + $latestArchiveSetting = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->where(function ($q) { + $q->whereNull('reactivate')->orWhere('reactivate', false); + }) + ->orderByDesc('id') + ->first(); + $archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null + $relatedArchiveTables = []; + if ($latestArchiveSetting) { + $entities = (array) $latestArchiveSetting->entities; + foreach ($entities as $edef) { + if (isset($edef['related']) && is_array($edef['related'])) { + foreach ($edef['related'] as $rel) { + $relatedArchiveTables[] = $rel; + } + } + } + $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); + } + + // Prepare contracts and a reference map. + // Only apply active/inactive filtering IF a segment filter is provided. $contractsQuery = $case->contracts() - ->with(['type', 'account', 'objects', 'segments:id,name']) - ->orderByDesc('created_at'); + ->with(['type', 'account', 'objects', 'segments:id,name']); + + $contractsQuery->orderByDesc('created_at'); if (! empty($segmentId)) { // Filter to contracts that are in the provided segment and active on pivot + if ($archiveSegmentId && $segmentId === $archiveSegmentId) { + // Viewing the archive segment: only archived (inactive) contracts + $contractsQuery->where('active', 0); + } else { + // Any other specific segment: only active contracts + $contractsQuery->where('active', 1); + } $contractsQuery->whereExists(function ($q) use ($segmentId) { $q->from('contract_segment') ->whereColumn('contract_segment.contract_id', 'contracts.id') @@ -1019,6 +1071,7 @@ public function show(ClientCase $clientCase) } $contracts = $contractsQuery->get(); + $contractRefMap = []; foreach ($contracts as $c) { $contractRefMap[$c->id] = $c->reference; @@ -1062,6 +1115,10 @@ public function show(ClientCase $clientCase) 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'bankAccounts']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, + 'archive_meta' => [ + 'archive_segment_id' => $archiveSegmentId, + 'related_tables' => $relatedArchiveTables, + ], 'activities' => tap( (function () use ($case, $segmentId, $contractIds) { $q = $case->activities() @@ -1090,7 +1147,11 @@ function ($p) { 'documents' => $mergedDocs, 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'account_types' => \App\Models\AccountType::all(), - 'actions' => \App\Models\Action::with('decisions')->get(), + 'actions' => \App\Models\Action::with('decisions') + /*->when($segmentId, function($q) use($segmentId) { + $q->where('segment_id', $segmentId)->orWhereNull('segment_id'); + })*/ + ->get(), 'types' => $types, 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), @@ -1170,4 +1231,184 @@ public function deleteContractDocument(Contract $contract, Document $document, R ? response()->json(['status' => 'ok']) : back()->with('success', 'Document deleted.'); } + + /** + * Manually archive a contract (flag active=0) and optionally its immediate financial relations. + */ + public function archiveContract(ClientCase $clientCase, string $uuid, Request $request) + { + $contract = Contract::query()->where('uuid', $uuid)->firstOrFail(); + if ($contract->client_case_id !== $clientCase->id) { + abort(404); + } + $reactivateRequested = (bool) $request->boolean('reactivate'); + // Determine applicable settings based on intent (archive vs reactivate) + if ($reactivateRequested) { + $latestReactivate = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->where('reactivate', true) + ->whereIn('strategy', ['immediate', 'manual']) + ->orderByDesc('id') + ->first(); + if (! $latestReactivate) { + return back()->with('warning', __('contracts.reactivate_not_allowed')); + } + $settings = collect([$latestReactivate]); + $hasReactivateRule = true; + } else { + $settings = \App\Models\ArchiveSetting::query() + ->where('enabled', true) + ->whereIn('strategy', ['immediate', 'manual']) + ->where(function ($q) { // exclude reactivate-only rules from archive run + $q->whereNull('reactivate')->orWhere('reactivate', false); + }) + ->get(); + if ($settings->isEmpty()) { + return back()->with('warning', __('contracts.no_archive_settings')); + } + $hasReactivateRule = false; + } + + $executor = app(\App\Services\Archiving\ArchiveExecutor::class); + $context = [ + 'contract_id' => $contract->id, + 'client_case_id' => $clientCase->id, + ]; + if ($contract->account) { + $context['account_id'] = $contract->account->id; + } + + $overall = []; + $hadAnyEffect = false; + foreach ($settings as $setting) { + $res = $executor->executeSetting($setting, $context, optional($request->user())->id); + foreach ($res as $table => $count) { + $overall[$table] = ($overall[$table] ?? 0) + $count; + if ($count > 0) { + $hadAnyEffect = true; + } + } + } + + if ($reactivateRequested && $hasReactivateRule) { + // Reactivation path: ensure contract becomes active and soft-delete cleared. + if ($contract->active == 0 || $contract->deleted_at) { + $contract->forceFill(['active' => 1, 'deleted_at' => null])->save(); + $overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1; + $hadAnyEffect = true; + } + } else { + // Ensure the contract itself is archived even if rule conditions would have excluded it + if (! empty($contract->getAttributes()) && $contract->active) { + if (! array_key_exists('contracts', $overall)) { + $contract->update(['active' => 0]); + $overall['contracts'] = ($overall['contracts'] ?? 0) + 1; + } else { + $contract->refresh(); + } + $hadAnyEffect = true; + } + } + + // Create an Activity record logging this archive if an action or decision is tied to any setting + if ($hadAnyEffect) { + $activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id)); + if ($activitySetting) { + try { + if ($reactivateRequested) { + $note = 'Ponovna aktivacija pogodba '.$contract->reference; + } else { + $noteKey = 'contracts.archived_activity_note'; + $note = __($noteKey, ['reference' => $contract->reference]); + if ($note === $noteKey) { + $note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl'); + } + } + $activityData = [ + 'client_case_id' => $clientCase->id, + 'action_id' => $activitySetting->action_id, + 'decision_id' => $activitySetting->decision_id, + 'note' => $note, + 'active' => 1, + 'user_id' => optional($request->user())->id, + ]; + if ($reactivateRequested) { + // Attach the contract_id when reactivated as per requirement + $activityData['contract_id'] = $contract->id; + } + \App\Models\Activity::create($activityData); + } catch (\Throwable $e) { + logger()->warning('Failed to create archive/reactivate activity', [ + 'error' => $e->getMessage(), + 'contract_id' => $contract->id, + 'setting_id' => optional($activitySetting)->id, + 'reactivate' => $reactivateRequested, + ]); + } + } + } + // If any archive setting specifies a segment_id, move the contract to that segment (archive bucket) + $segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified + if ($segmentSetting && $segmentSetting->segment_id) { + try { + $segmentId = $segmentSetting->segment_id; + \DB::transaction(function () use ($contract, $segmentId, $clientCase) { + // Ensure the segment is attached to the client case (activate if previously inactive) + $casePivot = \DB::table('client_case_segment') + ->where('client_case_id', $clientCase->id) + ->where('segment_id', $segmentId) + ->first(); + if (! $casePivot) { + \DB::table('client_case_segment')->insert([ + 'client_case_id' => $clientCase->id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } elseif (! $casePivot->active) { + \DB::table('client_case_segment') + ->where('id', $casePivot->id) + ->update(['active' => true, 'updated_at' => now()]); + } + + // Deactivate all current active contract segments + \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', true) + ->update(['active' => false, 'updated_at' => now()]); + + // Attach or activate the archive segment for this contract + $existing = \DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('segment_id', $segmentId) + ->first(); + if ($existing) { + \DB::table('contract_segment') + ->where('id', $existing->id) + ->update(['active' => true, 'updated_at' => now()]); + } else { + \DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $segmentId, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } catch (\Throwable $e) { + logger()->warning('Failed to move contract to archive segment', [ + 'error' => $e->getMessage(), + 'contract_id' => $contract->id, + 'segment_id' => $segmentSetting->segment_id, + 'setting_id' => $segmentSetting->id, + ]); + } + } + + $message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived'); + + return back()->with('success', $message); + } } diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index c28ed5b..e48f741 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -109,6 +109,7 @@ public function store(Request $request) 'sample_headers' => 'nullable|array', 'client_id' => 'nullable|integer|exists:clients,id', 'is_active' => 'boolean', + 'reactivate' => 'boolean', 'entities' => 'nullable|array', 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,payments', 'mappings' => 'array', @@ -155,6 +156,7 @@ public function store(Request $request) 'user_id' => $request->user()?->id, 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? true, + 'reactivate' => $data['reactivate'] ?? false, 'meta' => array_filter([ 'entities' => $entities, 'segment_id' => data_get($data, 'meta.segment_id'), @@ -219,6 +221,7 @@ public function edit(ImportTemplate $template) 'source_type' => $template->source_type, 'default_record_type' => $template->default_record_type, 'is_active' => $template->is_active, + 'reactivate' => $template->reactivate, 'client_uuid' => $template->client?->uuid, 'sample_headers' => $template->sample_headers, 'meta' => $template->meta, @@ -298,6 +301,7 @@ public function update(Request $request, ImportTemplate $template) 'default_record_type' => 'nullable|string|max:50', 'client_id' => 'nullable|integer|exists:clients,id', 'is_active' => 'boolean', + 'reactivate' => 'boolean', 'sample_headers' => 'nullable|array', 'meta' => 'nullable|array', 'meta.delimiter' => 'nullable|string|max:4', @@ -341,6 +345,7 @@ public function update(Request $request, ImportTemplate $template) 'default_record_type' => $data['default_record_type'] ?? null, 'client_id' => $data['client_id'] ?? null, 'is_active' => $data['is_active'] ?? $template->is_active, + 'reactivate' => $data['reactivate'] ?? $template->reactivate, 'sample_headers' => $data['sample_headers'] ?? $template->sample_headers, 'meta' => (function () use ($newMeta) { // If payments import mode is enabled, force entities sequence in meta diff --git a/app/Http/Requests/ArchiveSettingRequest.php b/app/Http/Requests/ArchiveSettingRequest.php new file mode 100644 index 0000000..5af1c60 --- /dev/null +++ b/app/Http/Requests/ArchiveSettingRequest.php @@ -0,0 +1,39 @@ + ['nullable', 'exists:actions,id'], + 'decision_id' => ['nullable', 'exists:decisions,id'], + 'segment_id' => ['nullable', 'exists:segments,id'], + 'entities' => ['required', 'array', 'min:1'], + 'entities.*.table' => ['required', 'string', 'in:'.implode(',', $allowed)], + 'entities.*.related' => ['nullable', 'array'], + 'entities.*.conditions' => ['nullable', 'array'], + 'entities.*.columns' => ['nullable', 'array'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'enabled' => ['boolean'], + 'strategy' => ['required', 'in:immediate,scheduled,queued,manual'], + 'soft' => ['boolean'], + 'reactivate' => ['boolean'], + 'options' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Models/ArchiveEntity.php b/app/Models/ArchiveEntity.php new file mode 100644 index 0000000..39967e4 --- /dev/null +++ b/app/Models/ArchiveEntity.php @@ -0,0 +1,27 @@ + 'array', + 'enabled' => 'boolean', + ]; + } +} diff --git a/app/Models/ArchiveRun.php b/app/Models/ArchiveRun.php new file mode 100644 index 0000000..68e2bc3 --- /dev/null +++ b/app/Models/ArchiveRun.php @@ -0,0 +1,43 @@ + 'array', + 'context' => 'array', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + public function setting() + { + return $this->belongsTo(ArchiveSetting::class, 'archive_setting_id'); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ArchiveSetting.php b/app/Models/ArchiveSetting.php new file mode 100644 index 0000000..282103e --- /dev/null +++ b/app/Models/ArchiveSetting.php @@ -0,0 +1,66 @@ + 'array', + 'options' => 'array', + 'enabled' => 'boolean', + 'soft' => 'boolean', + 'reactivate' => 'boolean', + ]; + } + + // Relationships (nullable FKs) + public function action() + { + return $this->belongsTo(Action::class); + } + + public function decision() + { + return $this->belongsTo(Decision::class); + } + + public function segment() + { + return $this->belongsTo(Segment::class); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 574eb8a..1f8b540 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -56,6 +56,7 @@ public function segments(): BelongsToMany public function account(): HasOne { return $this->hasOne(\App\Models\Account::class) + ->latestOfMany() ->with('type'); } diff --git a/app/Models/Import.php b/app/Models/Import.php index 0dc2af0..1072869 100644 --- a/app/Models/Import.php +++ b/app/Models/Import.php @@ -12,7 +12,7 @@ class Import extends Model use HasFactory; protected $fillable = [ - 'uuid','user_id','import_template_id','client_id','source_type','file_name','original_name','disk','path','size','sheet_name','status','total_rows','valid_rows','invalid_rows','imported_rows','started_at','finished_at','failed_at','error_summary','meta' + 'uuid', 'user_id', 'import_template_id', 'client_id', 'source_type', 'file_name', 'original_name', 'disk', 'path', 'size', 'sheet_name', 'status', 'reactivate', 'total_rows', 'valid_rows', 'invalid_rows', 'imported_rows', 'started_at', 'finished_at', 'failed_at', 'error_summary', 'meta', ]; protected $casts = [ @@ -21,6 +21,7 @@ class Import extends Model 'started_at' => 'datetime', 'finished_at' => 'datetime', 'failed_at' => 'datetime', + 'reactivate' => 'boolean', ]; public function user(): BelongsTo diff --git a/app/Models/ImportTemplate.php b/app/Models/ImportTemplate.php index 5206a3c..b63ac8f 100644 --- a/app/Models/ImportTemplate.php +++ b/app/Models/ImportTemplate.php @@ -12,13 +12,14 @@ class ImportTemplate extends Model use HasFactory; protected $fillable = [ - 'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'meta' + 'uuid', 'name', 'description', 'source_type', 'default_record_type', 'sample_headers', 'user_id', 'client_id', 'is_active', 'reactivate', 'meta', ]; protected $casts = [ 'sample_headers' => 'array', 'meta' => 'array', 'is_active' => 'boolean', + 'reactivate' => 'boolean', ]; public function user(): BelongsTo diff --git a/app/Policies/ArchiveSettingPolicy.php b/app/Policies/ArchiveSettingPolicy.php new file mode 100644 index 0000000..a8bcad8 --- /dev/null +++ b/app/Policies/ArchiveSettingPolicy.php @@ -0,0 +1,40 @@ +is_admin ?? false); + } + + public function viewAny(User $user): bool + { + return $this->isAdmin($user); + } + + public function view(User $user, ArchiveSetting $setting): bool + { + return $this->isAdmin($user); + } + + public function create(User $user): bool + { + return $this->isAdmin($user); + } + + public function update(User $user, ArchiveSetting $setting): bool + { + return $this->isAdmin($user); + } + + public function delete(User $user, ArchiveSetting $setting): bool + { + return $this->isAdmin($user); + } +} diff --git a/app/Services/Archiving/ArchiveExecutor.php b/app/Services/Archiving/ArchiveExecutor.php new file mode 100644 index 0000000..0f55f01 --- /dev/null +++ b/app/Services/Archiving/ArchiveExecutor.php @@ -0,0 +1,286 @@ +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; + } +} diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index 6c346bd..0873c16 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -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]; + } + } } diff --git a/app/Services/ImportSimulationService.php b/app/Services/ImportSimulationService.php index 7e92028..fde9b30 100644 --- a/app/Services/ImportSimulationService.php +++ b/app/Services/ImportSimulationService.php @@ -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', ]; } diff --git a/config/archiving.php b/config/archiving.php new file mode 100644 index 0000000..c8e6fb8 --- /dev/null +++ b/config/archiving.php @@ -0,0 +1,20 @@ + [ + 'contracts.account', + 'contracts.account.payments', + 'contracts.account.bookings', + 'contracts.documents', + 'account.payments', + 'account.bookings', + ], + // Singular-to-plural resolution for chain tail segments + 'singular_plural' => [ + 'account' => 'accounts', + 'payment' => 'payments', + 'booking' => 'bookings', + 'document' => 'documents', + ], +]; diff --git a/database/factories/ArchiveSettingFactory.php b/database/factories/ArchiveSettingFactory.php new file mode 100644 index 0000000..7b8668c --- /dev/null +++ b/database/factories/ArchiveSettingFactory.php @@ -0,0 +1,43 @@ + + */ +class ArchiveSettingFactory extends Factory +{ + protected $model = ArchiveSetting::class; + + public function definition(): array + { + return [ + 'action_id' => null, + 'decision_id' => null, + 'segment_id' => null, + 'entities' => [ + [ + 'table' => 'documents', + 'conditions' => [ + 'older_than_days' => $this->faker->numberBetween(30, 365), + ], + 'columns' => ['id', 'deleted_at'], + ], + ], + 'name' => $this->faker->sentence(3), + 'description' => $this->faker->optional()->paragraph(), + 'enabled' => true, + 'strategy' => 'immediate', + 'soft' => true, + 'options' => [ + 'batch_size' => $this->faker->numberBetween(50, 500), + ], + 'created_by' => User::query()->inRandomOrder()->value('id'), + 'updated_by' => null, + ]; + } +} diff --git a/database/migrations/2025_10_05_000000_create_archive_settings_table.php b/database/migrations/2025_10_05_000000_create_archive_settings_table.php new file mode 100644 index 0000000..6691cea --- /dev/null +++ b/database/migrations/2025_10_05_000000_create_archive_settings_table.php @@ -0,0 +1,61 @@ +id(); + + // Contextual foreign keys (nullable allows broader global rules) + $table->foreignId('action_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('decision_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('segment_id')->nullable()->constrained()->nullOnDelete(); + + // JSON describing entities (tables/models) impacted + // Example shape: [{"table":"documents","conditions":{"older_than_days":180},"columns":["id","deleted_at"]}] + $table->json('entities'); + + // Optional descriptive metadata + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->boolean('enabled')->default(true); + + // Execution strategy: immediate | scheduled | queued + $table->string('strategy')->default('immediate'); + + // Whether to perform a soft archive (flag / soft delete) instead of permanent removal + $table->boolean('soft')->default(true); + + // Additional arbitrary options (thresholds, flags, custom logic parameters) + $table->json('options')->nullable(); + + // Auditing foreign keys for who created / last updated the rule + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + + // Useful indexes + $table->index(['action_id', 'decision_id', 'segment_id']); + $table->index('enabled'); + $table->index('strategy'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('archive_settings'); + } +}; diff --git a/database/migrations/2025_10_05_121500_create_archive_entities_table.php b/database/migrations/2025_10_05_121500_create_archive_entities_table.php new file mode 100644 index 0000000..eb80c54 --- /dev/null +++ b/database/migrations/2025_10_05_121500_create_archive_entities_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('focus')->unique(); // e.g. contracts, client_cases + $table->json('related'); // JSON array of related table names + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + $table->index('enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('archive_entities'); + } +}; diff --git a/database/migrations/2025_10_05_123000_add_active_columns_for_archivable_tables.php b/database/migrations/2025_10_05_123000_add_active_columns_for_archivable_tables.php new file mode 100644 index 0000000..5b8f94d --- /dev/null +++ b/database/migrations/2025_10_05_123000_add_active_columns_for_archivable_tables.php @@ -0,0 +1,63 @@ +unsignedTinyInteger('active')->default(1)->after('description'); + $table->index('active'); + }); + } + + // Add active column to activities if missing + if (Schema::hasTable('activities') && ! Schema::hasColumn('activities', 'active')) { + Schema::table('activities', function (Blueprint $table): void { + $table->unsignedTinyInteger('active')->default(1)->after('note'); + $table->index('active'); + }); + } + + // Add active column to documents if missing + if (Schema::hasTable('documents') && ! Schema::hasColumn('documents', 'active')) { + Schema::table('documents', function (Blueprint $table): void { + $table->unsignedTinyInteger('active')->default(1)->after('is_public'); + $table->index('active'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('bookings') && Schema::hasColumn('bookings', 'active')) { + Schema::table('bookings', function (Blueprint $table): void { + $table->dropIndex(['active']); + $table->dropColumn('active'); + }); + } + if (Schema::hasTable('activities') && Schema::hasColumn('activities', 'active')) { + Schema::table('activities', function (Blueprint $table): void { + $table->dropIndex(['active']); + $table->dropColumn('active'); + }); + } + if (Schema::hasTable('documents') && Schema::hasColumn('documents', 'active')) { + Schema::table('documents', function (Blueprint $table): void { + $table->dropIndex(['active']); + $table->dropColumn('active'); + }); + } + } +}; diff --git a/database/migrations/2025_10_05_124500_add_active_column_to_payments_if_missing.php b/database/migrations/2025_10_05_124500_add_active_column_to_payments_if_missing.php new file mode 100644 index 0000000..23dc1f4 --- /dev/null +++ b/database/migrations/2025_10_05_124500_add_active_column_to_payments_if_missing.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('active')->default(1)->after('type_id'); + $table->index('active'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('payments') && Schema::hasColumn('payments', 'active')) { + Schema::table('payments', function (Blueprint $table): void { + $table->dropIndex(['active']); + $table->dropColumn('active'); + }); + } + } +}; diff --git a/database/migrations/2025_10_05_140000_create_archive_runs_table.php b/database/migrations/2025_10_05_140000_create_archive_runs_table.php new file mode 100644 index 0000000..5c2f862 --- /dev/null +++ b/database/migrations/2025_10_05_140000_create_archive_runs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('archive_setting_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status', 30)->default('running'); // running|success|error + $table->json('counts')->nullable(); // per-table affected counts + $table->json('context')->nullable(); // manual context scope + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->text('message')->nullable(); + $table->timestamps(); + $table->index(['archive_setting_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('archive_runs'); + } +}; diff --git a/database/migrations/2025_10_05_170000_add_reactivate_column_to_archive_settings_table.php b/database/migrations/2025_10_05_170000_add_reactivate_column_to_archive_settings_table.php new file mode 100644 index 0000000..eb6c506 --- /dev/null +++ b/database/migrations/2025_10_05_170000_add_reactivate_column_to_archive_settings_table.php @@ -0,0 +1,28 @@ +boolean('reactivate')->default(false)->after('soft'); + $table->index('reactivate'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('archive_settings') && Schema::hasColumn('archive_settings', 'reactivate')) { + Schema::table('archive_settings', function (Blueprint $table): void { + $table->dropIndex(['reactivate']); + $table->dropColumn('reactivate'); + }); + } + } +}; diff --git a/database/migrations/2025_10_05_180000_add_reactivate_to_import_templates_and_imports.php b/database/migrations/2025_10_05_180000_add_reactivate_to_import_templates_and_imports.php new file mode 100644 index 0000000..fdd1690 --- /dev/null +++ b/database/migrations/2025_10_05_180000_add_reactivate_to_import_templates_and_imports.php @@ -0,0 +1,36 @@ +boolean('reactivate')->default(false)->after('is_active'); + } + }); + Schema::table('imports', function (Blueprint $table) { + if (! Schema::hasColumn('imports', 'reactivate')) { + $table->boolean('reactivate')->default(false)->after('status'); + } + }); + } + + public function down(): void + { + Schema::table('import_templates', function (Blueprint $table) { + if (Schema::hasColumn('import_templates', 'reactivate')) { + $table->dropColumn('reactivate'); + } + }); + Schema::table('imports', function (Blueprint $table) { + if (Schema::hasColumn('imports', 'reactivate')) { + $table->dropColumn('reactivate'); + } + }); + } +}; diff --git a/database/migrations/2025_10_05_190500_update_accounts_reference_unique_add_active.php b/database/migrations/2025_10_05_190500_update_accounts_reference_unique_add_active.php new file mode 100644 index 0000000..511f96a --- /dev/null +++ b/database/migrations/2025_10_05_190500_update_accounts_reference_unique_add_active.php @@ -0,0 +1,35 @@ +dropUnique('accounts_reference_unique'); + } catch (\Throwable $e) { + // ignore if it does not exist + } + // Recreate including active flag so archived/inactive accounts can reuse reference + $table->unique(['contract_id', 'reference', 'active', 'deleted_at'], 'accounts_reference_unique'); + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + try { + $table->dropUnique('accounts_reference_unique'); + } catch (\Throwable $e) { + // ignore + } + // Restore previous definition without active + $table->unique(['contract_id', 'reference', 'deleted_at'], 'accounts_reference_unique'); + }); + } +}; diff --git a/database/seeders/ArchiveEntitySeeder.php b/database/seeders/ArchiveEntitySeeder.php new file mode 100644 index 0000000..26f8772 --- /dev/null +++ b/database/seeders/ArchiveEntitySeeder.php @@ -0,0 +1,59 @@ + 'contracts', + 'name' => 'Contracts', + 'description' => 'Contracts and their financial / activity related records.', + 'related' => [ + // Direct related tables + 'accounts', + 'activities', + 'documents', // polymorphic (contract documents only when used as focus) + // Chained relations (dot notation) – resolve via contract -> account -> payments/bookings + 'account.payments', + 'account.bookings', + ], + ], + [ + 'focus' => 'client_cases', + 'name' => 'Client Cases', + 'description' => 'Client cases and subordinate contractual / financial records.', + 'related' => [ + 'contracts', // direct contracts under case + 'contracts.account', // via contracts (hasOne account) + 'activities', // case level activities (and possibly contract-linked) + 'documents', // case level documents + // Chained relations: + 'contracts.account.payments', // contracts -> account -> payments + 'contracts.account.bookings', // contracts -> account -> bookings + 'contracts.documents', // contracts -> documents (polymorphic) + ], + ], + ]; + + foreach ($entities as $data) { + ArchiveEntity::query()->updateOrCreate( + ['focus' => $data['focus']], + [ + 'name' => $data['name'], + 'description' => $data['description'], + 'related' => $data['related'], + 'enabled' => true, + ] + ); + } + } +} diff --git a/database/seeders/ArchiveSettingSeeder.php b/database/seeders/ArchiveSettingSeeder.php new file mode 100644 index 0000000..a1388ee --- /dev/null +++ b/database/seeders/ArchiveSettingSeeder.php @@ -0,0 +1,16 @@ +count() === 0) { + ArchiveSetting::factory()->count(2)->create(); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 52b4848..357937a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,6 +31,7 @@ public function run(): void $this->call([ AccountTypeSeeder::class, PaymentSettingSeeder::class, + ArchiveEntitySeeder::class, PersonSeeder::class, SegmentSeeder::class, ActionSeeder::class, diff --git a/lang/sl/contracts.php b/lang/sl/contracts.php new file mode 100644 index 0000000..3bcd2ee --- /dev/null +++ b/lang/sl/contracts.php @@ -0,0 +1,14 @@ + 'Pogodba je bila arhivirana.', + 'reactivated' => 'Pogodba je bila ponovno aktivirana.', + 'reactivate_not_allowed' => 'Ponovna aktivacija ni na voljo.', + 'no_archive_settings' => 'Ni nastavitev za arhiviranje.', + 'archived_activity_note' => 'Arhivirana pogodba :reference', + 'reactivated_activity_note' => 'Ponovno aktivirana pogodba :reference', + 'segment_change_not_allowed_archived' => 'Segmenta ni mogoče spremeniti za arhivirano pogodbo.', + 'edit_not_allowed_archived' => 'Urejanje ni dovoljeno za arhivirano pogodbo.', + 'activity_not_allowed_archived' => 'Aktivnosti ni mogoče dodati k arhivirani pogodbi.', + 'document_not_allowed_archived' => 'Dokumenta ni mogoče dodati k arhivirani pogodbi.', +]; diff --git a/resources/js/Components/DatePickerField.vue b/resources/js/Components/DatePickerField.vue index 4e043fe..becccc8 100644 --- a/resources/js/Components/DatePickerField.vue +++ b/resources/js/Components/DatePickerField.vue @@ -1,7 +1,7 @@ - diff --git a/resources/js/Components/DocumentsTable.vue b/resources/js/Components/DocumentsTable.vue index c72763c..20d9fb1 100644 --- a/resources/js/Components/DocumentsTable.vue +++ b/resources/js/Components/DocumentsTable.vue @@ -255,7 +255,13 @@ function closeActions() { class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3" >Vir - + Opis + diff --git a/resources/js/Components/PersonDetailPhone.vue b/resources/js/Components/PersonDetailPhone.vue index ca3159d..3975256 100644 --- a/resources/js/Components/PersonDetailPhone.vue +++ b/resources/js/Components/PersonDetailPhone.vue @@ -1,143 +1,323 @@ diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 0c2cc64..7cb37a8 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -9,7 +9,18 @@ import Breadcrumbs from "@/Components/Breadcrumbs.vue"; import GlobalSearch from "./Partials/GlobalSearch.vue"; import NotificationsBell from "./Partials/NotificationsBell.vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; -import { faMobileScreenButton } from "@fortawesome/free-solid-svg-icons"; +import { + faMobileScreenButton, + faGaugeHigh, + faLayerGroup, + faUserGroup, + faFolderOpen, + faFileImport, + faTableList, + faFileCirclePlus, + faMap, + faGear, +} from "@fortawesome/free-solid-svg-icons"; const props = defineProps({ title: String, @@ -173,7 +184,7 @@ const rawMenuGroups = [ ], }, { - label: "Terensko", + label: "Terensko delo", items: [ { key: "fieldjobs", @@ -205,6 +216,19 @@ const menuGroups = computed(() => { })); }); +// Icon map for menu keys -> FontAwesome icon definitions +const menuIconMap = { + dashboard: faGaugeHigh, + segments: faLayerGroup, + clients: faUserGroup, + cases: faFolderOpen, + imports: faFileImport, + "import-templates": faTableList, + "import-templates-new": faFileCirclePlus, + fieldjobs: faMap, + settings: faGear, +}; + function isActive(patterns) { try { return patterns?.some((p) => route().current(p)); @@ -267,161 +291,12 @@ function isActive(patterns) { ]" :title="item.title" > - - - - - - - - - - + + {{ item.title }} diff --git a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue index 1239364..302757f 100644 --- a/resources/js/Pages/Cases/Partials/ActivityDrawer.vue +++ b/resources/js/Pages/Cases/Partials/ActivityDrawer.vue @@ -73,11 +73,22 @@ const store = async () => { amount: form.amount, note: form.note, }); + // Helper to safely format a selected date (Date instance or parsable value) to YYYY-MM-DD + const formatDateForSubmit = (value) => { + if (!value) return null; // leave empty as null + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d.getTime())) return null; // invalid date -> null + // Avoid timezone shifting by constructing in local time + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; // matches en-CA style YYYY-MM-DD + }; form .transform((data) => ({ ...data, - due_date: new Date(data.due_date).toLocaleDateString("en-CA"), + due_date: formatDateForSubmit(data.due_date), })) .post(route("clientCase.activity.store", props.client_case), { onSuccess: () => { diff --git a/resources/js/Pages/Cases/Partials/ActivityTable.vue b/resources/js/Pages/Cases/Partials/ActivityTable.vue index 618ee24..15107bd 100644 --- a/resources/js/Pages/Cases/Partials/ActivityTable.vue +++ b/resources/js/Pages/Cases/Partials/ActivityTable.vue @@ -179,17 +179,17 @@ const confirmDeleteAction = () => { > - - + + diff --git a/resources/js/Pages/Cases/Partials/ContractTable.vue b/resources/js/Pages/Cases/Partials/ContractTable.vue index 8b9b221..236ada5 100644 --- a/resources/js/Pages/Cases/Partials/ContractTable.vue +++ b/resources/js/Pages/Cases/Partials/ContractTable.vue @@ -21,6 +21,7 @@ import { faTrash, faListCheck, faPlus, + faBoxArchive, } from "@fortawesome/free-solid-svg-icons"; const props = defineProps({ @@ -119,6 +120,10 @@ const confirmChange = ref({ fromAll: false, }); const askChangeSegment = (c, segmentId, fromAll = false) => { + // Prevent segment change for archived contracts + if (!c?.active) { + return; + } confirmChange.value = { show: true, contract: c, segmentId, fromAll }; }; const closeConfirm = () => { @@ -262,13 +267,16 @@ const closePaymentsDialog = () => { class="inline-flex items-center justify-center h-7 w-7 rounded-full hover:bg-gray-100" :class="{ 'opacity-50 cursor-not-allowed': - !segments || segments.length === 0, + !segments || segments.length === 0 || !c.active, }" :title=" - segments && segments.length + !c.active + ? 'Segmenta ni mogoče spremeniti za arhivirano pogodbo' + : segments && segments.length ? 'Spremeni segment' : 'Ni segmentov na voljo za ta primer' " + :disabled="!c.active || !segments || !segments.length" > { + Arhivirano {{ @@ -433,6 +446,7 @@ const closePaymentsDialog = () => { +
+ +
+ {{ c.active ? "Arhiviranje" : "Ponovna aktivacija" }} +
+ +
+
+ + diff --git a/routes/web.php b/routes/web.php index bed1d17..03be493 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Charts\ExampleChart; use App\Http\Controllers\AccountBookingController; use App\Http\Controllers\AccountPaymentController; +use App\Http\Controllers\ArchiveSettingController; use App\Http\Controllers\CaseObjectController; use App\Http\Controllers\ClientCaseContoller; use App\Http\Controllers\ClientController; @@ -149,6 +150,7 @@ Route::get('client-cases', [ClientCaseContoller::class, 'index'])->name('clientCase'); Route::get('client-cases/{client_case:uuid}', [ClientCaseContoller::class, 'show'])->name('clientCase.show'); Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/segment', [ClientCaseContoller::class, 'updateContractSegment'])->name('clientCase.contract.updateSegment'); + Route::post('client-cases/{client_case:uuid}/contracts/{uuid}/archive', [ClientCaseContoller::class, 'archiveContract'])->name('clientCase.contract.archive'); Route::post('client-cases', [ClientCaseContoller::class, 'store'])->name('clientCase.store'); // client-case / contract Route::post('client-cases/{client_case:uuid}/contract', [ClientCaseContoller::class, 'storeContract'])->name('clientCase.contract.store'); @@ -177,6 +179,12 @@ Route::get('settings/segments', [SegmentController::class, 'settings'])->name('settings.segments'); Route::post('settings/segments', [SegmentController::class, 'store'])->name('settings.segments.store'); Route::put('settings/segments/{segment}', [SegmentController::class, 'update'])->name('settings.segments.update'); + // settings / archive settings + Route::get('settings/archive', [ArchiveSettingController::class, 'index'])->name('settings.archive.index'); + Route::post('settings/archive', [ArchiveSettingController::class, 'store'])->name('settings.archive.store'); + Route::put('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'update'])->name('settings.archive.update'); + Route::post('settings/archive/{archiveSetting}/run', [ArchiveSettingController::class, 'run'])->name('settings.archive.run'); + Route::delete('settings/archive/{archiveSetting}', [ArchiveSettingController::class, 'destroy'])->name('settings.archive.destroy'); Route::get('settings/workflow', [WorkflowController::class, 'index'])->name('settings.workflow'); Route::get('settings/field-job', [FieldJobSettingController::class, 'index'])->name('settings.fieldjob.index'); diff --git a/tests/Feature/ArchiveContractAccountChainTest.php b/tests/Feature/ArchiveContractAccountChainTest.php new file mode 100644 index 0000000..e47f187 --- /dev/null +++ b/tests/Feature/ArchiveContractAccountChainTest.php @@ -0,0 +1,56 @@ +create(); + $contract = Contract::factory()->create([ + 'client_case_id' => $case->id, + 'active' => 1, + ]); + $accountTypeId = \DB::table('account_types')->insertGetId([ + 'name' => 'Type', + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + 'deleted_at' => null, + ]); + $account = Account::create([ + 'contract_id' => $contract->id, + 'type_id' => $accountTypeId, + 'active' => 1, + 'initial_amount' => 0, + 'balance_amount' => 0, + ]); + + ArchiveSetting::factory()->create([ + 'enabled' => true, + 'strategy' => 'manual', + 'soft' => true, + 'entities' => [ + ['table' => 'contracts', 'focus' => true], + ['table' => 'contracts.account'], + ], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid])) + ->assertRedirect(); + + $this->assertDatabaseHas('accounts', ['id' => $account->id, 'active' => 0]); + } +} diff --git a/tests/Feature/ArchiveContractChainedEntitiesTest.php b/tests/Feature/ArchiveContractChainedEntitiesTest.php new file mode 100644 index 0000000..8652a48 --- /dev/null +++ b/tests/Feature/ArchiveContractChainedEntitiesTest.php @@ -0,0 +1,84 @@ +create(); + $contract = Contract::factory()->create([ + 'client_case_id' => $case->id, + 'active' => 1, + ]); + // Create account tied to contract + // Minimal account type requirement + $accountTypeId = \DB::table('account_types')->insertGetId([ + 'name' => 'Test Type', + 'description' => 'Temp', + 'created_at' => now(), + 'updated_at' => now(), + 'deleted_at' => null, + ]); + $account = Account::create([ + 'contract_id' => $contract->id, + 'type_id' => $accountTypeId, + 'active' => 1, + 'initial_amount' => 0, + 'balance_amount' => 0, + ]); + + // Seed payments & bookings for that account + $payment = Payment::create([ + 'account_id' => $account->id, + 'amount_cents' => 10000, + 'currency' => 'EUR', + 'reference' => 'P-TEST', + 'paid_at' => now(), + 'meta' => json_encode([]), + ]); + $booking = Booking::create([ + 'account_id' => $account->id, + 'payment_id' => $payment->id, + 'amount_cents' => 10000, + 'type' => 'debit', + 'description' => 'Test Booking', + 'booked_at' => now(), + ]); + + ArchiveSetting::factory()->create([ + 'enabled' => true, + 'strategy' => 'manual', + 'soft' => true, + 'entities' => [ + ['table' => 'contracts', 'focus' => true], + ['table' => 'account.payments'], + ['table' => 'account.bookings'], + ], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid])) + ->assertRedirect(); + + // Refresh models + $payment->refresh(); + $booking->refresh(); + + $this->assertDatabaseHas('payments', ['id' => $payment->id, 'active' => 0]); + $this->assertDatabaseHas('bookings', ['id' => $booking->id, 'active' => 0]); + } +} diff --git a/tests/Feature/ArchiveContractSegmentTest.php b/tests/Feature/ArchiveContractSegmentTest.php new file mode 100644 index 0000000..4278fd4 --- /dev/null +++ b/tests/Feature/ArchiveContractSegmentTest.php @@ -0,0 +1,66 @@ +create(); + $contract = Contract::factory()->create([ + 'client_case_id' => $case->id, + 'active' => 1, + ]); + + $originalSegment = Segment::factory()->create(['active' => true]); + $archiveSegment = Segment::factory()->create(['active' => true]); + + DB::table('client_case_segment')->insert([ + 'client_case_id' => $case->id, + 'segment_id' => $originalSegment->id, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('contract_segment')->insert([ + 'contract_id' => $contract->id, + 'segment_id' => $originalSegment->id, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + ArchiveSetting::factory()->create([ + 'enabled' => true, + 'strategy' => 'manual', + 'segment_id' => $archiveSegment->id, + 'entities' => [ + ['table' => 'contracts', 'focus' => true], + ], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid])); + $response->assertRedirect(); + + $activePivots = DB::table('contract_segment') + ->where('contract_id', $contract->id) + ->where('active', true) + ->pluck('segment_id'); + + $this->assertTrue($activePivots->contains($archiveSegment->id)); + $this->assertFalse($activePivots->contains($originalSegment->id)); + } +} diff --git a/tests/Feature/ArchiveContractTest.php b/tests/Feature/ArchiveContractTest.php new file mode 100644 index 0000000..cd1e9eb --- /dev/null +++ b/tests/Feature/ArchiveContractTest.php @@ -0,0 +1,48 @@ +create(); + test()->actingAs($user); + + $case = ClientCase::factory()->create(['active' => 1]); + $typeId = DB::table('contract_types')->insertGetId([ + 'name' => 'Standard', + 'description' => 'Test', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $contract = $case->contracts()->create([ + 'uuid' => (string) Str::uuid(), + 'reference' => 'T-TEST', + 'start_date' => now()->toDateString(), + 'end_date' => null, + 'type_id' => $typeId, + 'active' => 1, + ]); + + ArchiveSetting::factory()->create([ + 'entities' => [ + ['table' => 'contracts', 'conditions' => ['where' => ['client_case_id' => $case->id]]], + ], + 'strategy' => 'immediate', + 'soft' => true, + 'enabled' => true, + ]); + + test()->post(route('clientCase.contract.archive', [$case->uuid, $contract->uuid])) + ->assertRedirect(); + + $contract->refresh(); + expect($contract->active)->toBe(0); +}); diff --git a/tests/Feature/ArchiveRunNowTest.php b/tests/Feature/ArchiveRunNowTest.php new file mode 100644 index 0000000..4bd3de3 --- /dev/null +++ b/tests/Feature/ArchiveRunNowTest.php @@ -0,0 +1,48 @@ +create(); + test()->actingAs($user); + + // Insert sample document older than 200 days (minimal required columns) + $docId = DB::table('documents')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'documentable_type' => 'App\\Models\\ClientCase', // generic + 'documentable_id' => 1, + 'name' => 'Old Doc', + 'file_name' => 'old.txt', + 'original_name' => 'old.txt', + 'disk' => 'public', + 'path' => 'documents/old.txt', + 'mime_type' => 'text/plain', + 'active' => 1, + 'created_at' => now()->subDays(210), + 'updated_at' => now()->subDays(210), + ]); + + $setting = ArchiveSetting::factory()->create([ + 'entities' => [[ + 'table' => 'documents', + 'conditions' => ['older_than_days' => 180], + ]], + 'enabled' => true, + 'strategy' => 'immediate', + ]); + + test()->post(route('settings.archive.run', $setting->id))->assertRedirect(); + + $setting->refresh(); + $docRow = DB::table('documents')->where('id', $docId)->first(); + expect(ArchiveRun::count())->toBe(1) + ->and($docRow->active)->toBe(0); +}); diff --git a/tests/Feature/ArchiveSettingCrudTest.php b/tests/Feature/ArchiveSettingCrudTest.php new file mode 100644 index 0000000..5149ac2 --- /dev/null +++ b/tests/Feature/ArchiveSettingCrudTest.php @@ -0,0 +1,61 @@ +create(); + test()->actingAs($user); + + $response = test()->post(route('settings.archive.store'), [ + 'entities' => [ + ['table' => 'documents', 'conditions' => ['older_than_days' => 30]], + ], + 'strategy' => 'immediate', + 'soft' => true, + 'enabled' => true, + ]); + + $response->assertRedirect(); + expect(ArchiveSetting::count())->toBe(1); +}); + +it('updates an archive setting', function () { + $user = User::factory()->create(); + test()->actingAs($user); + $setting = ArchiveSetting::factory()->create(); + + $response = test()->put(route('settings.archive.update', $setting->id), [ + 'entities' => $setting->entities, + 'strategy' => 'queued', + 'soft' => false, + 'enabled' => false, + ]); + + $response->assertRedirect(); + $setting->refresh(); + expect($setting->strategy)->toBe('queued') + ->and($setting->soft)->toBeFalse() + ->and($setting->enabled)->toBeFalse(); +}); + +it('deletes an archive setting', function () { + $user = User::factory()->create(); + test()->actingAs($user); + $setting = ArchiveSetting::factory()->create(); + + $response = test()->delete(route('settings.archive.destroy', $setting->id)); + $response->assertRedirect(); + expect(ArchiveSetting::withTrashed()->count())->toBe(1) // soft deleted + ->and(ArchiveSetting::count())->toBe(0); +}); diff --git a/tests/Feature/ReactivateContractTest.php b/tests/Feature/ReactivateContractTest.php new file mode 100644 index 0000000..2e8bffb --- /dev/null +++ b/tests/Feature/ReactivateContractTest.php @@ -0,0 +1,50 @@ +create(); + test()->actingAs($user); + + $case = ClientCase::factory()->create(['active' => 1]); + $typeId = DB::table('contract_types')->insertGetId([ + 'name' => 'Standard', + 'description' => 'Test', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $contract = $case->contracts()->create([ + 'uuid' => (string) Str::uuid(), + 'reference' => 'T-REACT', + 'start_date' => now()->toDateString(), + 'end_date' => null, + 'type_id' => $typeId, + 'active' => 0, // initially archived + 'deleted_at' => now(), // also soft deleted to test clearing + ]); + + ArchiveSetting::factory()->create([ + 'entities' => [ + ['table' => 'contracts', 'conditions' => ['where' => ['client_case_id' => $case->id]]], + ], + 'strategy' => 'immediate', + 'soft' => true, + 'enabled' => true, + 'reactivate' => true, + ]); + + test()->post(route('clientCase.contract.archive', ['client_case' => $case->uuid, 'uuid' => $contract->uuid])) + ->assertRedirect(); + + $contract->refresh(); + expect($contract->active)->toBe(1) + ->and($contract->deleted_at)->toBeNull(); +});