From a596177a686eb2671c83f6635c2a8b9883e3d7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Tue, 16 Dec 2025 19:35:51 +0100 Subject: [PATCH] changes to import added activity entity --- .../Controllers/ImportEntityController.php | 7 + .../Controllers/ImportTemplateController.php | 70 ++- app/Services/ImportProcessor.php | 490 +++++++++++++++++- app/Services/ImportSimulationService.php | 99 +++- database/seeders/ImportEntitySeeder.php | 23 + database/seeders/ImportTemplateSeeder.php | 37 ++ resources/js/Pages/Imports/Import.vue | 72 +++ .../js/Pages/Imports/Templates/Create.vue | 65 ++- resources/js/Pages/Imports/Templates/Edit.vue | 155 ++++-- 9 files changed, 946 insertions(+), 72 deletions(-) diff --git a/app/Http/Controllers/ImportEntityController.php b/app/Http/Controllers/ImportEntityController.php index 0446bd5..ec9bd7c 100644 --- a/app/Http/Controllers/ImportEntityController.php +++ b/app/Http/Controllers/ImportEntityController.php @@ -58,6 +58,13 @@ public function index() 'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'], 'ui' => ['order' => 6], ], + [ + 'key' => 'activities', + 'canonical_root' => 'activity', + 'label' => 'Activities', + 'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'], + 'ui' => ['order' => 7], + ], ]); } else { // Ensure fields are arrays for frontend consumption diff --git a/app/Http/Controllers/ImportTemplateController.php b/app/Http/Controllers/ImportTemplateController.php index 3fde860..d1f7d90 100644 --- a/app/Http/Controllers/ImportTemplateController.php +++ b/app/Http/Controllers/ImportTemplateController.php @@ -111,10 +111,10 @@ public function store(Request $request) 'is_active' => 'boolean', 'reactivate' => 'boolean', 'entities' => 'nullable|array', - 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', + 'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities', 'mappings' => 'array', 'mappings.*.source_column' => 'required|string', - 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', + 'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities', 'mappings.*.target_field' => 'nullable|string', 'mappings.*.transform' => 'nullable|string|max:50', 'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both,keyref', @@ -124,7 +124,11 @@ public function store(Request $request) 'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id', + 'meta.activity_action_id' => 'nullable|integer|exists:actions,id', + 'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id', + 'meta.activity_created_at' => 'nullable|date', 'meta.payments_import' => 'nullable|boolean', + 'meta.history_import' => 'nullable|boolean', 'meta.contract_key_mode' => 'nullable|string|in:reference', ])->validate(); @@ -142,10 +146,38 @@ public function store(Request $request) $template = null; DB::transaction(function () use (&$template, $request, $data) { $paymentsImport = (bool) (data_get($data, 'meta.payments_import') ?? false); + $historyImport = (bool) (data_get($data, 'meta.history_import') ?? false); $entities = $data['entities'] ?? []; + if ($historyImport) { + $paymentsImport = false; // history import cannot be combined with payments mode + $allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases']; + $entities = array_values(array_intersect($entities, $allowedHistoryEntities)); + // If contracts are present, ensure accounts are included implicitly for reference consistency + if (in_array('contracts', $entities, true) && ! in_array('accounts', $entities, true)) { + $entities[] = 'accounts'; + } + // Reject mappings that target disallowed entities for history import + $disallowedMappings = collect($data['mappings'] ?? [])->filter(function ($m) use ($allowedHistoryEntities) { + if (empty($m['entity'])) { + return false; + } + + return ! in_array($m['entity'], $allowedHistoryEntities, true); + }); + if ($disallowedMappings->isNotEmpty()) { + abort(422, 'History import only allows entities: person, person_addresses, person_phones, contracts, activities, client_cases. Remove other mapping entities.'); + } + } if ($paymentsImport) { $entities = ['contracts', 'accounts', 'payments']; } + if (in_array('activities', $entities, true)) { + $actionId = data_get($data, 'meta.activity_action_id'); + $decisionId = data_get($data, 'meta.activity_decision_id'); + if (! $actionId || ! $decisionId) { + abort(422, 'Activities import requires selecting both a default action and decision.'); + } + } $template = ImportTemplate::create([ 'uuid' => (string) Str::uuid(), 'name' => $data['name'], @@ -162,7 +194,11 @@ public function store(Request $request) 'segment_id' => data_get($data, 'meta.segment_id'), 'decision_id' => data_get($data, 'meta.decision_id'), 'action_id' => data_get($data, 'meta.action_id'), + 'activity_action_id' => data_get($data, 'meta.activity_action_id'), + 'activity_decision_id' => data_get($data, 'meta.activity_decision_id'), + 'activity_created_at' => data_get($data, 'meta.activity_created_at'), 'payments_import' => $paymentsImport ?: null, + 'history_import' => $historyImport ?: null, 'contract_key_mode' => data_get($data, 'meta.contract_key_mode'), ], fn ($v) => ! is_null($v) && $v !== ''), ]); @@ -244,7 +280,7 @@ public function addMapping(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'source_column' => 'required|string', - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities', 'target_field' => 'nullable|string', 'transform' => 'nullable|string|in:trim,upper,lower', 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', @@ -314,7 +350,11 @@ public function update(Request $request, ImportTemplate $template) 'meta.segment_id' => 'nullable|integer|exists:segments,id', 'meta.decision_id' => 'nullable|integer|exists:decisions,id', 'meta.action_id' => 'nullable|integer|exists:actions,id', + 'meta.activity_action_id' => 'nullable|integer|exists:actions,id', + 'meta.activity_decision_id' => 'nullable|integer|exists:decisions,id', + 'meta.activity_created_at' => 'nullable|date', 'meta.payments_import' => 'nullable|boolean', + 'meta.history_import' => 'nullable|boolean', 'meta.contract_key_mode' => 'nullable|string|in:reference', ])->validate(); @@ -342,6 +382,11 @@ public function update(Request $request, ImportTemplate $template) unset($newMeta[$k]); } } + foreach (['activity_action_id', 'activity_decision_id', 'activity_created_at'] as $k) { + if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) { + unset($newMeta[$k]); + } + } } // Finalize meta (ensure payments entities forced if enabled) @@ -349,6 +394,20 @@ public function update(Request $request, ImportTemplate $template) if (! empty($finalMeta['payments_import'])) { $finalMeta['entities'] = ['contracts', 'accounts', 'payments']; } + if (! empty($finalMeta['history_import'])) { + $finalMeta['payments_import'] = false; + $allowedHistoryEntities = ['person', 'person_addresses', 'person_phones', 'contracts', 'activities', 'client_cases']; + $finalMeta['entities'] = array_values(array_intersect($finalMeta['entities'] ?? [], $allowedHistoryEntities)); + if (in_array('contracts', $finalMeta['entities'] ?? [], true) && ! in_array('accounts', $finalMeta['entities'] ?? [], true)) { + $finalMeta['entities'][] = 'accounts'; + } + } + + if (in_array('activities', $finalMeta['entities'] ?? [], true)) { + if (empty($finalMeta['activity_action_id']) || empty($finalMeta['activity_decision_id'])) { + return back()->withErrors(['meta.activity_action_id' => 'Activities import requires selecting both a default action and decision.'])->withInput(); + } + } $update = [ 'name' => $data['name'], @@ -381,7 +440,7 @@ public function bulkAddMappings(Request $request, ImportTemplate $template) } $data = validator($raw, [ 'sources' => 'required|string', // comma and/or newline separated - 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments', + 'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases,case_objects,payments,activities', 'default_field' => 'nullable|string', // if provided, used as the field name for all entries 'apply_mode' => 'nullable|string|in:insert,update,both,keyref', 'transform' => 'nullable|string|in:trim,upper,lower', @@ -583,6 +642,9 @@ public function applyToImport(Request $request, ImportTemplate $template, Import 'segment_id' => $tplMeta['segment_id'] ?? null, 'decision_id' => $tplMeta['decision_id'] ?? null, 'action_id' => $tplMeta['action_id'] ?? null, + 'activity_action_id' => $tplMeta['activity_action_id'] ?? null, + 'activity_decision_id' => $tplMeta['activity_decision_id'] ?? null, + 'activity_created_at' => $tplMeta['activity_created_at'] ?? null, 'template_name' => $template->name, ], fn ($v) => ! is_null($v) && $v !== '')); diff --git a/app/Services/ImportProcessor.php b/app/Services/ImportProcessor.php index cc5dde8..6c6bc24 100644 --- a/app/Services/ImportProcessor.php +++ b/app/Services/ImportProcessor.php @@ -25,11 +25,18 @@ use App\Models\Person\PersonType; use App\Models\Person\PhoneType; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; class ImportProcessor { + /** + * Track contracts that already existed and were matched during history imports. + * @var array + */ + private array $historyFoundContractIds = []; + /** * Process an import and apply basic dedup checks. * Returns summary counts. @@ -42,6 +49,7 @@ public function process(Import $import, ?Authenticatable $user = null): array $imported = 0; $invalid = 0; $fh = null; + $this->historyFoundContractIds = []; // Only CSV/TSV supported in this pass if (! in_array($import->source_type, ['csv', 'txt'])) { @@ -73,6 +81,7 @@ public function process(Import $import, ?Authenticatable $user = null): array // Template meta flags $tplMeta = optional($import->template)->meta ?? []; $paymentsImport = (bool) ($tplMeta['payments_import'] ?? false); + $historyImport = (bool) ($tplMeta['history_import'] ?? false); $contractKeyMode = $tplMeta['contract_key_mode'] ?? null; // Prefer explicitly chosen delimiter, then template meta, else detected $delimiter = $import->meta['forced_delimiter'] @@ -299,34 +308,51 @@ public function process(Import $import, ?Authenticatable $user = null): array $contractResult = null; } } else { - $contractResult = $this->upsertContractChain($import, $mapped, $mappings); + $contractResult = $this->upsertContractChain($import, $mapped, $mappings, $historyImport); // If contract was resolved/updated/inserted and reactivation requested but not needed (already active), we just continue normal flow. if ($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. + // Do not attempt reactivation on freshly inserted contracts + if (($contractResult['action'] ?? null) === 'inserted') { + // Newly created contracts are already active; skip reactivation path + } else { + $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') { + if ($contractResult['action'] === 'skipped_history') { + // History import: keep existing contract for downstream relations but do not update or attach segments/actions + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_skipped', + 'level' => 'info', + 'message' => $contractResult['message'] ?? 'Existing contract reused (history import).', + ]); + } elseif ($contractResult['action'] === 'skipped') { // Even if no contract fields were updated, we may still need to apply template meta // like attaching a segment or creating an activity. Do that if we have the contract. if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { @@ -437,12 +463,18 @@ public function process(Import $import, ?Authenticatable $user = null): array // Accounts $accountResult = null; + if ($historyImport && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract && ! isset($mapped['account'])) { + $autoAcc = $this->ensureHistoryAccount($contractResult['contract'], $mapped); + if ($autoAcc) { + $accountResult = ['action' => 'inserted', 'account' => $autoAcc, 'contract' => $contractResult['contract'], 'contract_id' => $contractResult['contract']->id]; + } + } if (isset($mapped['account'])) { // If a contract was just created or resolved above, pass its id to account mapping for this row if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { $mapped['account']['contract_id'] = $contractResult['contract']->id; } - $accountResult = $this->upsertAccount($import, $mapped, $mappings); + $accountResult = $this->upsertAccount($import, $mapped, $mappings, $historyImport); if ($accountResult['action'] === 'skipped') { $skipped++; $importRow->update(['status' => 'skipped']); @@ -555,6 +587,55 @@ public function process(Import $import, ?Authenticatable $user = null): array } } + // Activities: create or update activities linked to contracts/cases + if (isset($mapped['activity'])) { + $activityResult = $this->upsertActivity($import, $mapped, $mappings, $contractResult ?? null, $accountResult ?? null); + if ($activityResult['action'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_skipped', + 'level' => 'info', + 'message' => $activityResult['message'] ?? 'Skipped (no changes).', + 'context' => $activityResult['context'] ?? null, + ]); + } elseif (in_array($activityResult['action'], ['inserted', 'updated'], true)) { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => Activity::class, + 'entity_id' => $activityResult['activity']->id, + ]); + $activityFieldsStr = ''; + if (! empty($activityResult['applied_fields'] ?? [])) { + $activityFieldsStr = $this->formatAppliedFieldMessage('activity', $activityResult['applied_fields']); + } + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_imported', + 'level' => 'info', + 'message' => ucfirst($activityResult['action']).' activity'.($activityFieldsStr ? ' '.$activityFieldsStr : ''), + 'context' => ['id' => $activityResult['activity']->id, 'fields' => $activityResult['applied_fields'] ?? []], + ]); + } else { + $invalid++; + $importRow->update(['status' => 'invalid', 'errors' => [$activityResult['message'] ?? 'Activity processing failed']]); + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'import_row_id' => $importRow->id, + 'event' => 'row_invalid', + 'level' => 'error', + 'message' => $activityResult['message'] ?? 'Activity processing failed', + ]); + } + } + // Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers $personIdForRow = null; // Prefer person from contract created/updated above @@ -1060,6 +1141,29 @@ public function process(Import $import, ?Authenticatable $user = null): array ]); } + $meta = $import->meta ?? []; + if ($historyImport) { + if (! empty($this->historyFoundContractIds)) { + $found = Contract::query() + ->with(['clientCase.person']) + ->whereIn('id', array_keys($this->historyFoundContractIds)) + ->get() + ->map(function (Contract $c) { + return [ + 'contract_uuid' => $c->uuid ?? null, + 'reference' => $c->reference, + 'case_uuid' => $c->clientCase?->uuid, + 'full_name' => $c->clientCase?->person?->full_name, + ]; + }) + ->values() + ->all(); + $meta['history_found_contracts'] = $found; + } else { + $meta['history_found_contracts'] = []; + } + } + $import->update([ 'status' => 'completed', 'finished_at' => now(), @@ -1067,6 +1171,7 @@ public function process(Import $import, ?Authenticatable $user = null): array 'imported_rows' => $imported, 'invalid_rows' => $invalid, 'valid_rows' => $total - $invalid, + 'meta' => $meta, ]); DB::commit(); @@ -1119,6 +1224,21 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple) { $recordType = null; $mapped = []; + $appendValue = function ($existing, $label, $value) { + // Skip empty new values + if (is_null($value) || (is_string($value) && trim($value) === '')) { + return $existing; + } + $stringVal = is_scalar($value) ? (string) $value : json_encode($value); + $existingStr = is_null($existing) ? '' : (is_scalar($existing) ? (string) $existing : json_encode($existing)); + if ($existingStr === '') { + return $label.': '.$stringVal; + } + + return $existingStr.', '.$label.': '.$stringVal; + }; + $fieldFirstLabel = []; + $rootFirstLabel = []; foreach ($mappings as $map) { $src = $map->source_column; $target = $map->target_field; @@ -1278,9 +1398,34 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple) if (! isset($mapped[$root]) || ! is_array($mapped[$root])) { $mapped[$root] = []; } - $mapped[$root][$field] = $value; + $canConcat = $this->fieldAllowsConcatenation($root, $field); + if ($canConcat) { + $key = $root.'.'.$field; + if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') { + $mapped[$root][$field] = $value; + $fieldFirstLabel[$key] = (string) $src; + } else { + $existing = $mapped[$root][$field]; + $firstLabel = $fieldFirstLabel[$key] ?? null; + $existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing)); + $mapped[$root][$field] = $appendValue($existingStr, (string) $src, $value); + } + } else { + // For typed fields (dates/numbers), keep the first non-empty value to avoid coercion errors + if (! array_key_exists($field, $mapped[$root]) || is_null($mapped[$root][$field]) || $mapped[$root][$field] === '') { + $mapped[$root][$field] = $value; + } + } } else { - $mapped[$root] = $value; + if (! array_key_exists($root, $mapped) || is_null($mapped[$root]) || $mapped[$root] === '') { + $mapped[$root] = $value; + $rootFirstLabel[$root] = (string) $src; + } else { + $firstLabel = $rootFirstLabel[$root] ?? null; + $existing = $mapped[$root]; + $existingStr = $firstLabel ? ($firstLabel.': '.(is_scalar($existing) ? (string) $existing : json_encode($existing))) : (is_scalar($existing) ? (string) $existing : json_encode($existing)); + $mapped[$root] = $appendValue($existingStr, (string) $src, $value); + } } } } @@ -1288,6 +1433,28 @@ protected function applyMappings(array $raw, $mappings, array $supportsMultiple) return [$recordType, $mapped]; } + /** + * Decide whether multiple mapped source columns should be concatenated for a given target field. + * For date/time and numeric-like fields we avoid concatenation to prevent invalid type coercion. + */ + private function fieldAllowsConcatenation(?string $root, ?string $field): bool + { + if ($field === null) { + return true; + } + $f = strtolower($field); + // Date / datetime indicators + if ($f === 'birthday' || str_contains($f, 'date') || str_ends_with($f, '_at')) { + return false; + } + // Common numeric fields + if (in_array($f, ['amount', 'amount_cents', 'quantity', 'balance_amount'], true)) { + return false; + } + + return true; + } + private function arraySetDot(array &$arr, string $path, $value): void { $keys = explode('.', $path); @@ -1301,7 +1468,7 @@ private function arraySetDot(array &$arr, string $path, $value): void $ref = $value; } - private function upsertAccount(Import $import, array $mapped, $mappings): array + private function upsertAccount(Import $import, array $mapped, $mappings, bool $historyImport = false): array { $clientId = $import->client_id; // may be null, used for contract lookup/creation $acc = $mapped['account'] ?? []; @@ -1447,6 +1614,10 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array } } + if ($historyImport && $existing) { + return ['action' => 'skipped', 'account' => $existing, 'contract_id' => $contractId, 'message' => 'History import does not update accounts']; + } + if ($existing) { // Build non-null changes for account fields $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); @@ -1505,6 +1676,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array && ($initMode === null || in_array($initMode, ['insert', 'both'], true))) { $applyInsert['initial_amount'] = $applyInsert['balance_amount']; } + if ($historyImport) { + // Force zero amounts for history imports regardless of mapped amounts + $applyInsert['balance_amount'] = 0; + $applyInsert['initial_amount'] = 0; + } if (empty($applyInsert)) { return ['action' => 'skipped', 'message' => 'No fields marked for insert']; } @@ -1516,12 +1692,44 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array if (! array_key_exists('active', $data)) { $data['active'] = 1; } + if ($historyImport) { + $data['balance_amount'] = 0; + $data['initial_amount'] = 0; + } $created = Account::create($data); return ['action' => 'inserted', 'account' => $created, 'contract_id' => $contractId, 'applied_fields' => $data]; } } + private function ensureHistoryAccount(Contract $contract, array $mapped): ?Account + { + $existing = Account::query() + ->where('contract_id', $contract->id) + ->where('active', 1) + ->first(); + if ($existing) { + return $existing; + } + + $reference = $mapped['account']['reference'] ?? $mapped['contract']['reference'] ?? null; + if (is_string($reference)) { + $reference = preg_replace('/\s+/', '', trim($reference)); + } + if (! $reference || $reference === '') { + $reference = 'HIST-'.$contract->id; + } + + return Account::create([ + 'contract_id' => $contract->id, + 'reference' => $reference, + 'type_id' => $this->getDefaultAccountTypeId(), + 'active' => 1, + 'balance_amount' => 0, + 'initial_amount' => 0, + ]); + } + private function upsertCaseObject(Import $import, array $mapped, $mappings, int $contractId): array { // Support both 'case_object' and 'case_objects' keys (template may use plural) @@ -1625,6 +1833,208 @@ private function upsertCaseObject(Import $import, array $mapped, $mappings, int } } + private function upsertActivity(Import $import, array $mapped, $mappings, ?array $contractResult, ?array $accountResult): array + { + $activity = $mapped['activity'] ?? []; + + // Default contract/client_case from freshly created or updated contract when present + if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $activity['contract_id'] = $activity['contract_id'] ?? $contractResult['contract']->id; + $activity['client_case_id'] = $activity['client_case_id'] ?? $contractResult['contract']->client_case_id; + } + + $contractId = $activity['contract_id'] ?? null; + if (! $contractId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $contractId = $contractResult['contract']->id; + } elseif (! $contractId && $accountResult && isset($accountResult['contract_id'])) { + $contractId = $accountResult['contract_id']; + } elseif (! $contractId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) { + $contractId = $accountResult['contract']->id; + } + if (! $contractId && $import->client_id && ! empty($mapped['contract']['reference'] ?? null)) { + $ref = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference'])); + if ($ref !== '') { + $contractId = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $import->client_id) + ->where('contracts.reference', $ref) + ->value('contracts.id'); + } + } + + $clientCaseId = $activity['client_case_id'] ?? null; + if (! $clientCaseId && $contractId) { + $clientCaseId = Contract::where('id', $contractId)->value('client_case_id'); + } + if (! $clientCaseId && $contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) { + $clientCaseId = $contractResult['contract']->client_case_id; + } + if (! $clientCaseId && $accountResult && isset($accountResult['contract']) && $accountResult['contract'] instanceof Contract) { + $clientCaseId = $accountResult['contract']->client_case_id; + } + if (! $clientCaseId && $accountResult && isset($accountResult['contract_id'])) { + $clientCaseId = Contract::where('id', $accountResult['contract_id'])->value('client_case_id'); + } + if (! $clientCaseId && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) { + $clientCaseId = ClientCase::where('client_id', $import->client_id) + ->where('client_ref', $mapped['client_case']['client_ref']) + ->value('id'); + } + + if (! $clientCaseId) { + return [ + 'action' => 'invalid', + 'message' => 'Activity requires a client_case_id or resolvable contract.', + 'context' => [ + 'contract_id' => $contractId, + 'contract_reference' => $mapped['contract']['reference'] ?? null, + ], + ]; + } + + // Collect apply_mode settings for activity fields + $applyModeByField = []; + foreach ($mappings as $map) { + $target = (string) ($map->target_field ?? ''); + if (! str_starts_with($target, 'activity.')) { + continue; + } + $field = substr($target, strlen('activity.')); + $applyModeByField[$field] = (string) ($map->apply_mode ?? 'both'); + } + + $applyInsert = []; + $applyUpdate = []; + foreach ($activity as $field => $value) { + $applyMode = $applyModeByField[$field] ?? 'both'; + $normalized = $value; + if ($field === 'due_date') { + $normalized = is_scalar($value) ? $this->normalizeDate((string) $value) : null; + } elseif ($field === 'created_at') { + $normalized = is_scalar($value) ? $this->normalizeDateTime((string) $value) : null; + } elseif ($field === 'amount') { + if (is_string($value)) { + $normalized = $this->normalizeDecimal($value); + } + $normalized = is_null($normalized) ? null : (float) $normalized; + } elseif (in_array($field, ['action_id', 'decision_id', 'user_id'], true)) { + $normalized = is_null($value) ? null : (int) $value; + } elseif (is_string($normalized)) { + $normalized = trim($normalized); + } + if (in_array($applyMode, ['both', 'insert'], true)) { + $applyInsert[$field] = $normalized; + } + if (in_array($applyMode, ['both', 'update'], true)) { + $applyUpdate[$field] = $normalized; + } + } + + $settings = \App\Models\PaymentSetting::query()->first(); + $tplMeta = optional($import->template)->meta ?? []; + $defaultActionId = $applyInsert['action_id'] + ?? $applyUpdate['action_id'] + ?? ($import->meta['activity_action_id'] ?? null) + ?? ($tplMeta['activity_action_id'] ?? null) + ?? ($import->meta['action_id'] ?? null) + ?? ($tplMeta['action_id'] ?? null) + ?? ($settings?->default_action_id ?? null); + $defaultDecisionId = $applyInsert['decision_id'] + ?? $applyUpdate['decision_id'] + ?? ($import->meta['activity_decision_id'] ?? null) + ?? ($tplMeta['activity_decision_id'] ?? null) + ?? ($import->meta['decision_id'] ?? null) + ?? ($tplMeta['decision_id'] ?? null); + + if (! $defaultActionId) { + return [ + 'action' => 'invalid', + 'message' => 'Activity requires action_id (provide via mapping or import meta).', + ]; + } + + $applyInsert['action_id'] = $applyInsert['action_id'] ?? $defaultActionId; + $applyUpdate['action_id'] = $applyUpdate['action_id'] ?? $defaultActionId; + if ($defaultDecisionId) { + $applyInsert['decision_id'] = $applyInsert['decision_id'] ?? $defaultDecisionId; + $applyUpdate['decision_id'] = $applyUpdate['decision_id'] ?? $defaultDecisionId; + } + + // Default created_at for inserted activities (from mapped field or template/import meta) + if (empty($applyInsert['created_at'])) { + $defaultCreated = $this->normalizeDateTime($applyInsert['created_at'] ?? $import->meta['activity_created_at'] ?? $tplMeta['activity_created_at'] ?? null); + if ($defaultCreated) { + $applyInsert['created_at'] = $defaultCreated; + if (empty($applyInsert['updated_at'])) { + $applyInsert['updated_at'] = $defaultCreated; + } + } + } + + $applyInsert['client_case_id'] = $clientCaseId; + $applyUpdate['client_case_id'] = $applyUpdate['client_case_id'] ?? $clientCaseId; + if ($contractId) { + $applyInsert['contract_id'] = $contractId; + $applyUpdate['contract_id'] = $applyUpdate['contract_id'] ?? $contractId; + } + + // Idempotency: if note + due_date + contract match, treat as existing + $existing = null; + $noteKey = $applyInsert['note'] ?? null; + $dueKey = $applyInsert['due_date'] ?? null; + if ($contractId && $noteKey && $dueKey) { + $existing = Activity::query() + ->where('contract_id', $contractId) + ->where('note', $noteKey) + ->whereDate('due_date', $dueKey) + ->first(); + } elseif ($clientCaseId && $noteKey && $dueKey) { + $existing = Activity::query() + ->where('client_case_id', $clientCaseId) + ->where('note', $noteKey) + ->whereDate('due_date', $dueKey) + ->first(); + } + + if ($existing) { + $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); + if (! empty($changes)) { + $existing->fill($changes); + $existing->save(); + + return ['action' => 'updated', 'activity' => $existing, 'applied_fields' => $changes]; + } + + return ['action' => 'skipped', 'activity' => $existing, 'message' => 'No changes needed']; + } + + $data = array_filter($applyInsert, fn ($v) => ! is_null($v)); + $activityModel = new Activity(); + $activityModel->forceFill($data); + if (array_key_exists('created_at', $data)) { + // Preserve provided timestamps by disabling automatic timestamps for this save + $activityModel->timestamps = false; + $activityModel->save(); + $activityModel->timestamps = true; + } else { + $activityModel->save(); + } + + return ['action' => 'inserted', 'activity' => $activityModel, 'applied_fields' => $data]; + } + + private function normalizeDateTime(?string $raw): ?string + { + if ($raw === null || trim($raw) === '') { + return null; + } + try { + return Carbon::parse($raw)->format('Y-m-d H:i:s'); + } catch (\Throwable $e) { + return null; + } + } + private function mappingsContainRoot($mappings, string $root): bool { foreach ($mappings as $map) { @@ -1657,7 +2067,7 @@ private function findPersonIdByIdentifiers(array $p): ?int return null; } - private function upsertContractChain(Import $import, array $mapped, $mappings): array + private function upsertContractChain(Import $import, array $mapped, $mappings, bool $historyImport = false): array { $contractData = $mapped['contract'] ?? []; $reference = $contractData['reference'] ?? null; @@ -1821,6 +2231,10 @@ private function upsertContractChain(Import $import, array $mapped, $mappings): } if ($existing) { + if ($historyImport) { + $this->historyFoundContractIds[$existing->id] = true; + return ['action' => 'skipped_history', 'contract' => $existing, 'message' => 'Existing contract left unchanged (history import)']; + } // 1) Prepare contract field changes (non-null) $changes = array_filter($applyUpdate, fn ($v) => ! is_null($v)); @@ -2529,6 +2943,12 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array if ($value === '') { return ['action' => 'skipped', 'message' => 'No email value']; } + $normalizedEmail = filter_var($value, FILTER_VALIDATE_EMAIL); + if (! $normalizedEmail) { + return ['action' => 'skipped', 'message' => 'Invalid email format']; + } + $value = $normalizedEmail; + $emailData['value'] = $normalizedEmail; $existing = Email::where('person_id', $personId)->where('value', $value)->first(); $applyInsert = []; $applyUpdate = []; @@ -2580,9 +3000,19 @@ private function upsertEmail(int $personId, array $emailData, $mappings): array private function upsertAddress(int $personId, array $addrData, $mappings): array { $addressLine = trim((string) ($addrData['address'] ?? '')); - if ($addressLine === '') { + // Normalize whitespace + $addressLine = preg_replace('/\s+/', ' ', $addressLine); + // Skip common placeholders or missing values + if ($addressLine === '' || $addressLine === '0' || strcasecmp($addressLine, '#N/A') === 0 || preg_match('/^(#?n\/?a|na|null|none)$/i', $addressLine)) { return ['action' => 'skipped', 'message' => 'No address value']; } + if (mb_strlen($addressLine) < 3) { + return ['action' => 'skipped', 'message' => 'Invalid address value']; + } + // Allow only basic address characters to avoid noisy special chars + if (! preg_match('/^[A-Za-z0-9\\s\\.,\\-\\/\\#\\\'"\\(\\)&]+$/', $addressLine)) { + return ['action' => 'skipped', 'message' => 'Invalid address value']; + } // Default country SLO if not provided if (! isset($addrData['country']) || $addrData['country'] === null || $addrData['country'] === '') { $addrData['country'] = 'SLO'; @@ -2644,6 +3074,10 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array if ($nu === '') { return ['action' => 'skipped', 'message' => 'No phone value']; } + if (! preg_match('/^[0-9]{6,20}$/', $nu)) { + return ['action' => 'skipped', 'message' => 'Invalid phone value']; + } + $phoneData['nu'] = $nu; // Find existing phone by normalized number (strip non-numeric from DB values too) $existing = PersonPhone::where('person_id', $personId) diff --git a/app/Services/ImportSimulationService.php b/app/Services/ImportSimulationService.php index 4e0510c..56e7edf 100644 --- a/app/Services/ImportSimulationService.php +++ b/app/Services/ImportSimulationService.php @@ -25,6 +25,11 @@ class ImportSimulationService */ private ?int $clientId = null; + /** + * History import mode flag (from template meta). + */ + private bool $historyImport = false; + /** * Public entry: simulate import applying mappings to first $limit rows. * Keeps existing machine keys for backward compatibility, but adds Slovenian @@ -79,6 +84,7 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false $simRows = []; // Determine keyref behavior for contract.reference from mappings/template $tplMeta = optional($import->template)->meta ?? []; + $this->historyImport = (bool) ($tplMeta['history_import'] ?? false); $contractKeyModeTpl = $tplMeta['contract_key_mode'] ?? null; // e.g. 'reference' $contractRefMode = $this->mappingModeForImport($import, 'contract.reference'); // e.g. 'keyref' foreach ($rows as $idx => $rawValues) { @@ -489,6 +495,38 @@ public function simulate(Import $import, int $limit = 100, bool $verbose = false } } + // History import: auto-ensure account placeholder when contract exists but no account mapping + if ($this->historyImport && $existingContract && isset($rowEntities['contract']['id']) && ! isset($rowEntities['account'])) { + if (! isset($summaries['account'])) { + $summaries['account'] = [ + 'root' => 'account', + 'total_rows' => 0, + 'create' => 0, + 'update' => 0, + 'missing_ref' => 0, + 'invalid' => 0, + 'duplicate' => 0, + 'duplicate_db' => 0, + ]; + } + $summaries['account']['total_rows']++; + $summaries['account']['update']++; + $ref = $rowEntities['contract']['reference'] ?? null; + if ($ref === null || $ref === '') { + $ref = 'HIST-'.$rowEntities['contract']['id']; + } + $rowEntities['account'] = [ + 'reference' => $ref, + 'exists' => true, + 'id' => null, + 'balance_before' => 0, + 'balance_after' => 0, + 'action' => 'implicit_history', + 'action_label' => $translatedActions['implicit'] ?? 'posredno', + 'history_zeroed' => true, + ]; + } + // Payment (affects account balance; may create implicit account) if (isset($entityRoots['payment'])) { // Inject inferred account if none mapped explicitly @@ -891,7 +929,7 @@ private function simulateContract(callable $val, array $summaries, array $cache, 'client_case_id' => $contract?->client_case_id, 'active' => $contract?->active, 'deleted_at' => $contract?->deleted_at, - 'action' => $contract ? 'update' : ($reference ? 'create' : 'skip'), + 'action' => $contract ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'), ]; $summaries['contract']['total_rows']++; if (! $reference) { @@ -902,6 +940,11 @@ private function simulateContract(callable $val, array $summaries, array $cache, $summaries['contract']['create']++; } + if ($this->historyImport && $contract) { + $entity['history_reuse'] = true; + $entity['message'] = 'Existing contract reused (history import)'; + } + return [$entity, $summaries, $cache]; } @@ -931,7 +974,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache, 'exists' => (bool) $account, 'balance_before' => $account?->balance_amount, 'balance_after' => $account?->balance_amount, - 'action' => $account ? 'update' : ($reference ? 'create' : 'skip'), + 'action' => $account ? ($this->historyImport ? 'skipped_history' : 'update') : ($reference ? 'create' : 'skip'), ]; // Direct balance override support. @@ -940,7 +983,7 @@ private function simulateAccount(callable $val, array $summaries, array $cache, $rawIncoming = $val('account.balance_amount') ?? $val('accounts.balance_amount') ?? $val('account.balance'); - if ($rawIncoming !== null && $rawIncoming !== '') { + if (! $this->historyImport && $rawIncoming !== null && $rawIncoming !== '') { $rawStr = (string) $rawIncoming; // Remove currency symbols and non numeric punctuation except , . - $clean = preg_replace('/[^0-9,\.\-]+/', '', $rawStr) ?? ''; @@ -974,6 +1017,19 @@ private function simulateAccount(callable $val, array $summaries, array $cache, $summaries['account']['create']++; } + if ($this->historyImport) { + // History imports keep balances unchanged and do not update accounts + $entity['balance_after'] = $account?->balance_amount ?? 0; + $entity['balance_before'] = $account?->balance_amount ?? 0; + if ($account) { + $entity['message'] = 'Existing account left unchanged (history import)'; + } else { + $entity['balance_after'] = 0; + $entity['balance_before'] = 0; + $entity['history_zeroed'] = true; + } + } + return [$entity, $summaries, $cache]; } @@ -1210,6 +1266,10 @@ private function simulateGenericRoot( $reference = $val('phone.nu'); } elseif ($root === 'email') { $reference = $val('email.value'); + } elseif ($root === 'activity') { + $noteRef = $val('activity.note'); + $dueRef = $val('activity.due_date'); + $reference = $noteRef || $dueRef ? trim((string) ($dueRef ?? '')).($noteRef ? ' | '.$noteRef : '') : null; } } @@ -1253,6 +1313,13 @@ private function simulateGenericRoot( $entity['description'] = $val('case_object.description') ?? null; $entity['type'] = $val('case_object.type') ?? null; break; + case 'activity': + $entity['note'] = $val('activity.note') ?? null; + $entity['due_date'] = $val('activity.due_date') ?? null; + $entity['amount'] = $val('activity.amount') ?? null; + $entity['action_id'] = $val('activity.action_id') ?? null; + $entity['decision_id'] = $val('activity.decision_id') ?? null; + break; } if ($verbose) { @@ -1367,6 +1434,16 @@ private function genericIdentityCandidates(string $root, callable $val): array $ids[] = 'name:'.mb_strtolower(trim((string) $name)); } + return $ids; + case 'activity': + $note = $val('activity.note'); + $due = $val('activity.due_date'); + $contractRef = $val('contract.reference'); + $ids = []; + if ($note || $due) { + $ids[] = 'activity:'.mb_strtolower(trim((string) ($note ?? ''))).'|'.mb_strtolower(trim((string) ($due ?? ''))).'|'.mb_strtolower(trim((string) ($contractRef ?? ''))); + } + return $ids; default: return []; @@ -1426,6 +1503,20 @@ private function loadExistingGenericIdentities(string $root): array } } break; + case 'activity': + foreach (\App\Models\Activity::query()->get(['note', 'due_date', 'contract_id', 'client_case_id']) as $rec) { + $note = mb_strtolower(trim((string) ($rec->note ?? ''))); + $due = $rec->due_date ? mb_strtolower(trim((string) $rec->due_date)) : ''; + $contractRef = null; + if ($rec->contract_id) { + $contractRef = Contract::where('id', $rec->contract_id)->value('reference'); + } + $key = 'activity:'.$note.'|'.$due.'|'.mb_strtolower(trim((string) ($contractRef ?? ''))); + if (trim($key, 'activity:|') !== '') { + $set[$key] = true; + } + } + break; } } catch (\Throwable) { // swallow and return what we have @@ -1730,6 +1821,8 @@ private function actionTranslations(): array 'skip' => 'preskoči', 'implicit' => 'posredno', 'reactivate' => 'reaktiviraj', + 'skipped_history' => 'preskoči (zgodovina)', + 'implicit_history' => 'posredno (zgodovina)', ]; } diff --git a/database/seeders/ImportEntitySeeder.php b/database/seeders/ImportEntitySeeder.php index cda9f36..2ec1be7 100644 --- a/database/seeders/ImportEntitySeeder.php +++ b/database/seeders/ImportEntitySeeder.php @@ -175,6 +175,29 @@ public function run(): void ], 'ui' => ['order' => 9], ], + [ + 'key' => 'activities', + 'canonical_root' => 'activity', + 'label' => 'Activities', + 'fields' => ['note', 'due_date', 'amount', 'action_id', 'decision_id', 'contract_id', 'client_case_id', 'user_id'], + 'field_aliases' => [ + 'opis' => 'note', + 'datum' => 'due_date', + 'rok' => 'due_date', + 'znesek' => 'amount', + ], + 'aliases' => ['activity', 'activities', 'opravilo', 'opravila'], + 'rules' => [ + ['pattern' => '/^(aktivnost|activity|note|opis)\b/i', 'field' => 'note'], + ['pattern' => '/^(rok|due|datum|date)\b/i', 'field' => 'due_date'], + ['pattern' => '/^(znesek|amount|vrednost|value)\b/i', 'field' => 'amount'], + ['pattern' => '/^(akcija|action)\b/i', 'field' => 'action_id'], + ['pattern' => '/^(odlocitev|odločitev|decision)\b/i', 'field' => 'decision_id'], + ['pattern' => '/^(pogodba|contract)\b/i', 'field' => 'contract_id'], + ['pattern' => '/^(primer|case)\b/i', 'field' => 'client_case_id'], + ], + 'ui' => ['order' => 10], + ], ]; foreach ($defs as $d) { diff --git a/database/seeders/ImportTemplateSeeder.php b/database/seeders/ImportTemplateSeeder.php index 90db8bc..2c92f85 100644 --- a/database/seeders/ImportTemplateSeeder.php +++ b/database/seeders/ImportTemplateSeeder.php @@ -155,5 +155,42 @@ public function run(): void 'options' => $map['options'] ?? null, ]); } + + // Activities linked to contracts demo + $activities = ImportTemplate::query()->firstOrCreate([ + 'name' => 'Activities CSV (contract linked)', + ], [ + 'uuid' => (string) Str::uuid(), + 'description' => 'Activities import linked to existing contracts via reference.', + 'source_type' => 'csv', + 'default_record_type' => 'activity', + 'sample_headers' => ['contract_reference', 'note', 'due_date', 'amount', 'action', 'decision', 'user_email'], + 'is_active' => true, + 'meta' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + ], + ]); + + $activityMappings = [ + ['source_column' => 'contract_reference', 'target_field' => 'contract.reference', 'position' => 1], + ['source_column' => 'note', 'target_field' => 'activity.note', 'position' => 2], + ['source_column' => 'due_date', 'target_field' => 'activity.due_date', 'position' => 3], + ['source_column' => 'amount', 'target_field' => 'activity.amount', 'position' => 4], + ['source_column' => 'action', 'target_field' => 'activity.action_id', 'position' => 5], + ['source_column' => 'decision', 'target_field' => 'activity.decision_id', 'position' => 6], + ]; + + foreach ($activityMappings as $map) { + ImportTemplateMapping::firstOrCreate([ + 'import_template_id' => $activities->id, + 'source_column' => $map['source_column'], + ], [ + 'target_field' => $map['target_field'], + 'position' => $map['position'], + 'options' => $map['options'] ?? null, + ]); + } } } diff --git a/resources/js/Pages/Imports/Import.vue b/resources/js/Pages/Imports/Import.vue index d7e089f..f03bf84 100644 --- a/resources/js/Pages/Imports/Import.vue +++ b/resources/js/Pages/Imports/Import.vue @@ -15,6 +15,7 @@ import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere import CsvPreviewModal from "./Partials/CsvPreviewModal.vue"; import SimulationModal from "./Partials/SimulationModal.vue"; import { useCurrencyFormat } from "./useCurrencyFormat.js"; +import DialogModal from "@/Components/DialogModal.vue"; // Reintroduce props definition lost during earlier edits const props = defineProps({ @@ -185,6 +186,23 @@ function downloadUnresolvedCsv() { window.location.href = route("imports.missing-keyref-csv", { import: importId.value }); } +// History import: list of contracts that already existed in DB and were matched +const isHistoryImport = computed(() => { + const foundList = props.import?.meta?.history_found_contracts; + const hasFound = Array.isArray(foundList) && foundList.length > 0; + return Boolean( + props.import?.template?.meta?.history_import ?? + props.import?.import_template?.meta?.history_import ?? + props.import?.meta?.history_import ?? + hasFound + ); +}); +const historyFoundContracts = computed(() => { + const list = props.import?.meta?.history_found_contracts; + return Array.isArray(list) ? list : []; +}); +const showFoundContracts = ref(false); + // Determine if all detected columns are mapped with entity+field function evaluateMappingSaved() { console.log("here the evaluation happen of mapping save!"); @@ -1145,6 +1163,21 @@ async function fetchSimulation() {
+
+ + + {{ historyFoundContracts.length }} že obstoječih + +
@@ -1378,6 +1411,45 @@ async function fetchSimulation() {
+ + + + + + +
diff --git a/resources/js/Pages/Imports/Templates/Create.vue b/resources/js/Pages/Imports/Templates/Create.vue index b1aa623..0b54a58 100644 --- a/resources/js/Pages/Imports/Templates/Create.vue +++ b/resources/js/Pages/Imports/Templates/Create.vue @@ -28,6 +28,8 @@ const form = useForm({ delimiter: "", // Payments import mode payments_import: false, + // History import mode + history_import: false, // For payments mode: how to locate Contract - use single key 'reference' contract_key_mode: null, }, @@ -59,6 +61,9 @@ const prevEntities = ref([]); watch( () => form.meta.payments_import, (enabled) => { + if (enabled && form.meta.history_import) { + form.meta.history_import = false; + } if (enabled) { // Save current selection and lock to the required chain prevEntities.value = Array.isArray(form.entities) ? [...form.entities] : []; @@ -74,6 +79,35 @@ watch( } } ); + +// History import: restrict entities and auto-add accounts when contracts selected +watch( + () => form.meta.history_import, + (enabled) => { + if (enabled && form.meta.payments_import) { + form.meta.payments_import = false; + form.meta.contract_key_mode = null; + } + const allowed = ["person", "person_addresses", "person_phones", "contracts", "activities", "client_cases"]; + if (enabled) { + const current = Array.isArray(form.entities) ? [...form.entities] : []; + let filtered = current.filter((e) => allowed.includes(e)); + if (filtered.includes("contracts") && !filtered.includes("accounts")) { + filtered = [...filtered, "accounts"]; + } + form.entities = filtered; + } + } +); + +watch( + () => form.entities, + (vals) => { + if (form.meta.history_import && Array.isArray(vals) && vals.includes("contracts") && ! vals.includes("accounts")) { + form.entities = [...vals, "accounts"]; + } + } +);