updates to UI and add archiving option
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ArchiveSetting;
|
||||
use App\Services\Archiving\ArchiveExecutor;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunArchive extends Command
|
||||
{
|
||||
protected $signature = 'archive:run {--setting=* : Specific archive_setting IDs to run}';
|
||||
|
||||
protected $description = 'Execute archive operations based on configured archive settings';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = collect($this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ArchiveSettingRequest;
|
||||
use App\Models\Action;
|
||||
use App\Models\ArchiveEntity;
|
||||
use App\Models\ArchiveSetting;
|
||||
use App\Models\Segment;
|
||||
use App\Services\Archiving\ArchiveExecutor;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ArchiveSettingController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$settings = ArchiveSetting::query()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ArchiveSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // TODO: add policy / gate later
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$chains = (array) config('archiving.chains', []);
|
||||
// Focus entity names (seeded focuses) allowed as primary tables
|
||||
$focuses = ['contracts', 'client_cases'];
|
||||
$allowed = array_unique(array_merge($focuses, $chains));
|
||||
|
||||
return [
|
||||
'action_id' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ArchiveEntity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'focus',
|
||||
'related',
|
||||
'name',
|
||||
'description',
|
||||
'enabled',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'related' => 'array',
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ArchiveRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'archive_setting_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'counts',
|
||||
'context',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'duration_ms',
|
||||
'message',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'counts' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ArchiveSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'action_id',
|
||||
'decision_id',
|
||||
'segment_id',
|
||||
'entities',
|
||||
'name',
|
||||
'description',
|
||||
'enabled',
|
||||
'strategy',
|
||||
'soft',
|
||||
'reactivate',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'entities' => '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');
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public function segments(): BelongsToMany
|
||||
public function account(): HasOne
|
||||
{
|
||||
return $this->hasOne(\App\Models\Account::class)
|
||||
->latestOfMany()
|
||||
->with('type');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ArchiveSetting;
|
||||
use App\Models\User;
|
||||
|
||||
class ArchiveSettingPolicy
|
||||
{
|
||||
protected function isAdmin(User $user): bool
|
||||
{
|
||||
// Placeholder: adjust to real permission system / role flag
|
||||
return (bool) ($user->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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user