diff --git a/.gitignore b/.gitignore index 94d3f3a..75f1845 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,13 @@ yarn-error.log /.idea /.vscode /.zed -/shadcn-vue \ No newline at end of file +/shadcn-vue + +# Development/Testing Scripts +check-*.php +test-*.php +fix-*.php + +# Development Documentation +IMPORT_*.md +V2_*.md \ No newline at end of file diff --git a/DEDUPLICATION_PLAN_V2.md b/DEDUPLICATION_PLAN_V2.md new file mode 100644 index 0000000..dcab8bb --- /dev/null +++ b/DEDUPLICATION_PLAN_V2.md @@ -0,0 +1,654 @@ +# V2 Deduplication Implementation Plan + +## Problem Statement + +Currently, ImportServiceV2 allows duplicate Person records and related entities when: +1. A ClientCase with the same `client_ref` already exists in the database +2. A Contract with the same `reference` already exists for the client +3. Person data is present in the import row + +This causes data duplication because V2 doesn't check for existing entities before creating Person and related entities (addresses, phones, emails, activities). + +## V1 Deduplication Strategy (Analysis) + +### V1 Person Resolution Order (Lines 913-1015) +V1 follows this hierarchical lookup before creating a new Person: + +1. **Contract Reference Lookup** (Lines 913-922) + - If contract.reference exists → Find existing Contract → Get ClientCase → Get Person + - Prevents creating new Person when Contract already exists + +2. **Account Result Derivation** (Lines 924-936) + - If Account processing resolved/created a Contract → Get ClientCase → Get Person + +3. **ClientCase.client_ref Lookup** (Lines 937-945) + - If client_ref exists → Find ClientCase by (client_id, client_ref) → Get Person + - Prevents creating new Person when ClientCase already exists + +4. **Contact Values Lookup** (Lines 949-964) + - Check Email.value → Get Person + - Check PersonPhone.nu → Get Person + - Check PersonAddress.address → Get Person + +5. **Person Identifiers Lookup** (Lines 1005-1007) + - Check tax_number, ssn, etc. via `findPersonIdByIdentifiers()` + +6. **Create New Person** (Lines 1009-1011) + - Only if all above fail + +### V1 Contract Deduplication (Lines 2158-2196) + +**Early Contract Lookup** (Lines 2168-2180): +```php +// Try to find existing contract EARLY by (client_id, reference) +// across all cases to prevent duplicates +$existing = Contract::query()->withTrashed() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->select('contracts.*') + ->first(); +``` + +**ClientCase Reuse Logic** (Lines 2214-2228): +```php +// If we have a client and client_ref, try to reuse existing case +// to avoid creating extra persons +if ($clientId && $clientRef) { + $cc = ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + if ($cc) { + // Reuse this case + $clientCaseId = $cc->id; + // If case has no person yet, set it + if (!$cc->person_id) { + // Find or create person and attach + } + } +} +``` + +### Key V1 Design Principles + +✅ **Resolution before Creation** - Always check for existing entities first +✅ **Chain Derivation** - Contract → ClientCase → Person (reuse existing chain) +✅ **Contact Deduplication** - Match by email/phone/address before creating +✅ **Client-Scoped Lookups** - All queries scoped to import.client_id +✅ **Minimal Person Creation** - Only create Person as last resort + +## V2 Current Architecture Issues + +### Problem Areas + +1. **PersonHandler** (`app/Services/Import/Handlers/PersonHandler.php`) + - Currently only deduplicates by tax_number/ssn (Lines 38-58) + - Doesn't check if Person exists via Contract/ClientCase + - Processes independently without context awareness + +2. **ClientCaseHandler** (`app/Services/Import/Handlers/ClientCaseHandler.php`) + - Correctly resolves by client_ref (Lines 16-27) + - But doesn't prevent PersonHandler from running afterwards + +3. **ContractHandler** (`app/Services/Import/Handlers/ContractHandler.php`) + - Missing early resolution logic + - Doesn't derive Person from existing Contract chain + +4. **Processing Order Issue** + - Current priority: Person(100) → ClientCase(95) → Contract(90) + - Person runs BEFORE we know if ClientCase/Contract exists + - Should be reversed: Contract → ClientCase → Person + +## V2 Deduplication Plan + +### Phase 1: Reverse Processing Order ✅ + +**Change entity priorities in database seeder:** +```php +// NEW ORDER (descending priority) +Contract: 100 +ClientCase: 95 +Person: 90 +Email: 80 +Address: 70 +Phone: 60 +Account: 50 +Payment: 40 +Activity: 30 +``` + +**Rationale:** Process high-level entities first (Contract, ClientCase) so we can derive Person from existing chains. + +### Phase 2: Early Resolution Service 🔧 + +**Create:** `app/Services/Import/EntityResolutionService.php` + +This service will be called BEFORE handlers process entities: + +```php +class EntityResolutionService +{ + /** + * Resolve Person ID from import context (existing entities). + * Returns Person ID if found, null otherwise. + */ + public function resolvePersonFromContext( + Import $import, + array $mapped, + array $context + ): ?int { + // 1. Check if Contract already processed + if ($contract = $context['contract']['entity'] ?? null) { + $personId = $this->getPersonFromContract($contract); + if ($personId) return $personId; + } + + // 2. Check if ClientCase already processed + if ($clientCase = $context['client_case']['entity'] ?? null) { + if ($clientCase->person_id) { + return $clientCase->person_id; + } + } + + // 3. Check for existing Contract by reference + if ($contractRef = $mapped['contract']['reference'] ?? null) { + $personId = $this->getPersonFromContractReference( + $import->client_id, + $contractRef + ); + if ($personId) return $personId; + } + + // 4. Check for existing ClientCase by client_ref + if ($clientRef = $mapped['client_case']['client_ref'] ?? null) { + $personId = $this->getPersonFromClientRef( + $import->client_id, + $clientRef + ); + if ($personId) return $personId; + } + + // 5. Check for existing Person by contact values + $personId = $this->resolvePersonByContacts($mapped); + if ($personId) return $personId; + + return null; // No existing Person found + } + + /** + * Check if ClientCase exists for this client_ref. + */ + public function clientCaseExists(int $clientId, string $clientRef): bool + { + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->exists(); + } + + /** + * Check if Contract exists for this reference. + */ + public function contractExists(int $clientId, string $reference): bool + { + return Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->exists(); + } + + private function getPersonFromContract(Contract $contract): ?int + { + if ($contract->client_case_id) { + return ClientCase::where('id', $contract->client_case_id) + ->value('person_id'); + } + return null; + } + + private function getPersonFromContractReference( + ?int $clientId, + string $reference + ): ?int { + if (!$clientId) return null; + + $clientCaseId = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->value('contracts.client_case_id'); + + if ($clientCaseId) { + return ClientCase::where('id', $clientCaseId) + ->value('person_id'); + } + + return null; + } + + private function getPersonFromClientRef( + ?int $clientId, + string $clientRef + ): ?int { + if (!$clientId) return null; + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->value('person_id'); + } + + private function resolvePersonByContacts(array $mapped): ?int + { + // Check email + if ($email = $mapped['email']['value'] ?? $mapped['emails'][0]['value'] ?? null) { + $personId = Email::where('value', trim($email))->value('person_id'); + if ($personId) return $personId; + } + + // Check phone + if ($phone = $mapped['phone']['nu'] ?? $mapped['person_phones'][0]['nu'] ?? null) { + $personId = PersonPhone::where('nu', trim($phone))->value('person_id'); + if ($personId) return $personId; + } + + // Check address + if ($address = $mapped['address']['address'] ?? $mapped['person_addresses'][0]['address'] ?? null) { + $personId = PersonAddress::where('address', trim($address))->value('person_id'); + if ($personId) return $personId; + } + + return null; + } +} +``` + +### Phase 3: Update PersonHandler 🔧 + +**Modify:** `app/Services/Import/Handlers/PersonHandler.php` + +Add resolution service check before creating: + +```php +public function process(Import $import, array $mapped, array $raw, array $context = []): array +{ + // FIRST: Check if Person already resolved from context + $resolutionService = app(EntityResolutionService::class); + $existingPersonId = $resolutionService->resolvePersonFromContext( + $import, + $mapped, + $context + ); + + if ($existingPersonId) { + $existing = Person::find($existingPersonId); + + // Update if configured + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Person already exists (found via Contract/ClientCase chain)', + ]; + } + + // Update logic... + return [ + 'action' => 'updated', + 'entity' => $existing, + 'count' => 1, + ]; + } + + // SECOND: Try existing deduplication (tax_number, ssn) + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update logic... + } + + // THIRD: Check contacts deduplication + $personIdFromContacts = $resolutionService->resolvePersonByContacts($mapped); + if ($personIdFromContacts) { + $existing = Person::find($personIdFromContacts); + // Update logic... + } + + // LAST: Create new Person only if all checks failed + $payload = $this->buildPayload($mapped); + $person = Person::create($payload); + + return [ + 'action' => 'inserted', + 'entity' => $person, + 'count' => 1, + ]; +} +``` + +### Phase 4: Update ContractHandler 🔧 + +**Modify:** `app/Services/Import/Handlers/ContractHandler.php` + +Add early Contract lookup and ClientCase reuse: + +```php +public function process(Import $import, array $mapped, array $raw, array $context = []): array +{ + $clientId = $import->client_id; + $reference = $mapped['reference'] ?? null; + + if (!$clientId || !$reference) { + return [ + 'action' => 'invalid', + 'errors' => ['Contract requires client_id and reference'], + ]; + } + + // EARLY LOOKUP: Check if Contract exists across all cases + $existing = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->select('contracts.*') + ->first(); + + if ($existing) { + // Contract exists - update or skip + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Contract already exists', + ]; + } + + // Update logic... + return [ + 'action' => 'updated', + 'entity' => $existing, + 'count' => 1, + ]; + } + + // Creating new Contract - resolve/create ClientCase + $clientCaseId = $this->resolveOrCreateClientCase($import, $mapped, $context); + + if (!$clientCaseId) { + return [ + 'action' => 'invalid', + 'errors' => ['Unable to resolve client_case_id'], + ]; + } + + // Create Contract + $payload = array_merge($this->buildPayload($mapped), [ + 'client_case_id' => $clientCaseId, + ]); + + $contract = Contract::create($payload); + + return [ + 'action' => 'inserted', + 'entity' => $contract, + 'count' => 1, + ]; +} + +protected function resolveOrCreateClientCase( + Import $import, + array $mapped, + array $context +): ?int { + $clientId = $import->client_id; + $clientRef = $mapped['client_ref'] ?? + $context['client_case']['entity']?->client_ref ?? + null; + + // If ClientCase already processed in this row + if ($clientCaseId = $context['client_case']['entity']?->id ?? null) { + return $clientCaseId; + } + + // Try to find existing ClientCase by client_ref + if ($clientRef) { + $existing = ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + + if ($existing) { + // REUSE existing ClientCase (and its Person) + return $existing->id; + } + } + + // Create new ClientCase (Person should already be processed) + $personId = $context['person']['entity']?->id ?? null; + + if (!$personId) { + // Person wasn't in import, create minimal + $personId = Person::create(['type_id' => 1])->id; + } + + $clientCase = ClientCase::create([ + 'client_id' => $clientId, + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + + return $clientCase->id; +} +``` + +### Phase 5: Update ClientCaseHandler 🔧 + +**Modify:** `app/Services/Import/Handlers/ClientCaseHandler.php` + +Ensure it uses resolved Person from context: + +```php +public function process(Import $import, array $mapped, array $raw, array $context = []): array +{ + $clientId = $import->client_id ?? null; + $clientRef = $mapped['client_ref'] ?? null; + + // Get Person from context (should be processed first now) + $personId = $context['person']['entity']?->id ?? null; + + if (!$clientId) { + return [ + 'action' => 'skipped', + 'message' => 'ClientCase requires client_id', + ]; + } + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'ClientCase already exists (skip mode)', + ]; + } + + $payload = $this->buildPayload($mapped, $existing); + + // Update person_id ONLY if provided and different + if ($personId && $existing->person_id !== $personId) { + $payload['person_id'] = $personId; + } + + $appliedFields = $this->trackAppliedFields($existing, $payload); + $existing->update($payload); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'count' => 1, + ]; + } + + // Create new ClientCase + $payload = $this->buildPayload($mapped); + + // Attach Person if resolved + if ($personId) { + $payload['person_id'] = $personId; + } + + $payload['client_id'] = $clientId; + + $clientCase = ClientCase::create($payload); + + return [ + 'action' => 'inserted', + 'entity' => $clientCase, + 'count' => 1, + ]; +} +``` + +### Phase 6: Integration into ImportServiceV2 🔧 + +**Modify:** `app/Services/Import/ImportServiceV2.php` + +Inject resolution service into processRow: + +```php +protected function processRow(Import $import, array $mapped, array $raw, array $context): array +{ + $entityResults = []; + $lastEntityType = null; + $lastEntityId = null; + $hasErrors = false; + + // NEW: Add resolution service to context + $context['resolution_service'] = app(EntityResolutionService::class); + + // Process entities in configured priority order + foreach ($this->entityConfigs as $root => $config) { + // ... existing logic ... + } + + // ... rest of method ... +} +``` + +## Implementation Checklist + +### Step 1: Update Database Priority ✅ +- [ ] Modify `database/seeders/ImportEntitiesV2Seeder.php` +- [ ] Change priorities: Contract(100), ClientCase(95), Person(90) +- [ ] Run seeder: `php artisan db:seed --class=ImportEntitiesV2Seeder --force` + +### Step 2: Create EntityResolutionService 🔧 +- [ ] Create `app/Services/Import/EntityResolutionService.php` +- [ ] Implement all resolution methods +- [ ] Add comprehensive PHPDoc +- [ ] Add logging for debugging + +### Step 3: Update PersonHandler 🔧 +- [ ] Modify `process()` method to check resolution service first +- [ ] Add contact-based deduplication +- [ ] Ensure proper skip/update modes + +### Step 4: Update ContractHandler 🔧 +- [ ] Add early Contract lookup (client_id + reference) +- [ ] Implement ClientCase reuse logic +- [ ] Prevent duplicate Contract creation + +### Step 5: Update ClientCaseHandler 🔧 +- [ ] Use Person from context +- [ ] Handle person_id properly on updates +- [ ] Maintain existing deduplication + +### Step 6: Integrate into ImportServiceV2 🔧 +- [ ] Add resolution service to context +- [ ] Test with existing imports + +### Step 7: Testing 🧪 +- [ ] Test import with existing client_ref +- [ ] Test import with existing contract reference +- [ ] Test import with existing email/phone +- [ ] Test mixed scenarios +- [ ] Verify no duplicate Persons created +- [ ] Check all related entities linked correctly + +## Expected Behavior After Implementation + +### Scenario 1: Existing ClientCase by client_ref +``` +Import Row: {client_ref: "B387055", name: "John", email: "john@test.com"} + +Before V2 Fix: +❌ Creates new Person (duplicate) +❌ Creates new Email (duplicate) +✅ Reuses ClientCase + +After V2 Fix: +✅ Finds existing Person via ClientCase +✅ Updates Person if needed +✅ Reuses ClientCase +✅ Reuses/updates Email +``` + +### Scenario 2: Existing Contract by reference +``` +Import Row: {contract.reference: "REF-123", person.name: "Jane"} + +Before V2 Fix: +❌ Creates new Person (duplicate) +❌ Contract might be created or updated +❌ New Person not linked to existing ClientCase + +After V2 Fix: +✅ Finds existing Contract +✅ Derives Person from Contract → ClientCase chain +✅ Updates Person if needed +✅ No duplicate Person created +``` + +### Scenario 3: New Import (no existing entities) +``` +Import Row: {client_ref: "NEW-001", name: "Bob"} + +Behavior: +✅ Creates new Person +✅ Creates new ClientCase +✅ Links correctly +✅ No duplicates +``` + +## Success Criteria + +✅ **No duplicate Persons** when client_ref or contract reference exists +✅ **Proper entity linking** - all entities connected to correct Person +✅ **Backward compatibility** - existing imports still work +✅ **Skip mode respected** - handlers honor skip/update modes +✅ **Contact deduplication** - matches by email/phone/address +✅ **Performance maintained** - no significant slowdown + +## Rollback Plan + +If issues occur: +1. Revert priority changes in database +2. Disable EntityResolutionService by commenting out context injection +3. Fall back to original handler behavior +4. Investigate and fix issues +5. Re-implement with fixes + +## Notes + +- This plan maintains V2's modular handler architecture +- Resolution logic is centralized in EntityResolutionService +- Handlers remain independent but context-aware +- Similar to V1 but cleaner separation of concerns +- Can be implemented incrementally (phase by phase) +- Each phase can be tested independently diff --git a/app/Console/Commands/FixImportMappingEntities.php b/app/Console/Commands/FixImportMappingEntities.php new file mode 100644 index 0000000..ca7ee0e --- /dev/null +++ b/app/Console/Commands/FixImportMappingEntities.php @@ -0,0 +1,156 @@ + 'contract', + 'contract' => 'contract', + 'client_cases' => 'client_case', + 'client_case' => 'client_case', + 'person_addresses' => 'address', + 'addresses' => 'address', + 'address' => 'address', + 'person_phones' => 'phone', + 'phones' => 'phone', + 'phone' => 'phone', + 'emails' => 'email', + 'email' => 'email', + 'activities' => 'activity', + 'activity' => 'activity', + 'persons' => 'person', + 'person' => 'person', + 'accounts' => 'account', + 'account' => 'account', + 'payments' => 'payment', + 'payment' => 'payment', + 'bookings' => 'booking', + 'booking' => 'booking', + ]; + + /** + * Execute the console command. + */ + public function handle() + { + $dryRun = $this->option('dry-run'); + + if ($dryRun) { + $this->info('Running in DRY-RUN mode - no changes will be made'); + } + + $mappings = DB::table('import_mappings') + ->whereNotNull('entity') + ->where('entity', '!=', '') + ->get(); + + if ($mappings->isEmpty()) { + $this->info('No mappings found to fix.'); + return 0; + } + + $this->info("Found {$mappings->count()} mappings to check"); + $this->newLine(); + + $updates = []; + $unchanged = 0; + + foreach ($mappings as $mapping) { + $currentEntity = trim($mapping->entity); + + if (isset($this->entityMapping[$currentEntity])) { + $correctEntity = $this->entityMapping[$currentEntity]; + + if ($currentEntity !== $correctEntity) { + $updates[] = [ + 'id' => $mapping->id, + 'current' => $currentEntity, + 'correct' => $correctEntity, + 'source' => $mapping->source_column, + 'target' => $mapping->target_field, + ]; + } else { + $unchanged++; + } + } else { + $this->warn("Unknown entity type: {$currentEntity} (ID: {$mapping->id})"); + } + } + + if (empty($updates)) { + $this->info("✓ All {$unchanged} mappings already have correct entity names!"); + return 0; + } + + // Display changes + $this->info("Changes to be made:"); + $this->newLine(); + + $table = []; + foreach ($updates as $update) { + $table[] = [ + $update['id'], + $update['source'], + $update['target'], + $update['current'], + $update['correct'], + ]; + } + + $this->table( + ['ID', 'Source Column', 'Target Field', 'Current Entity', 'Correct Entity'], + $table + ); + + $this->newLine(); + $this->info("Total changes: " . count($updates)); + $this->info("Unchanged: {$unchanged}"); + + if ($dryRun) { + $this->newLine(); + $this->warn('DRY-RUN mode: No changes were made. Run without --dry-run to apply changes.'); + return 0; + } + + // Confirm before proceeding + if (!$this->confirm('Do you want to apply these changes?', true)) { + $this->info('Operation cancelled.'); + return 0; + } + + // Apply updates + $updated = 0; + foreach ($updates as $update) { + DB::table('import_mappings') + ->where('id', $update['id']) + ->update(['entity' => $update['correct']]); + $updated++; + } + + $this->newLine(); + $this->info("✓ Successfully updated {$updated} mappings!"); + + return 0; + } +} diff --git a/app/Console/Commands/PopulateImportMappingEntities.php b/app/Console/Commands/PopulateImportMappingEntities.php new file mode 100644 index 0000000..914ae17 --- /dev/null +++ b/app/Console/Commands/PopulateImportMappingEntities.php @@ -0,0 +1,113 @@ + 'contract', + 'client_cases' => 'client_case', + 'person_addresses' => 'address', + 'person_phones' => 'phone', + 'emails' => 'email', + 'activities' => 'activity', + 'payments' => 'payment', + 'accounts' => 'account', + 'persons' => 'person', + 'person' => 'person', + 'contract' => 'contract', + 'client_case' => 'client_case', + 'address' => 'address', + 'phone' => 'phone', + 'email' => 'email', + 'activity' => 'activity', + 'payment' => 'payment', + 'account' => 'account', + ]; + + public function handle() + { + $dryRun = $this->option('dry-run'); + + $this->info('Populating entity column from target_field...'); + if ($dryRun) { + $this->warn('DRY RUN MODE - No changes will be made'); + } + + // Get all mappings where entity is null + $mappings = DB::table('import_mappings') + ->whereNull('entity') + ->get(); + + if ($mappings->isEmpty()) { + $this->info('No mappings found with null entity.'); + return 0; + } + + $this->info("Found {$mappings->count()} mappings to process."); + $this->newLine(); + + $updated = 0; + $skipped = 0; + + foreach ($mappings as $mapping) { + $targetField = $mapping->target_field; + + // Parse the target_field to extract entity and field + if (str_contains($targetField, '.')) { + [$rawEntity, $field] = explode('.', $targetField, 2); + } elseif (str_contains($targetField, '->')) { + [$rawEntity, $field] = explode('->', $targetField, 2); + } else { + $this->warn("Skipping mapping ID {$mapping->id}: Cannot parse target_field '{$targetField}'"); + $skipped++; + continue; + } + + $rawEntity = trim($rawEntity); + $field = trim($field); + + // Map to canonical entity name + $canonicalEntity = $this->entityMap[$rawEntity] ?? $rawEntity; + + $this->line(sprintf( + "ID %d: '%s' -> '%s' => entity='%s', field='%s'", + $mapping->id, + $mapping->source_column, + $targetField, + $canonicalEntity, + $field + )); + + if (!$dryRun) { + DB::table('import_mappings') + ->where('id', $mapping->id) + ->update([ + 'entity' => $canonicalEntity, + 'target_field' => $field, + ]); + $updated++; + } + } + + $this->newLine(); + if ($dryRun) { + $this->info("Dry run complete. Would have updated {$mappings->count()} mappings."); + } else { + $this->info("Successfully updated {$updated} mappings."); + } + + if ($skipped > 0) { + $this->warn("Skipped {$skipped} mappings that couldn't be parsed."); + } + + return 0; + } +} diff --git a/app/Console/Commands/SimulateImportV2Command.php b/app/Console/Commands/SimulateImportV2Command.php new file mode 100644 index 0000000..be0ded3 --- /dev/null +++ b/app/Console/Commands/SimulateImportV2Command.php @@ -0,0 +1,145 @@ +argument('import_id'); + $limit = (int) $this->option('limit'); + $verbose = (bool) $this->option('verbose'); + + $import = Import::find($importId); + + if (! $import) { + $this->error("Import #{$importId} not found."); + + return 1; + } + + $this->info("Simulating import #{$importId} - {$import->file_name}"); + $this->info("Client: ".($import->client->name ?? 'N/A')); + $this->info("Limit: {$limit} rows"); + $this->line(''); + + $result = $service->simulate($import, $limit, $verbose); + + if (! $result['success']) { + $this->error('Simulation failed: '.$result['error']); + + return 1; + } + + $this->info("✓ Simulated {$result['total_simulated']} rows"); + $this->line(''); + + // Display summaries + if (! empty($result['summaries'])) { + $this->info('=== Entity Summaries ==='); + $summaryRows = []; + + foreach ($result['summaries'] as $entity => $stats) { + $summaryRows[] = [ + 'entity' => $entity, + 'create' => $stats['create'], + 'update' => $stats['update'], + 'skip' => $stats['skip'], + 'invalid' => $stats['invalid'], + 'total' => array_sum($stats), + ]; + } + + $this->table( + ['Entity', 'Create', 'Update', 'Skip', 'Invalid', 'Total'], + $summaryRows + ); + $this->line(''); + } + + // Display row previews (first 5) + if (! empty($result['rows'])) { + $this->info('=== Row Previews (first 5) ==='); + + foreach (array_slice($result['rows'], 0, 5) as $row) { + $this->line("Row #{$row['row_number']}:"); + + if (! empty($row['entities'])) { + foreach ($row['entities'] as $entity => $data) { + $action = $data['action']; + $color = match ($action) { + 'create' => 'green', + 'update' => 'yellow', + 'skip' => 'gray', + 'invalid', 'error' => 'red', + default => 'white', + }; + + $line = " {$entity}: {$action}"; + + if (isset($data['reference'])) { + $line .= " ({$data['reference']})"; + } + + if (isset($data['existing_id'])) { + $line .= " [ID: {$data['existing_id']}]"; + } + + $this->line($line); + + if ($verbose && ! empty($data['changes'])) { + foreach ($data['changes'] as $field => $change) { + $this->line(" {$field}: {$change['old']} → {$change['new']}"); + } + } + + if (! empty($data['errors'])) { + foreach ($data['errors'] as $error) { + $this->error(" ✗ {$error}"); + } + } + } + } + + if (! empty($row['warnings'])) { + foreach ($row['warnings'] as $warning) { + $this->warn(" ⚠ {$warning}"); + } + } + + if (! empty($row['errors'])) { + foreach ($row['errors'] as $error) { + $this->error(" ✗ {$error}"); + } + } + + $this->line(''); + } + } + + $this->info('Simulation completed successfully.'); + + return 0; + } +} diff --git a/app/Console/Commands/TestImportV2Command.php b/app/Console/Commands/TestImportV2Command.php new file mode 100644 index 0000000..5a94e7f --- /dev/null +++ b/app/Console/Commands/TestImportV2Command.php @@ -0,0 +1,68 @@ +argument('import_id'); + $useQueue = $this->option('queue'); + + $import = Import::find($importId); + + if (! $import) { + $this->error("Import {$importId} not found."); + + return 1; + } + + $this->info("Processing import: {$import->id} ({$import->file_name})"); + $this->info("Source: {$import->source_type}"); + $this->info("Status: {$import->status}"); + $this->newLine(); + + if ($useQueue) { + $this->info('Dispatching to queue...'); + ProcessLargeImportJob::dispatch($import, auth()->id()); + $this->info('Job dispatched successfully. Monitor queue for progress.'); + + return 0; + } + + $this->info('Processing synchronously...'); + $service = app(ImportServiceV2::class); + + try { + $results = $service->process($import, auth()->user()); + + $this->newLine(); + $this->info('Processing completed!'); + $this->table( + ['Metric', 'Count'], + [ + ['Total rows', $results['total']], + ['Imported', $results['imported']], + ['Skipped', $results['skipped']], + ['Invalid', $results['invalid']], + ] + ); + + return 0; + } catch (\Throwable $e) { + $this->error('Processing failed: '.$e->getMessage()); + $this->error($e->getTraceAsString()); + + return 1; + } + } +} diff --git a/app/Http/Controllers/FieldJobController.php b/app/Http/Controllers/FieldJobController.php index 89683bf..07a7639 100644 --- a/app/Http/Controllers/FieldJobController.php +++ b/app/Http/Controllers/FieldJobController.php @@ -25,56 +25,71 @@ public function index(Request $request) optional($setting)->segment_id, ])->filter()->unique()->values(); - $contracts = Contract::query() - ->with(['clientCase.person', 'clientCase.client.person', 'type', 'account']) - ->when($segmentIds->isNotEmpty(), function ($q) use ($segmentIds) { - $q->whereHas('segments', function ($sq) use ($segmentIds) { - // Relation already filters on active pivots - $sq->whereIn('segments.id', $segmentIds); - }); - }, function ($q) { - // No segments configured on FieldJobSetting -> return none - $q->whereRaw('1 = 0'); - }) + $search = $request->input('search'); + $assignedUserId = $request->input('assigned_user_id'); + + $unassignedContracts = Contract::query() + ->with(['clientCase.person.addresses', 'clientCase.client.person', 'type', 'account']) + ->when($segmentIds->isNotEmpty(), fn($q) => + $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)), + fn($q) => $q->whereRaw('1 = 0') + ) + ->when( !empty($search), fn ($q) => + $q->where(fn($sq) => + $sq->where('reference', 'like', "%{$search}%") + ->orWhereHas('clientCase.person', fn($psq) => + $psq->where('full_name', 'ilike', "%{$search}%") + ) + ->orWhereHas('clientCase.person.addresses', fn ($ccpaq) => + $ccpaq->where('address', 'ilike', "%{$search}") + ) + ) + ) + ->whereDoesntHave('fieldJobs', fn ($q) => + $q->whereNull('completed_at') + ->whereNull('cancelled_at') + ) ->latest('id') - ->limit(50) - ->get(); + ->paginate( + $request->input('per_page_contracts', 10), + ['*'], + 'page_contracts', + $request->input('page_contracts', 1) + ); - // Mirror client onto the contract for simpler frontend access: c.client.person.full_name - $contracts->each(function (Contract $contract): void { - if ($contract->relationLoaded('clientCase') && $contract->clientCase) { - $contract->setRelation('client', $contract->clientCase->client); - } - }); + - // Build active assignment map keyed by contract uuid for quicker UI checks - $assignments = collect(); - if ($contracts->isNotEmpty()) { - $activeJobs = FieldJob::query() - ->whereIn('contract_id', $contracts->pluck('id')) - ->whereNull('completed_at') - ->whereNull('cancelled_at') - ->with(['assignedUser:id,name', 'user:id,name', 'contract:id,uuid']) - ->get(); - - $assignments = $activeJobs->mapWithKeys(function (FieldJob $job) { - return [ - optional($job->contract)->uuid => [ - 'assigned_to' => $job->assignedUser ? ['id' => $job->assignedUser->id, 'name' => $job->assignedUser->name] : null, - 'assigned_by' => $job->user ? ['id' => $job->user->id, 'name' => $job->user->name] : null, - 'assigned_at' => $job->assigned_at, - ], - ]; - })->filter(); - } + $assignedContracts = Contract::query() + ->with(['clientCase.person.addresses', 'clientCase.client.person', 'type', 'account', 'lastFieldJobs', 'lastFieldJobs.assignedUser', 'lastFieldJobs.user']) + ->when($segmentIds->isNotEmpty(), fn($q) => + $q->whereHas('segments', fn($rq) => $rq->whereIn('segments.id', $segmentIds)), + fn($q) => $q->whereRaw('1 = 0') + ) + ->whereHas('lastFieldJobs', fn ($q) => + $q->whereNull('completed_at') + ->whereNull('cancelled_at') + ->when($assignedUserId && $assignedUserId !== 'all', fn ($jq) => + $jq->where('assigned_user_id', $assignedUserId)) + ) + ->latest('id') + ->paginate( + $request->input('per_page_assignments', 10), + ['*'], + 'page_assignments', + $request->input('page_assignments', 1) + ); $users = User::query()->orderBy('name')->get(['id', 'name']); return Inertia::render('FieldJob/Index', [ 'setting' => $setting, - 'contracts' => $contracts, + 'unassignedContracts' => $unassignedContracts, + 'assignedContracts' => $assignedContracts, 'users' => $users, - 'assignments' => $assignments, + 'filters' => [ + 'search' => $search, + 'assigned_user_id' => $assignedUserId, + ], ]); } diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 14e6e4d..197cd61 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -9,6 +9,8 @@ use App\Models\ImportEvent; use App\Models\ImportTemplate; use App\Services\CsvImportService; +use App\Services\Import\ImportServiceV2; +use App\Services\Import\ImportSimulationServiceV2; use App\Services\ImportProcessor; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -182,12 +184,27 @@ public function store(Request $request) } // Kick off processing of an import - simple synchronous step for now - public function process(Import $import, Request $request, ImportProcessor $processor) + public function process(Import $import, Request $request, ImportServiceV2 $processor) { $import->update(['status' => 'validating', 'started_at' => now()]); - $result = $processor->process($import, user: $request->user()); - - return response()->json($result); + + try { + $result = $processor->process($import, user: $request->user()); + return response()->json($result); + } catch (\Throwable $e) { + \Log::error('Import processing failed', [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $import->update(['status' => 'failed']); + + return response()->json([ + 'success' => false, + 'message' => 'Import processing failed: ' . $e->getMessage(), + ], 500); + } } // Analyze the uploaded file and return column headers or positional indices @@ -426,7 +443,7 @@ public function missingContracts(Import $import) // Query active, non-archived contracts for this client that were not in import // Include person full_name (owner of the client case) and aggregate active accounts' balance_amount - $contractsQ = \App\Models\Contract::query() + $contractsQ = Contract::query() ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') ->join('person', 'person.id', '=', 'client_cases.person_id') ->leftJoin('accounts', function ($join) { @@ -514,7 +531,7 @@ public function getEvents(Import $import) public function missingKeyrefRows(Import $import) { // Identify row IDs from events. Prefer specific event key, fallback to message pattern - $rowIds = \App\Models\ImportEvent::query() + $rowIds = ImportEvent::query() ->where('import_id', $import->id) ->where(function ($q) { $q->where('event', 'contract_keyref_not_found') @@ -694,6 +711,10 @@ public function simulatePayments(Import $import, Request $request) * using the first N rows and current saved mappings. Works for both payments and non-payments * templates. For payments templates, payment-specific summaries/entities will be included * automatically by the simulation service when mappings contain the payment root. + * + * @param Import $import + * @param Request $request + * @return \Illuminate\Http\JsonResponse */ public function simulate(Import $import, Request $request) { @@ -704,7 +725,7 @@ public function simulate(Import $import, Request $request) $limit = (int) ($validated['limit'] ?? 100); $verbose = (bool) ($validated['verbose'] ?? false); - $service = app(\App\Services\ImportSimulationService::class); + $service = app(ImportSimulationServiceV2::class); $result = $service->simulate($import, $limit, $verbose); return response()->json($result); diff --git a/app/Jobs/ProcessLargeImportJob.php b/app/Jobs/ProcessLargeImportJob.php new file mode 100644 index 0000000..7b90954 --- /dev/null +++ b/app/Jobs/ProcessLargeImportJob.php @@ -0,0 +1,107 @@ + $this->import->id, + 'user_id' => $this->userId, + ]); + + try { + $user = $this->userId ? \App\Models\User::find($this->userId) : null; + + $service = app(ImportServiceV2::class); + $results = $service->process($this->import, $user); + + Log::info('ProcessLargeImportJob completed', [ + 'import_id' => $this->import->id, + 'results' => $results, + ]); + + ImportEvent::create([ + 'import_id' => $this->import->id, + 'user_id' => $this->userId, + 'event' => 'queue_job_completed', + 'level' => 'info', + 'message' => sprintf( + 'Queued import completed: %d imported, %d skipped, %d invalid', + $results['imported'], + $results['skipped'], + $results['invalid'] + ), + 'context' => $results, + ]); + } catch (\Throwable $e) { + Log::error('ProcessLargeImportJob failed', [ + 'import_id' => $this->import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->import->update(['status' => 'failed']); + + ImportEvent::create([ + 'import_id' => $this->import->id, + 'user_id' => $this->userId, + 'event' => 'queue_job_failed', + 'level' => 'error', + 'message' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('ProcessLargeImportJob permanently failed', [ + 'import_id' => $this->import->id, + 'error' => $exception->getMessage(), + ]); + + $this->import->update(['status' => 'failed']); + + ImportEvent::create([ + 'import_id' => $this->import->id, + 'user_id' => $this->userId, + 'event' => 'queue_job_permanently_failed', + 'level' => 'error', + 'message' => 'Import job failed after maximum retries: '.$exception->getMessage(), + ]); + } +} diff --git a/app/Models/Contract.php b/app/Models/Contract.php index c419346..be845a7 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -140,6 +140,10 @@ public function fieldJobs(): HasMany return $this->hasMany(\App\Models\FieldJob::class); } + public function lastFieldJobs(): HasOne { + return $this->hasOne(\App\Models\FieldJob::class)->latestOfMany(); + } + public function latestObject(): HasOne { return $this->hasOne(\App\Models\CaseObject::class) diff --git a/app/Models/ImportEntity.php b/app/Models/ImportEntity.php index d8b26c7..034d0d5 100644 --- a/app/Models/ImportEntity.php +++ b/app/Models/ImportEntity.php @@ -17,6 +17,11 @@ class ImportEntity extends Model 'meta', 'rules', 'ui', + 'handler_class', + 'validation_rules', + 'processing_options', + 'is_active', + 'priority', ]; protected $casts = [ @@ -27,5 +32,9 @@ class ImportEntity extends Model 'meta' => 'boolean', 'rules' => 'array', 'ui' => 'array', + 'validation_rules' => 'array', + 'processing_options' => 'array', + 'is_active' => 'boolean', + 'priority' => 'integer', ]; } diff --git a/app/Services/DateNormalizer.php b/app/Services/DateNormalizer.php index 7ea90a0..839ba6f 100644 --- a/app/Services/DateNormalizer.php +++ b/app/Services/DateNormalizer.php @@ -2,57 +2,11 @@ namespace App\Services; -class DateNormalizer +/** + * Backward compatibility alias for DateNormalizer. + * Old code references App\Services\DateNormalizer, but actual class is at App\Services\Import\DateNormalizer. + */ +class DateNormalizer extends \App\Services\Import\DateNormalizer { - /** - * Normalize a raw date string to Y-m-d (ISO) or return null if unparseable. - * Accepted examples: 30.10.2025, 30/10/2025, 30-10-2025, 1/2/25, 2025-10-30 - */ - public static function toDate(?string $raw): ?string - { - if ($raw === null) { - return null; - } - $raw = trim($raw); - if ($raw === '') { - return null; - } - - // Common European and ISO formats first (day-first, then ISO) - $candidates = [ - 'd.m.Y', 'd.m.y', - 'd/m/Y', 'd/m/y', - 'd-m-Y', 'd-m-y', - 'Y-m-d', 'Y/m/d', 'Y.m.d', - ]; - - foreach ($candidates as $fmt) { - $dt = \DateTime::createFromFormat($fmt, $raw); - if ($dt instanceof \DateTime) { - $errors = \DateTime::getLastErrors(); - if ((int) ($errors['warning_count'] ?? 0) === 0 && (int) ($errors['error_count'] ?? 0) === 0) { - // Adjust two-digit years to reasonable century (00-69 => 2000-2069, 70-99 => 1970-1999) - $year = (int) $dt->format('Y'); - if ($year < 100) { - $year += ($year <= 69) ? 2000 : 1900; - // Rebuild date with corrected year - $month = (int) $dt->format('m'); - $day = (int) $dt->format('d'); - - return sprintf('%04d-%02d-%02d', $year, $month, $day); - } - - return $dt->format('Y-m-d'); - } - } - } - - // Fallback: strtotime (permissive). If fails, return null. - $ts = @strtotime($raw); - if ($ts === false) { - return null; - } - - return date('Y-m-d', $ts); - } + // This class extends the actual DateNormalizer for backward compatibility } diff --git a/app/Services/Import/BaseEntityHandler.php b/app/Services/Import/BaseEntityHandler.php new file mode 100644 index 0000000..ff5757d --- /dev/null +++ b/app/Services/Import/BaseEntityHandler.php @@ -0,0 +1,86 @@ +entityConfig = $entityConfig; + } + + /** + * Validate mapped data using configuration rules. + */ + public function validate(array $mapped): array + { + $rules = $this->entityConfig?->validation_rules ?? []; + + if (empty($rules)) { + return ['valid' => true, 'errors' => []]; + } + + $validator = Validator::make($mapped, $rules); + + if ($validator->fails()) { + return [ + 'valid' => false, + 'errors' => $validator->errors()->all(), + ]; + } + + return ['valid' => true, 'errors' => []]; + } + + /** + * Get processing options from config. + */ + protected function getOption(string $key, mixed $default = null): mixed + { + return $this->entityConfig?->processing_options[$key] ?? $default; + } + + /** + * Determine if a field has changed. + */ + protected function hasChanged($model, string $field, mixed $newValue): bool + { + $current = $model->{$field}; + + if (is_null($newValue) && is_null($current)) { + return false; + } + + return $current != $newValue; + } + + /** + * Track which fields were applied/changed. + */ + protected function trackAppliedFields($model, array $payload): array + { + $applied = []; + + foreach ($payload as $field => $value) { + if ($this->hasChanged($model, $field, $value)) { + $applied[] = $field; + } + } + + return $applied; + } + + /** + * Default implementation returns null - override in specific handlers. + */ + public function resolve(array $mapped, array $context = []): mixed + { + return null; + } +} diff --git a/app/Services/Import/Contracts/EntityHandlerInterface.php b/app/Services/Import/Contracts/EntityHandlerInterface.php new file mode 100644 index 0000000..3a6065e --- /dev/null +++ b/app/Services/Import/Contracts/EntityHandlerInterface.php @@ -0,0 +1,43 @@ + bool, 'errors' => array] + */ + public function validate(array $mapped): array; + + /** + * Get the entity class name this handler manages. + * + * @return string + */ + public function getEntityClass(): string; + + /** + * Resolve existing entity by key/reference. + * + * @param array $mapped Mapped data for this entity + * @param array $context Additional context + * @return mixed|null Existing entity instance or null + */ + public function resolve(array $mapped, array $context = []): mixed; +} diff --git a/app/Services/Import/DateNormalizer.php b/app/Services/Import/DateNormalizer.php new file mode 100644 index 0000000..55d0233 --- /dev/null +++ b/app/Services/Import/DateNormalizer.php @@ -0,0 +1,58 @@ + 2000-2069, 70-99 => 1970-1999) + $year = (int) $dt->format('Y'); + if ($year < 100) { + $year += ($year <= 69) ? 2000 : 1900; + // Rebuild date with corrected year + $month = (int) $dt->format('m'); + $day = (int) $dt->format('d'); + + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + + return $dt->format('Y-m-d'); + } + } + } + + // Fallback: strtotime (permissive). If fails, return null. + $ts = @strtotime($raw); + if ($ts === false) { + return null; + } + + return date('Y-m-d', $ts); + } +} diff --git a/app/Services/Import/EntityResolutionService.php b/app/Services/Import/EntityResolutionService.php new file mode 100644 index 0000000..cbc8afc --- /dev/null +++ b/app/Services/Import/EntityResolutionService.php @@ -0,0 +1,394 @@ +getPersonFromContract($contract); + if ($personId) { + Log::info('EntityResolutionService: Found Person from processed Contract', [ + 'person_id' => $personId, + 'contract_id' => $contract->id, + ]); + return $personId; + } + } + + // 2. Check if ClientCase already processed in this row + if ($clientCase = $context['client_case']['entity'] ?? null) { + if ($clientCase->person_id) { + Log::info('EntityResolutionService: Found Person from processed ClientCase', [ + 'person_id' => $clientCase->person_id, + 'client_case_id' => $clientCase->id, + ]); + return $clientCase->person_id; + } + } + + // 3. Check for existing Contract by reference (before it's processed) + if ($contractRef = $mapped['contract']['reference'] ?? null) { + $personId = $this->getPersonFromContractReference($import->client_id, $contractRef); + if ($personId) { + Log::info('EntityResolutionService: Found Person from existing Contract reference', [ + 'person_id' => $personId, + 'contract_reference' => $contractRef, + ]); + return $personId; + } + } + + // 4. Check for existing ClientCase by client_ref (before it's processed) + if ($clientRef = $mapped['client_case']['client_ref'] ?? null) { + $personId = $this->getPersonFromClientRef($import->client_id, $clientRef); + if ($personId) { + Log::info('EntityResolutionService: Found Person from existing ClientCase client_ref', [ + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + return $personId; + } + } + + // 5. Check for existing Person by contact values (email/phone/address) + $personId = $this->resolvePersonByContacts($mapped); + if ($personId) { + Log::info('EntityResolutionService: Found Person from contact values', [ + 'person_id' => $personId, + ]); + return $personId; + } + + // No existing Person found + return null; + } + + /** + * Check if ClientCase exists for this client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return bool + */ + public function clientCaseExists(?int $clientId, string $clientRef): bool + { + if (!$clientId || !$clientRef) { + return false; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->exists(); + } + + /** + * Check if Contract exists for this reference. + * + * @param int|null $clientId + * @param string $reference + * @return bool + */ + public function contractExists(?int $clientId, string $reference): bool + { + if (!$clientId || !$reference) { + return false; + } + + return Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->exists(); + } + + /** + * Get existing ClientCase by client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return ClientCase|null + */ + public function getExistingClientCase(?int $clientId, string $clientRef): ?ClientCase + { + if (!$clientId || !$clientRef) { + return null; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + } + + /** + * Get existing Contract by reference for this client. + * + * @param int|null $clientId + * @param string $reference + * @return Contract|null + */ + public function getExistingContract(?int $clientId, string $reference): ?Contract + { + if (!$clientId || !$reference) { + return null; + } + + return Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->select('contracts.*') + ->first(); + } + + /** + * Get Person ID from a Contract entity. + * + * @param Contract $contract + * @return int|null + */ + protected function getPersonFromContract(Contract $contract): ?int + { + if ($contract->client_case_id) { + return ClientCase::where('id', $contract->client_case_id) + ->value('person_id'); + } + + return null; + } + + /** + * Get Person ID from existing Contract by reference. + * + * @param int|null $clientId + * @param string $reference + * @return int|null + */ + protected function getPersonFromContractReference(?int $clientId, string $reference): ?int + { + if (!$clientId) { + return null; + } + + $clientCaseId = Contract::query() + ->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->where('contracts.reference', $reference) + ->value('contracts.client_case_id'); + + if ($clientCaseId) { + return ClientCase::where('id', $clientCaseId) + ->value('person_id'); + } + + return null; + } + + /** + * Get Person ID from existing ClientCase by client_ref. + * + * @param int|null $clientId + * @param string $clientRef + * @return int|null + */ + protected function getPersonFromClientRef(?int $clientId, string $clientRef): ?int + { + if (!$clientId) { + return null; + } + + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->value('person_id'); + } + + /** + * Resolve Person by contact values (email, phone, address). + * Checks existing contact records and returns associated Person ID. + * + * @param array $mapped + * @return int|null + */ + protected function resolvePersonByContacts(array $mapped): ?int + { + // Check email (support both single and array formats) + $email = $this->extractContactValue($mapped, 'email', 'value', 'emails'); + if ($email) { + $personId = Email::where('value', trim($email))->value('person_id'); + if ($personId) { + return $personId; + } + } + + // Check phone (support both single and array formats) + $phone = $this->extractContactValue($mapped, 'phone', 'nu', 'person_phones'); + if ($phone) { + $personId = PersonPhone::where('nu', trim($phone))->value('person_id'); + if ($personId) { + return $personId; + } + } + + // Check address (support both single and array formats) + $address = $this->extractContactValue($mapped, 'address', 'address', 'person_addresses'); + if ($address) { + $personId = PersonAddress::where('address', trim($address))->value('person_id'); + if ($personId) { + return $personId; + } + } + + return null; + } + + /** + * Extract contact value from mapped data, supporting multiple formats. + * + * @param array $mapped + * @param string $singularKey e.g., 'email', 'phone', 'address' + * @param string $field Field name within the contact data + * @param string $pluralKey e.g., 'emails', 'person_phones', 'person_addresses' + * @return string|null + */ + protected function extractContactValue(array $mapped, string $singularKey, string $field, string $pluralKey): ?string + { + // Try singular key first (e.g., 'email') + if (isset($mapped[$singularKey][$field])) { + return $mapped[$singularKey][$field]; + } + + // Try plural key (e.g., 'emails') + if (isset($mapped[$pluralKey])) { + // If it's an array of contacts + if (is_array($mapped[$pluralKey])) { + // Try first element if it's an indexed array + if (isset($mapped[$pluralKey][0][$field])) { + return $mapped[$pluralKey][0][$field]; + } + // Try direct field access if it's a single hash + if (isset($mapped[$pluralKey][$field])) { + return $mapped[$pluralKey][$field]; + } + } + } + + return null; + } + + /** + * Check if this row should skip Person creation based on existing entities. + * Used by PersonHandler to determine if Person already exists via chain. + * + * @param Import $import + * @param array $mapped + * @param array $context + * @return bool True if Person should be skipped (already exists) + */ + public function shouldSkipPersonCreation(Import $import, array $mapped, array $context): bool + { + // If we can resolve existing Person, we should skip creation + $personId = $this->resolvePersonFromContext($import, $mapped, $context); + + return $personId !== null; + } + + /** + * Get or create ClientCase for Contract creation. + * Reuses existing ClientCase if found by client_ref. + * + * @param Import $import + * @param array $mapped + * @param array $context + * @return int|null ClientCase ID + */ + public function resolveOrCreateClientCaseForContract(Import $import, array $mapped, array $context): ?int + { + $clientId = $import->client_id; + + if (!$clientId) { + return null; + } + + // If ClientCase already processed in this row, use it + if ($clientCaseId = $context['client_case']['entity']?->id ?? null) { + return $clientCaseId; + } + + // Try to find by client_ref + $clientRef = $mapped['client_case']['client_ref'] ?? $mapped['client_ref'] ?? null; + + if ($clientRef) { + $existing = $this->getExistingClientCase($clientId, $clientRef); + + if ($existing) { + Log::info('EntityResolutionService: Reusing existing ClientCase for Contract', [ + 'client_case_id' => $existing->id, + 'client_ref' => $clientRef, + ]); + + return $existing->id; + } + } + + // Need to create new ClientCase + // Get Person from context (should be processed before Contract now) + $personId = $context['person']['entity']?->id ?? null; + + if (!$personId) { + // Person wasn't in import or wasn't found, try to resolve + $personId = $this->resolvePersonFromContext($import, $mapped, $context); + + if (!$personId) { + // Create minimal Person as last resort + $personId = Person::create(['type_id' => 1])->id; + Log::info('EntityResolutionService: Created minimal Person for new ClientCase', [ + 'person_id' => $personId, + ]); + } + } + + $clientCase = ClientCase::create([ + 'client_id' => $clientId, + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + + Log::info('EntityResolutionService: Created new ClientCase', [ + 'client_case_id' => $clientCase->id, + 'person_id' => $personId, + 'client_ref' => $clientRef, + ]); + + return $clientCase->id; + } +} diff --git a/app/Services/Import/Handlers/AccountHandler.php b/app/Services/Import/Handlers/AccountHandler.php new file mode 100644 index 0000000..d76bc69 --- /dev/null +++ b/app/Services/Import/Handlers/AccountHandler.php @@ -0,0 +1,158 @@ +entity?->id ?? null; + + if (! $reference || ! $contractId) { + return null; + } + + return Account::where('contract_id', $contractId) + ->where('reference', $reference) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Ensure contract context + if (! isset($context['contract'])) { + return [ + 'action' => 'skipped', + 'message' => 'Account requires contract context', + ]; + } + + $contractId = $context['contract']->entity->id; + $mapped['contract_id'] = $contractId; + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Track old balance for activity creation + $oldBalance = (float) ($existing->balance_amount ?? 0); + + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + // Create activity if balance changed and tracking is enabled + if ($this->getOption('track_balance_changes', true) && array_key_exists('balance_amount', $appliedFields)) { + $this->createBalanceChangeActivity($existing, $oldBalance, $import, $context); + } + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new account + $account = new Account; + $payload = $this->buildPayload($mapped, $account); + $account->fill($payload); + $account->save(); + + return [ + 'action' => 'inserted', + 'entity' => $account, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fieldMap = [ + 'contract_id' => 'contract_id', + 'reference' => 'reference', + 'title' => 'title', + 'description' => 'description', + 'balance_amount' => 'balance_amount', + 'currency' => 'currency', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $payload[$target] = $mapped[$source]; + } + } + + return $payload; + } + + /** + * Create activity when account balance changes. + */ + protected function createBalanceChangeActivity(Account $account, float $oldBalance, Import $import, array $context): void + { + if (! $this->getOption('create_activity_on_balance_change', true)) { + return; + } + + try { + $newBalance = (float) ($account->balance_amount ?? 0); + + // Skip if balance didn't actually change + if ($newBalance === $oldBalance) { + return; + } + + $currency = \App\Models\PaymentSetting::first()?->default_currency ?? 'EUR'; + $beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency; + $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; + $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; + + // Get client_case_id + $clientCaseId = $account->contract?->client_case_id; + + if ($clientCaseId) { + // Use action_id from import meta if available + $metaActionId = (int) ($import->meta['action_id'] ?? 0); + + if ($metaActionId > 0) { + \App\Models\Activity::create([ + 'due_date' => null, + 'amount' => null, + 'note' => $note, + 'action_id' => $metaActionId, + 'decision_id' => $import->meta['decision_id'] ?? null, + 'client_case_id' => $clientCaseId, + 'contract_id' => $account->contract_id, + ]); + } + } + } catch (\Throwable $e) { + \Log::warning('Failed to create balance change activity', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + } + } +} \ No newline at end of file diff --git a/app/Services/Import/Handlers/ActivityHandler.php b/app/Services/Import/Handlers/ActivityHandler.php new file mode 100644 index 0000000..a2dbbf2 --- /dev/null +++ b/app/Services/Import/Handlers/ActivityHandler.php @@ -0,0 +1,171 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty + if (empty($note) || trim((string)$note) === '') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + // Activities typically don't have a unique reference for deduplication + // Override this method if you have specific deduplication logic + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple activities if note is an array + $notes = $mapped['note'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($notes)) { + $notes = [$notes]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + // Get context IDs once + $clientCaseId = $mapped['client_case_id'] ?? $context['contract']['entity']?->client_case_id ?? null; + $contractId = $mapped['contract_id'] ?? $context['contract']['entity']?->id ?? null; + + foreach ($notes as $note) { + // Skip if note is empty + if (empty($note) || trim((string)$note) === '') { + $skippedCount++; + continue; + } + + // Require at least client_case_id or contract_id based on options + $requireCase = $this->getOption('require_client_case', false); + $requireContract = $this->getOption('require_contract', false); + + if ($requireCase && ! $clientCaseId) { + $skippedCount++; + continue; + } + + if ($requireContract && ! $contractId) { + $skippedCount++; + continue; + } + + // Build activity payload for this note + $payload = ['note' => $note]; + $payload['client_case_id'] = $clientCaseId; + $payload['contract_id'] = $contractId; + + // Set action_id and decision_id from template meta if not in mapped data + if (!isset($mapped['action_id'])) { + $payload['action_id'] = $import->template->meta['activity_action_id'] ?? $this->getDefaultActionId(); + } else { + $payload['action_id'] = $mapped['action_id']; + } + + if (!isset($mapped['decision_id']) && isset($import->template->meta['activity_decision_id'])) { + $payload['decision_id'] = $import->template->meta['activity_decision_id']; + } + + // Create activity + $activity = new \App\Models\Activity; + $activity->fill($payload); + $activity->save(); + + $results[] = $activity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All activities empty or missing requirements', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['note', 'client_case_id', 'contract_id', 'action_id'], + 'count' => $insertedCount, + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map activity fields + if (isset($mapped['due_date'])) { + $payload['due_date'] = DateNormalizer::toDate((string) $mapped['due_date']); + } + + if (isset($mapped['amount'])) { + $payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount']; + } + + if (isset($mapped['note'])) { + $payload['note'] = $mapped['note']; + } + + if (isset($mapped['action_id'])) { + $payload['action_id'] = (int) $mapped['action_id']; + } + + if (isset($mapped['decision_id'])) { + $payload['decision_id'] = (int) $mapped['decision_id']; + } + + return $payload; + } + + /** + * Get default action ID (use minimum ID from actions table). + */ + private function getDefaultActionId(): int + { + return (int) (\App\Models\Action::min('id') ?? 1); + } +} diff --git a/app/Services/Import/Handlers/AddressHandler.php b/app/Services/Import/Handlers/AddressHandler.php new file mode 100644 index 0000000..44c8734 --- /dev/null +++ b/app/Services/Import/Handlers/AddressHandler.php @@ -0,0 +1,144 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty + if (empty($address) || trim((string)$address) === '') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $address = $mapped['address'] ?? null; + $personId = $mapped['person_id'] + ?? ($context['person']['entity']->id ?? null) + ?? ($context['person']?->entity?->id ?? null); + + if (! $address || ! $personId) { + return null; + } + + // Find existing address by exact match for this person + return PersonAddress::where('person_id', $personId) + ->where('address', $address) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple addresses if address is an array + $addresses = $mapped['address'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($addresses)) { + $addresses = [$addresses]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + foreach ($addresses as $address) { + // Skip if address is empty or blank + if (empty($address) || trim((string)$address) === '') { + $skippedCount++; + continue; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + $skippedCount++; + continue; + } + + $existing = $this->resolveAddress($address, $personId); + + // Check for duplicates if configured + if ($this->getOption('deduplicate', true) && $existing) { + $skippedCount++; + continue; + } + + // Create new address + $payload = $this->buildPayloadForAddress($address); + $payload['person_id'] = $personId; + + $addressEntity = new \App\Models\Person\PersonAddress; + $addressEntity->fill($payload); + $addressEntity->save(); + + $results[] = $addressEntity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All addresses empty or duplicates', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['address', 'person_id'], + 'count' => $insertedCount, + ]; + } + + protected function resolveAddress(string $address, int $personId): mixed + { + return \App\Models\Person\PersonAddress::where('person_id', $personId) + ->where('address', $address) + ->first(); + } + + protected function buildPayloadForAddress(string $address): array + { + return [ + 'address' => $address, + 'type_id' => 1, // Default to permanent address + ]; + } +} diff --git a/app/Services/Import/Handlers/CaseObjectHandler.php b/app/Services/Import/Handlers/CaseObjectHandler.php new file mode 100644 index 0000000..6d23db5 --- /dev/null +++ b/app/Services/Import/Handlers/CaseObjectHandler.php @@ -0,0 +1,96 @@ +first(); + if ($object) { + return $object; + } + } + + // Fall back to name if reference not found + if ($name) { + return CaseObject::where('name', $name)->first(); + } + + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update existing object + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new case object + $payload = $this->buildPayload($mapped, new CaseObject); + + $caseObject = new CaseObject; + $caseObject->fill($payload); + $caseObject->save(); + + return [ + 'action' => 'inserted', + 'entity' => $caseObject, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fields = ['reference', 'name', 'description', 'type', 'contract_id']; + + foreach ($fields as $field) { + if (array_key_exists($field, $mapped)) { + $payload[$field] = $mapped[$field]; + } + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/ClientCaseHandler.php b/app/Services/Import/Handlers/ClientCaseHandler.php new file mode 100644 index 0000000..e3edc1b --- /dev/null +++ b/app/Services/Import/Handlers/ClientCaseHandler.php @@ -0,0 +1,163 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return ClientCase::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + $clientRef = $mapped['client_ref'] ?? null; + $clientId = $context['import']?->client_id ?? null; + + if (! $clientRef || ! $clientId) { + return null; + } + + // Find existing case by client_ref for this client + return ClientCase::where('client_id', $clientId) + ->where('client_ref', $clientRef) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $clientId = $import->client_id ?? null; + + if (! $clientId) { + return [ + 'action' => 'skipped', + 'message' => 'ClientCase requires client_id', + ]; + } + + // PHASE 5: Use Person from context (already processed due to reversed priorities) + // Priority order: explicit person_id > context person > resolved person + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + // If no Person in context, try to resolve using EntityResolutionService + if (!$personId) { + $personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context); + + if ($personId) { + Log::info('ClientCaseHandler: Resolved Person via EntityResolutionService', [ + 'person_id' => $personId, + ]); + } else { + Log::warning('ClientCaseHandler: No Person found in context or via resolution', [ + 'has_person_context' => isset($context['person']), + 'has_mapped_person_id' => isset($mapped['person_id']), + ]); + } + } else { + Log::info('ClientCaseHandler: Using Person from context/mapping', [ + 'person_id' => $personId, + 'source' => $mapped['person_id'] ? 'mapped' : 'context', + ]); + } + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update if configured + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'ClientCase already exists (skip mode)', + ]; + } + + $payload = $this->buildPayload($mapped, $existing); + + // Update person_id if provided and different + if ($personId && $existing->person_id !== $personId) { + $payload['person_id'] = $personId; + } + + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + Log::info('ClientCaseHandler: Updated existing ClientCase', [ + 'client_case_id' => $existing->id, + 'person_id' => $existing->person_id, + 'applied_fields' => $appliedFields, + ]); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new client case + $payload = $this->buildPayload($mapped, new ClientCase); + $payload['client_id'] = $clientId; + + if ($personId) { + $payload['person_id'] = $personId; + } + + $clientCase = new ClientCase; + $clientCase->fill($payload); + $clientCase->save(); + + Log::info('ClientCaseHandler: Created new ClientCase', [ + 'client_case_id' => $clientCase->id, + 'person_id' => $clientCase->person_id, + 'client_ref' => $clientCase->client_ref, + ]); + + return [ + 'action' => 'inserted', + 'entity' => $clientCase, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fields = ['client_ref']; + + foreach ($fields as $field) { + if (array_key_exists($field, $mapped)) { + $payload[$field] = $mapped[$field]; + } + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/ContractHandler.php b/app/Services/Import/Handlers/ContractHandler.php new file mode 100644 index 0000000..44f5fb7 --- /dev/null +++ b/app/Services/Import/Handlers/ContractHandler.php @@ -0,0 +1,343 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return Contract::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + $reference = $mapped['reference'] ?? null; + + if (! $reference) { + return null; + } + + $query = Contract::query(); + + // Scope by client if available + if ($clientId = $context['import']->client_id) { + $query->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id') + ->where('client_cases.client_id', $clientId) + ->select('contracts.*'); + } + + return $query->where('contracts.reference', $reference)->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // PHASE 4: Check for existing Contract early to prevent duplicate creation + $reference = $mapped['reference'] ?? null; + + if ($reference) { + $existingContract = $this->resolutionService->getExistingContract( + $import->client_id, + $reference + ); + + if ($existingContract) { + Log::info('ContractHandler: Found existing Contract by reference', [ + 'contract_id' => $existingContract->id, + 'reference' => $reference, + ]); + + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existingContract, + 'message' => 'Contract already exists (skip mode)', + ]; + } + + // Update existing contract + $payload = $this->buildPayload($mapped, $existingContract); + $payload = $this->mergeJsonFields($payload, $existingContract); + $appliedFields = $this->trackAppliedFields($existingContract, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existingContract, + 'message' => 'No changes detected', + ]; + } + + $existingContract->fill($payload); + $existingContract->save(); + + return [ + 'action' => 'updated', + 'entity' => $existingContract, + 'applied_fields' => $appliedFields, + ]; + } + } + + $existing = $this->resolve($mapped, $context); + + // Check for reactivation request + $reactivate = $this->shouldReactivate($context); + + // Handle reactivation if entity is soft-deleted or inactive + if ($existing && $reactivate && $this->needsReactivation($existing)) { + $reactivated = $this->attemptReactivation($existing, $context); + if ($reactivated) { + return [ + 'action' => 'reactivated', + 'entity' => $existing, + 'message' => 'Contract reactivated', + ]; + } + } + + // Determine if we should update or skip based on mode + $mode = $this->getOption('update_mode', 'update'); + + if ($existing) { + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Contract already exists (skip mode)', + ]; + } + + // Update + $payload = $this->buildPayload($mapped, $existing); + + // Merge JSON fields instead of overwriting + $payload = $this->mergeJsonFields($payload, $existing); + + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new contract + $contract = new Contract; + $payload = $this->buildPayload($mapped, $contract); + + // Get client_case_id from context or mapped data + $clientCaseId = $mapped['client_case_id'] + ?? $context['client_case']?->entity?->id + ?? null; + + // If no client_case_id, try to create/find one automatically (using EntityResolutionService) + if (!$clientCaseId) { + // Add mapped data to context for EntityResolutionService + $context['mapped'] = $mapped; + $clientCaseId = $this->findOrCreateClientCaseId($context); + } + + if (!$clientCaseId) { + return [ + 'action' => 'invalid', + 'message' => 'Contract requires client_case_id (import must have client_id)', + ]; + } + + $payload['client_case_id'] = $clientCaseId; + + // Ensure required defaults + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultContractTypeId(); + } + if (!isset($payload['start_date'])) { + $payload['start_date'] = now()->toDateString(); + } + + $contract->fill($payload); + $contract->save(); + + return [ + 'action' => 'inserted', + 'entity' => $contract, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map fields according to contract schema + $fieldMap = [ + 'reference' => 'reference', + 'title' => 'title', + 'description' => 'description', + 'amount' => 'amount', + 'currency' => 'currency', + 'start_date' => 'start_date', + 'end_date' => 'end_date', + 'active' => 'active', + 'type_id' => 'type_id', + 'client_case_id' => 'client_case_id', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $payload[$target] = $mapped[$source]; + } + } + + return $payload; + } + + private function getDefaultContractTypeId(): int + { + return (int) (\App\Models\ContractType::min('id') ?? 1); + } + + /** + * Check if reactivation should be attempted. + */ + protected function shouldReactivate(array $context): bool + { + // Row-level reactivate column takes precedence + if (isset($context['raw']['reactivate'])) { + return filter_var($context['raw']['reactivate'], FILTER_VALIDATE_BOOLEAN); + } + + // Then import-level + if (isset($context['import']->reactivate)) { + return (bool) $context['import']->reactivate; + } + + // Finally template-level + if (isset($context['import']->template?->reactivate)) { + return (bool) $context['import']->template->reactivate; + } + + return false; + } + + /** + * Check if entity needs reactivation. + */ + protected function needsReactivation($entity): bool + { + return $entity->active == 0 || $entity->deleted_at !== null; + } + + /** + * Attempt to reactivate soft-deleted or inactive contract. + */ + protected function attemptReactivation(Contract $contract, array $context): bool + { + if (! $this->getOption('supports_reactivation', false)) { + return false; + } + + try { + if ($contract->trashed()) { + $contract->restore(); + } + + $contract->update(['active' => 1]); + + return true; + } catch (\Throwable $e) { + \Log::error('Contract reactivation failed', [ + 'contract_id' => $contract->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Merge JSON fields instead of overwriting. + */ + protected function mergeJsonFields(array $payload, $existing): array + { + $mergeFields = $this->getOption('merge_json_fields', []); + + foreach ($mergeFields as $field) { + if (isset($payload[$field]) && isset($existing->{$field})) { + $existingData = is_array($existing->{$field}) ? $existing->{$field} : []; + $newData = is_array($payload[$field]) ? $payload[$field] : []; + $payload[$field] = array_merge($existingData, $newData); + } + } + + return $payload; + } + + /** + * Find or create a ClientCase for this contract (using EntityResolutionService). + */ + protected function findOrCreateClientCaseId(array $context): ?int + { + $import = $context['import'] ?? null; + $mapped = $context['mapped'] ?? []; + $clientId = $import?->client_id ?? null; + + if (!$clientId) { + return null; + } + + // PHASE 4: Use EntityResolutionService to resolve or create ClientCase + // This will reuse existing ClientCase when possible + $clientCaseId = $this->resolutionService->resolveOrCreateClientCaseForContract( + $import, + $mapped, + $context + ); + + if ($clientCaseId) { + Log::info('ContractHandler: Resolved/Created ClientCase for Contract', [ + 'client_case_id' => $clientCaseId, + ]); + } + + return $clientCaseId; + } + + /** + * Generate a unique client_ref. + */ + protected function generateClientRef(int $clientId): string + { + $timestamp = now()->format('ymdHis'); + $random = substr(md5(uniqid()), 0, 4); + return "C{$clientId}-{$timestamp}-{$random}"; + } +} \ No newline at end of file diff --git a/app/Services/Import/Handlers/EmailHandler.php b/app/Services/Import/Handlers/EmailHandler.php new file mode 100644 index 0000000..89c9934 --- /dev/null +++ b/app/Services/Import/Handlers/EmailHandler.php @@ -0,0 +1,117 @@ + true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $value = $mapped['value'] ?? null; + + if (! $value) { + return null; + } + + return Email::where('value', strtolower(trim($value)))->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Skip if email is empty or blank + $email = $mapped['value'] ?? null; + if (empty($email) || trim((string)$email) === '') { + return [ + 'action' => 'skipped', + 'message' => 'Email is empty', + ]; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + return [ + 'action' => 'skipped', + 'message' => 'Email requires person_id', + ]; + } + + $existing = $this->resolve($mapped, $context); + + // Check for duplicates if configured + if ($this->getOption('deduplicate', true) && $existing) { + // Update person_id if different + if ($existing->person_id !== $personId) { + $existing->person_id = $personId; + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => ['person_id'], + ]; + } + + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Email already exists', + ]; + } + + // Create new email + $payload = $this->buildPayload($mapped, new Email); + $payload['person_id'] = $personId; + + $email = new Email; + $email->fill($payload); + $email->save(); + + return [ + 'action' => 'inserted', + 'entity' => $email, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + if (isset($mapped['value'])) { + $payload['value'] = strtolower(trim($mapped['value'])); + } + + if (isset($mapped['is_primary'])) { + $payload['is_primary'] = (bool) $mapped['is_primary']; + } + + if (isset($mapped['label'])) { + $payload['label'] = $mapped['label']; + } + + return $payload; + } +} diff --git a/app/Services/Import/Handlers/PaymentHandler.php b/app/Services/Import/Handlers/PaymentHandler.php new file mode 100644 index 0000000..4f280b5 --- /dev/null +++ b/app/Services/Import/Handlers/PaymentHandler.php @@ -0,0 +1,224 @@ + true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null; + $reference = $mapped['reference'] ?? null; + + if (! $accountId || ! $reference) { + return null; + } + + return Payment::where('account_id', $accountId) + ->where('reference', $reference) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Skip if amount is empty or invalid + $amount = $mapped['amount'] ?? null; + if (empty($amount) || !is_numeric($amount)) { + return [ + 'action' => 'skipped', + 'message' => 'Payment amount is empty or invalid', + ]; + } + + // Resolve account - either from mapped data or context + $accountId = $mapped['account_id'] ?? $context['account']?->entity?->id ?? null; + + if (! $accountId) { + return [ + 'action' => 'skipped', + 'message' => 'Payment requires an account', + ]; + } + + // Check for duplicates if configured + if ($this->getOption('deduplicate_by', [])) { + $existing = $this->resolve($mapped, ['account' => (object) ['entity' => (object) ['id' => $accountId]]]); + if ($existing) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Payment already exists (duplicate by reference)', + ]; + } + } + + // Build payment payload + $payload = $this->buildPayload($mapped, new Payment); + $payload['account_id'] = $accountId; + $payload['created_by'] = $context['user']?->getAuthIdentifier(); + + // Get account balance before payment + $account = Account::find($accountId); + $balanceBefore = $account ? (float) ($account->balance_amount ?? 0) : 0; + + // Create payment + $payment = new Payment; + $payment->fill($payload); + $payment->balance_before = $balanceBefore; + + try { + $payment->save(); + } catch (\Throwable $e) { + // Handle unique constraint violations gracefully + if (str_contains($e->getMessage(), 'payments_account_id_reference_unique')) { + return [ + 'action' => 'skipped', + 'message' => 'Payment duplicate detected (database constraint)', + ]; + } + throw $e; + } + + // Create booking if configured + if ($this->getOption('create_booking', true) && isset($payment->amount)) { + try { + Booking::create([ + 'account_id' => $accountId, + 'payment_id' => $payment->id, + 'amount_cents' => (int) round(((float) $payment->amount) * 100), + 'type' => 'credit', + 'description' => $payment->reference ? ('Plačilo '.$payment->reference) : 'Plačilo', + 'booked_at' => $payment->paid_at ?? now(), + ]); + } catch (\Throwable $e) { + Log::warning('Failed to create booking for payment', [ + 'payment_id' => $payment->id, + 'error' => $e->getMessage(), + ]); + } + } + + // Create activity if configured + if ($this->getOption('create_activity', false)) { + $this->createPaymentActivity($payment, $account, $balanceBefore); + } + + return [ + 'action' => 'inserted', + 'entity' => $payment, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + // Map payment fields + if (isset($mapped['reference'])) { + $payload['reference'] = is_string($mapped['reference']) ? trim($mapped['reference']) : $mapped['reference']; + } + + // Handle amount - support both amount and amount_cents + if (array_key_exists('amount', $mapped)) { + $payload['amount'] = is_string($mapped['amount']) ? (float) str_replace(',', '.', $mapped['amount']) : (float) $mapped['amount']; + } elseif (array_key_exists('amount_cents', $mapped)) { + $payload['amount'] = ((int) $mapped['amount_cents']) / 100.0; + } + + // Payment date - support both paid_at and payment_date + $dateValue = $mapped['paid_at'] ?? $mapped['payment_date'] ?? null; + if ($dateValue) { + $payload['paid_at'] = DateNormalizer::toDate((string) $dateValue); + } + + $payload['currency'] = $mapped['currency'] ?? 'EUR'; + + // Handle meta + $meta = []; + if (is_array($mapped['meta'] ?? null)) { + $meta = $mapped['meta']; + } + if (! empty($mapped['payment_nu'])) { + $meta['payment_nu'] = trim((string) $mapped['payment_nu']); + } + if (! empty($meta)) { + $payload['meta'] = $meta; + } + + return $payload; + } + + protected function createPaymentActivity(Payment $payment, ?Account $account, float $balanceBefore): void + { + try { + $settings = PaymentSetting::first(); + if (! $settings || ! ($settings->create_activity_on_payment ?? false)) { + return; + } + + $amountCents = (int) round(((float) $payment->amount) * 100); + $note = $settings->activity_note_template ?? 'Prejeto plačilo'; + $note = str_replace( + ['{amount}', '{currency}'], + [number_format($amountCents / 100, 2, ',', '.'), $payment->currency ?? 'EUR'], + $note + ); + + // Get updated balance + $account?->refresh(); + $balanceAfter = $account ? (float) ($account->balance_amount ?? 0) : 0; + + $beforeStr = number_format($balanceBefore, 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $afterStr = number_format($balanceAfter, 2, ',', '.').' '.($payment->currency ?? 'EUR'); + $note .= " (Stanje pred: {$beforeStr}, Stanje po: {$afterStr}; Izvor: plačilo)"; + + // Resolve client_case_id + $account?->loadMissing('contract'); + $clientCaseId = $account?->contract?->client_case_id; + + if ($clientCaseId) { + $activity = \App\Models\Activity::create([ + 'due_date' => null, + 'amount' => $amountCents / 100, + 'note' => $note, + 'action_id' => $settings->default_action_id, + 'decision_id' => $settings->default_decision_id, + 'client_case_id' => $clientCaseId, + 'contract_id' => $account->contract_id, + ]); + $payment->update(['activity_id' => $activity->id]); + } + } catch (\Throwable $e) { + Log::warning('Failed to create activity for payment', [ + 'payment_id' => $payment->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Import/Handlers/PersonHandler.php b/app/Services/Import/Handlers/PersonHandler.php new file mode 100644 index 0000000..e4ba4fe --- /dev/null +++ b/app/Services/Import/Handlers/PersonHandler.php @@ -0,0 +1,187 @@ +resolutionService = new EntityResolutionService(); + } + + public function getEntityClass(): string + { + return Person::class; + } + + public function resolve(array $mapped, array $context = []): mixed + { + // PHASE 3: Use EntityResolutionService to check chain-based deduplication + // This prevents creating duplicate Persons when Contract/ClientCase already exists + $import = $context['import'] ?? null; + + if ($import) { + $personId = $this->resolutionService->resolvePersonFromContext($import, $mapped, $context); + + if ($personId) { + $person = Person::find($personId); + + if ($person) { + Log::info('PersonHandler: Resolved existing Person via chain', [ + 'person_id' => $personId, + 'resolution_method' => 'EntityResolutionService', + ]); + + return $person; + } + } + } + + // Fall back to configured deduplication fields (tax_number, SSN) + $dedupeBy = $this->getOption('deduplicate_by', ['tax_number', 'social_security_number']); + + foreach ($dedupeBy as $field) { + if (! empty($mapped[$field])) { + $person = Person::where($field, $mapped[$field])->first(); + if ($person) { + Log::info('PersonHandler: Resolved existing Person by identifier', [ + 'person_id' => $person->id, + 'field' => $field, + ]); + + return $person; + } + } + } + + return null; + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Add import to context for EntityResolutionService + $context['import'] = $import; + + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update if configured + $mode = $this->getOption('update_mode', 'update'); + + if ($mode === 'skip') { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'Person already exists (skip mode)', + ]; + } + + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create new person + Log::info('PersonHandler: Creating new Person (no existing entity found)', [ + 'has_tax_number' => !empty($mapped['tax_number']), + 'has_ssn' => !empty($mapped['social_security_number']), + 'has_contract' => isset($context['contract']), + 'has_client_case' => isset($context['client_case']), + ]); + + $person = new Person; + $payload = $this->buildPayload($mapped, $person); + + // Ensure required foreign keys have defaults + if (!isset($payload['group_id'])) { + $payload['group_id'] = $this->getDefaultPersonGroupId(); + } + if (!isset($payload['type_id'])) { + $payload['type_id'] = $this->getDefaultPersonTypeId(); + } + + $person->fill($payload); + $person->save(); + + Log::info('PersonHandler: Created new Person', [ + 'person_id' => $person->id, + ]); + + return [ + 'action' => 'inserted', + 'entity' => $person, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + $payload = []; + + $fieldMap = [ + 'first_name' => 'first_name', + 'last_name' => 'last_name', + 'full_name' => 'full_name', + 'gender' => 'gender', + 'birthday' => 'birthday', + 'tax_number' => 'tax_number', + 'social_security_number' => 'social_security_number', + 'description' => 'description', + 'group_id' => 'group_id', + 'type_id' => 'type_id', + ]; + + foreach ($fieldMap as $source => $target) { + if (array_key_exists($source, $mapped)) { + $value = $mapped[$source]; + + // Normalize date fields + if ($source === 'birthday' && $value) { + $value = DateNormalizer::toDate((string) $value); + } + + $payload[$target] = $value; + } + } + + return $payload; + } + + private function getDefaultPersonGroupId(): int + { + return (int) (PersonGroup::min('id') ?? 1); + } + + private function getDefaultPersonTypeId(): int + { + return (int) (PersonType::min('id') ?? 1); + } +} diff --git a/app/Services/Import/Handlers/PhoneHandler.php b/app/Services/Import/Handlers/PhoneHandler.php new file mode 100644 index 0000000..a8c88df --- /dev/null +++ b/app/Services/Import/Handlers/PhoneHandler.php @@ -0,0 +1,153 @@ + true, 'errors' => []]; + } + // Skip parent validation for arrays - we'll validate in process() + return ['valid' => true, 'errors' => []]; + } + + // Single value - check if empty or invalid + if (empty($phone) || trim((string)$phone) === '' || $phone === '0') { + return ['valid' => true, 'errors' => []]; + } + + return parent::validate($mapped); + } + + public function resolve(array $mapped, array $context = []): mixed + { + $nu = $mapped['nu'] ?? null; + $personId = $mapped['person_id'] + ?? ($context['person']['entity']->id ?? null) + ?? ($context['person']?->entity?->id ?? null); + + if (! $nu || ! $personId) { + return null; + } + + // Normalize phone number for comparison + $normalizedNu = $this->normalizePhoneNumber($nu); + + // Find existing phone by normalized number for this person + return PersonPhone::where('person_id', $personId) + ->where('nu', $normalizedNu) + ->first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + // Handle multiple phones if nu is an array + $phones = $mapped['nu'] ?? null; + + // If single value, convert to array for uniform processing + if (!is_array($phones)) { + $phones = [$phones]; + } + + $results = []; + $insertedCount = 0; + $skippedCount = 0; + + foreach ($phones as $phone) { + // Skip if phone number is empty or blank or '0' + if (empty($phone) || trim((string)$phone) === '' || $phone === '0') { + $skippedCount++; + continue; + } + + // Resolve person_id from context + $personId = $mapped['person_id'] ?? $context['person']['entity']?->id ?? null; + + if (! $personId) { + $skippedCount++; + continue; + } + + // Normalize phone number + $normalizedPhone = $this->normalizePhoneNumber($phone); + + $existing = $this->resolvePhone($normalizedPhone, $personId); + + // Check for duplicates if configured + if ($this->getOption('deduplicate', true) && $existing) { + $skippedCount++; + continue; + } + + // Create new phone + $payload = [ + 'nu' => $normalizedPhone, + 'person_id' => $personId, + 'type_id' => 1, // Default to mobile + ]; + + $phoneEntity = new PersonPhone; + $phoneEntity->fill($payload); + $phoneEntity->save(); + + $results[] = $phoneEntity; + $insertedCount++; + } + + if ($insertedCount === 0 && $skippedCount > 0) { + return [ + 'action' => 'skipped', + 'message' => 'All phones empty, invalid or duplicates', + ]; + } + + return [ + 'action' => 'inserted', + 'entity' => $results[0] ?? null, + 'entities' => $results, + 'applied_fields' => ['nu', 'person_id'], + 'count' => $insertedCount, + ]; + } + + protected function resolvePhone(string $normalizedPhone, int $personId): mixed + { + return PersonPhone::where('person_id', $personId) + ->where('nu', $normalizedPhone) + ->first(); + } + + /** + * Normalize phone number by removing spaces, dashes, and parentheses. + */ + protected function normalizePhoneNumber(string $phone): string + { + return preg_replace('/[\s\-\(\)]/', '', $phone); + } +} diff --git a/app/Services/Import/ImportServiceV2.php b/app/Services/Import/ImportServiceV2.php new file mode 100644 index 0000000..6b448a4 --- /dev/null +++ b/app/Services/Import/ImportServiceV2.php @@ -0,0 +1,759 @@ +loadTemplateMeta($import); + + // Load entity configurations and handlers + $this->loadEntityConfigurations(); + + // Only CSV/TSV supported for now + if (! in_array($import->source_type, ['csv', 'txt'])) { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_skipped', + 'level' => 'warning', + 'message' => 'Only CSV/TXT supported in v2 processor.', + ]); + $import->update(['status' => 'completed', 'finished_at' => now()]); + + return compact('total', 'imported', 'skipped', 'invalid'); + } + + $import->update(['status' => 'processing', 'started_at' => $started]); + + $filePath = $import->path; + if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); + $fh = fopen($fullPath, 'r'); + + if (! $fh) { + throw new \RuntimeException("Could not open file: {$filePath}"); + } + + $meta = $import->meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; + + $mappings = $this->loadMappings($import); + $header = null; + $rowNum = 0; + + // Read header if present + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter); + $rowNum++; + } + + $isPg = DB::connection()->getDriverName() === 'pgsql'; + + while (($row = fgetcsv($fh, 0, $delimiter)) !== false) { + $rowNum++; + $total++; + + try { + $rawAssoc = $this->buildRowAssoc($row, $header); + + // Skip empty rows + if ($this->rowIsEffectivelyEmpty($rawAssoc)) { + $skipped++; + continue; + } + + $mapped = $this->applyMappings($rawAssoc, $mappings); + + $rawSha1 = sha1(json_encode($rawAssoc)); + $importRow = ImportRow::create([ + 'import_id' => $import->id, + 'row_number' => $rowNum, + 'record_type' => $this->determineRecordType($mapped), + 'raw_data' => $rawAssoc, + 'mapped_data' => $mapped, + 'status' => 'valid', + 'raw_sha1' => $rawSha1, + ]); + + // Process entities in priority order within a transaction + $context = ['import' => $import, 'user' => $user, 'import_row' => $importRow]; + + DB::beginTransaction(); + try { + $results = $this->processRow($import, $mapped, $rawAssoc, $context); + + // If processing succeeded, commit the transaction + if ($results['status'] === 'imported' || $results['status'] === 'skipped') { + DB::commit(); + } else { + DB::rollBack(); + } + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + // Collect entity details from results + $entityData = $this->collectEntityDetails($results); + $entityDetails = $entityData['details']; + $hasErrors = $entityData['hasErrors']; + $hasWarnings = $entityData['hasWarnings']; + + // Handle different result statuses + if ($results['status'] === 'imported') { + $imported++; + $importRow->update([ + 'status' => 'imported', + 'entity_type' => $results['entity_type'] ?? null, + 'entity_id' => $results['entity_id'] ?? null, + ]); + $this->createRowProcessedEvent($import, $user, $rowNum, $entityDetails, $hasWarnings, $rawAssoc); + } elseif ($results['status'] === 'skipped') { + $skipped++; + $importRow->update(['status' => 'skipped']); + $this->createRowSkippedEvent($import, $user, $rowNum, $entityDetails, $rawAssoc); + } else { + $invalid++; + $importRow->update([ + 'status' => 'invalid', + 'errors' => $results['errors'] ?? ['Processing failed'], + ]); + $this->createRowFailedEvent( + $import, + $user, + $rowNum, + $results['errors'] ?? ['Processing failed'], + $entityDetails, + $rawAssoc + ); + } + } catch (\Throwable $e) { + $invalid++; + $this->handleRowException($import, $user, $rowNum, $e); + } + } + + fclose($fh); + + $this->finalizeImport($import, $user, $total, $imported, $skipped, $invalid); + } catch (\Throwable $e) { + $this->handleFatalException($import, $user, $e); + throw $e; + } + + return compact('total', 'imported', 'skipped', 'invalid'); + } + + /** + * Load entity configurations from database. + */ + protected function loadEntityConfigurations(): void + { + $entities = ImportEntity::where('is_active', true) + ->orderBy('priority', 'desc') + ->get(); + + foreach ($entities as $entity) { + $this->entityConfigs[$entity->canonical_root] = $entity; + + // Instantiate handler if specified + if ($entity->handler_class && class_exists($entity->handler_class)) { + $this->handlers[$entity->canonical_root] = new $entity->handler_class($entity); + } + } + } + + /** + * Load mappings for import. + */ + protected function loadMappings(Import $import) + { + return DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->get(); + } + + /** + * Build associative array from row. + */ + protected function buildRowAssoc(array $row, ?array $header): array + { + if ($header) { + $result = []; + foreach ($header as $idx => $col) { + $result[$col] = $row[$idx] ?? null; + } + + return $result; + } + + return array_combine(range(0, count($row) - 1), $row); + } + + /** + * Check if row is effectively empty. + */ + protected function rowIsEffectivelyEmpty(array $raw): bool + { + foreach ($raw as $val) { + if (! is_null($val) && trim((string) $val) !== '') { + return false; + } + } + + return true; + } + + /** + * Apply mappings to raw data. + */ + protected function applyMappings(array $raw, $mappings): array + { + $mapped = []; + + // Group mappings by target field to handle concatenation + $groupedMappings = []; + foreach ($mappings as $mapping) { + $targetField = $mapping->target_field; + if (!isset($groupedMappings[$targetField])) { + $groupedMappings[$targetField] = []; + } + $groupedMappings[$targetField][] = $mapping; + } + + foreach ($groupedMappings as $targetField => $fieldMappings) { + // Group by group number from options + $valuesByGroup = []; + + foreach ($fieldMappings as $mapping) { + $sourceCol = $mapping->source_column; + + if (!isset($raw[$sourceCol])) { + continue; + } + + $value = $raw[$sourceCol]; + + // Apply transform + if ($mapping->transform) { + $value = $this->applyTransform($value, $mapping->transform); + } + + // Get group from options + $options = $mapping->options ? json_decode($mapping->options, true) : []; + $group = $options['group'] ?? null; + + // Group values by their group number + if ($group !== null) { + // Same group = concatenate + if (!isset($valuesByGroup[$group])) { + $valuesByGroup[$group] = []; + } + $valuesByGroup[$group][] = $value; + } else { + // No group = each gets its own group + $valuesByGroup[] = [$value]; + } + } + + // Now set the values + foreach ($valuesByGroup as $values) { + if (count($values) === 1) { + // Single value - set directly + $this->setNestedValue($mapped, $targetField, $values[0]); + } else { + // Multiple values in same group - concatenate with newline + $concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== '')); + if (!empty($concatenated)) { + $this->setNestedValue($mapped, $targetField, $concatenated); + } + } + } + } + + return $mapped; + } + + /** + * Apply transform to value. + */ + protected function applyTransform(mixed $value, string $transform): mixed + { + return match (strtolower($transform)) { + 'trim' => is_string($value) ? trim($value) : $value, + 'upper' => is_string($value) ? strtoupper($value) : $value, + 'lower' => is_string($value) ? strtolower($value) : $value, + 'date' => $this->normalizeDate($value), + default => $value, + }; + } + + /** + * Normalize date value. + */ + protected function normalizeDate(mixed $value): ?string + { + if (empty($value)) { + return null; + } + + try { + return DateNormalizer::toDate((string) $value); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Set nested value in array using dot notation. + * If the key already exists, convert to array and append the new value. + */ + protected function setNestedValue(array &$array, string $key, mixed $value): void + { + $keys = explode('.', $key); + $current = &$array; + + foreach ($keys as $i => $k) { + if ($i === count($keys) - 1) { + // If key already exists, convert to array and append + if (isset($current[$k])) { + // Convert existing single value to array if needed + if (!is_array($current[$k])) { + $current[$k] = [$current[$k]]; + } + // Append new value + $current[$k][] = $value; + } else { + // Set as single value + $current[$k] = $value; + } + } else { + if (! isset($current[$k]) || ! is_array($current[$k])) { + $current[$k] = []; + } + $current = &$current[$k]; + } + } + } + + /** + * Determine record type from mapped data. + */ + protected function determineRecordType(array $mapped): string + { + if (isset($mapped['payment'])) { + return 'payment'; + } + if (isset($mapped['activity'])) { + return 'activity'; + } + if (isset($mapped['contract'])) { + return 'contract'; + } + if (isset($mapped['account'])) { + return 'account'; + } + + return 'contact'; + } + + /** + * Process a single row through all entity handlers. + */ + protected function processRow(Import $import, array $mapped, array $raw, array $context): array + { + $entityResults = []; + $lastEntityType = null; + $lastEntityId = null; + $hasErrors = false; + + // Process entities in configured priority order + foreach ($this->entityConfigs as $root => $config) { + // Check if this entity exists in mapped data (support aliases) + $mappedKey = $this->findMappedKey($mapped, $root, $config); + + if (!$mappedKey || !isset($mapped[$mappedKey])) { + continue; + } + + $handler = $this->handlers[$root] ?? null; + + if (! $handler) { + continue; + } + + try { + // Validate before processing + $validation = $handler->validate($mapped[$mappedKey]); + if (! $validation['valid']) { + $entityResults[$root] = [ + 'action' => 'invalid', + 'errors' => $validation['errors'], + 'level' => 'error', + ]; + $hasErrors = true; + + // Don't stop processing, continue to other entities to collect all errors + continue; + } + + // Pass previous results as context + $result = $handler->process($import, $mapped[$mappedKey], $raw, array_merge($context, $entityResults)); + + $entityResults[$root] = $result; + + // Track last successful entity for row status + if (in_array($result['action'] ?? null, ['inserted', 'updated'])) { + $lastEntityType = $handler->getEntityClass(); + $lastEntityId = $result['entity']?->id ?? null; + } + } catch (\Throwable $e) { + $hasErrors = true; + + Log::error("Handler failed for entity {$root}", [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $entityResults[$root] = [ + 'action' => 'failed', + 'level' => 'error', + 'errors' => [$e->getMessage()], + 'exception' => [ + 'message' => $e->getMessage(), + 'file' => basename($e->getFile()), + 'line' => $e->getLine(), + 'class' => get_class($e), + ], + ]; + + // Continue to process other entities to collect all errors + continue; + } + } + + // If we had errors, return invalid status + if ($hasErrors) { + $allErrors = []; + foreach ($entityResults as $root => $result) { + if (isset($result['errors'])) { + $allErrors[] = "{$root}: " . implode(', ', $result['errors']); + } + } + + return [ + 'status' => 'invalid', + 'errors' => $allErrors, + 'results' => $entityResults, + ]; + } + + return [ + 'status' => $lastEntityId ? 'imported' : 'skipped', + 'entity_type' => $lastEntityType, + 'entity_id' => $lastEntityId, + 'results' => $entityResults, + ]; + } + + /** + * Find the key in mapped data that corresponds to this canonical root. + */ + protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string + { + // First check canonical_root itself + if (isset($mapped[$canonicalRoot])) { + return $canonicalRoot; + } + + // Then check key (e.g., 'contracts', 'person_addresses') + if (isset($mapped[$config->key])) { + return $config->key; + } + + // Then check aliases + $aliases = $config->aliases ?? []; + foreach ($aliases as $alias) { + if (isset($mapped[$alias])) { + return $alias; + } + } + + return null; + } + /** + * Load template meta flags for special processing modes. + */ + protected function loadTemplateMeta(Import $import): void + { + $this->templateMeta = optional($import->template)->meta ?? []; + $this->paymentsImport = (bool) ($this->templateMeta['payments_import'] ?? false); + $this->historyImport = (bool) ($this->templateMeta['history_import'] ?? false); + $this->contractKeyMode = $this->templateMeta['contract_key_mode'] ?? null; + } + + /** + * Collect entity details from processing results. + */ + protected function collectEntityDetails(array $results): array + { + $entityDetails = []; + $hasErrors = false; + $hasWarnings = false; + + if (isset($results['results']) && is_array($results['results'])) { + foreach ($results['results'] as $entityKey => $result) { + $action = $result['action'] ?? 'unknown'; + $message = $result['message'] ?? null; + $count = $result['count'] ?? 1; + + $detail = [ + 'entity' => $entityKey, + 'action' => $action, + 'count' => $count, + ]; + + if ($message) { + $detail['message'] = $message; + } + + if ($action === 'invalid' || isset($result['errors'])) { + $detail['level'] = 'error'; + $detail['errors'] = $result['errors'] ?? []; + $hasErrors = true; + } elseif ($action === 'skipped') { + $detail['level'] = 'warning'; + $hasWarnings = true; + } else { + $detail['level'] = 'info'; + } + + if (isset($result['exception'])) { + $detail['exception'] = $result['exception']; + $hasErrors = true; + } + + $entityDetails[] = $detail; + } + } + + return [ + 'details' => $entityDetails, + 'hasErrors' => $hasErrors, + 'hasWarnings' => $hasWarnings, + ]; + } + + /** + * Create a success event for a processed row. + */ + protected function createRowProcessedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $entityDetails, + bool $hasWarnings, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_processed', + 'level' => $hasWarnings ? 'warning' : 'info', + 'message' => "Row {$rowNum} processed successfully", + 'context' => [ + 'row' => $rowNum, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Create a skip event for a skipped row. + */ + protected function createRowSkippedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $entityDetails, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_skipped', + 'level' => 'warning', + 'message' => "Row {$rowNum} skipped", + 'context' => [ + 'row' => $rowNum, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Create a failure event for a failed row. + */ + protected function createRowFailedEvent( + Import $import, + ?Authenticatable $user, + int $rowNum, + array $errors, + array $entityDetails, + array $rawData = [] + ): void { + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_failed', + 'level' => 'error', + 'message' => "Row {$rowNum} failed: " . implode(', ', $errors), + 'context' => [ + 'row' => $rowNum, + 'errors' => $errors, + 'entity_details' => $entityDetails, + 'raw_data' => $rawData, + ], + ]); + } + + /** + * Handle row processing exception. + */ + protected function handleRowException( + Import $import, + ?Authenticatable $user, + int $rowNum, + \Throwable $e + ): void { + Log::error('ImportServiceV2 row processing failed', [ + 'import_id' => $import->id, + 'row' => $rowNum, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'row_failed', + 'level' => 'error', + 'message' => "Row {$rowNum} exception: {$e->getMessage()}", + 'context' => [ + 'row' => $rowNum, + 'exception' => [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], + ], + ]); + } + + /** + * Finalize import with completion event. + */ + protected function finalizeImport( + Import $import, + ?Authenticatable $user, + int $total, + int $imported, + int $skipped, + int $invalid + ): void { + $import->update([ + 'status' => 'completed', + 'finished_at' => now(), + 'total_rows' => $total, + 'imported_rows' => $imported, + 'valid_rows' => $imported, + 'invalid_rows' => $invalid, + ]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_completed', + 'level' => 'info', + 'message' => "Processed {$total} rows: {$imported} imported, {$skipped} skipped, {$invalid} invalid", + ]); + } + + /** + * Handle fatal processing exception. + */ + protected function handleFatalException( + Import $import, + ?Authenticatable $user, + \Throwable $e + ): void { + Log::error('ImportServiceV2 processing failed', [ + 'import_id' => $import->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $import->update(['status' => 'failed', 'finished_at' => now()]); + + ImportEvent::create([ + 'import_id' => $import->id, + 'user_id' => $user?->getAuthIdentifier(), + 'event' => 'processing_failed', + 'level' => 'error', + 'message' => $e->getMessage(), + ]); + } +} diff --git a/app/Services/ImportSimulationService.php b/app/Services/Import/ImportSimulationService.php similarity index 100% rename from app/Services/ImportSimulationService.php rename to app/Services/Import/ImportSimulationService.php diff --git a/app/Services/Import/ImportSimulationServiceV2.php b/app/Services/Import/ImportSimulationServiceV2.php new file mode 100644 index 0000000..00930d5 --- /dev/null +++ b/app/Services/Import/ImportSimulationServiceV2.php @@ -0,0 +1,786 @@ +loadEntityConfigurations(); + + // Only CSV/TXT supported + if (! in_array($import->source_type, ['csv', 'txt'])) { + return $this->errorPayload('Podprti so samo CSV/TXT formati.'); + } + + $filePath = $import->path; + if (! Storage::disk($import->disk ?? 'local')->exists($filePath)) { + return $this->errorPayload("Datoteka ni najdena: {$filePath}"); + } + + $fullPath = Storage::disk($import->disk ?? 'local')->path($filePath); + $fh = fopen($fullPath, 'r'); + + if (! $fh) { + return $this->errorPayload("Datoteke ni mogoče odpreti: {$filePath}"); + } + + $meta = $import->meta ?? []; + $hasHeader = (bool) ($meta['has_header'] ?? true); + $delimiter = $meta['forced_delimiter'] ?? $meta['detected_delimiter'] ?? ','; + + $mappings = $this->loadMappings($import); + if (empty($mappings)) { + fclose($fh); + + return $this->errorPayload('Ni shranjenih mapiranj za ta uvoz.'); + } + + $header = null; + $rowNum = 0; + + // Read header if present + if ($hasHeader) { + $header = fgetcsv($fh, 0, $delimiter); + $rowNum++; + } + + $simRows = []; + $summaries = $this->initSummaries(); + $rowCount = 0; + + while (($row = fgetcsv($fh, 0, $delimiter)) !== false && $rowCount < $limit) { + $rowNum++; + $rowCount++; + + try { + $rawAssoc = $this->buildRowAssoc($row, $header); + + // Skip empty rows + if ($this->rowIsEffectivelyEmpty($rawAssoc)) { + continue; + } + + $mapped = $this->applyMappings($rawAssoc, $mappings); + + // Group mapped data by entity (from "entity.field" to nested structure) + $groupedMapped = $this->groupMappedDataByEntity($mapped); + + \Log::info('ImportSimulation: Grouped entities', [ + 'row' => $rowNum, + 'entity_keys' => array_keys($groupedMapped), + 'config_roots' => array_keys($this->entityConfigs), + ]); + + // Simulate processing for this row + // Context must include 'import' for EntityResolutionService to work + $context = [ + 'import' => $import, + 'simulation' => true, + ]; + + $rowResult = $this->simulateRow($import, $groupedMapped, $rawAssoc, $context, $verbose); + + // Update summaries - handle both single and array results + foreach ($rowResult['entities'] ?? [] as $entityKey => $entityDataOrArray) { + // Extract entity root from key (e.g., 'person', 'contract', etc.) + $root = explode('.', $entityKey)[0]; + + // Handle array of results (grouped entities) + if (is_array($entityDataOrArray) && isset($entityDataOrArray[0])) { + foreach ($entityDataOrArray as $entityData) { + $action = $entityData['action'] ?? 'skip'; + if (!isset($summaries[$root])) { + $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; + } + $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; + } + } else { + // Single result + $action = $entityDataOrArray['action'] ?? 'skip'; + if (!isset($summaries[$root])) { + $summaries[$root] = ['create' => 0, 'update' => 0, 'skip' => 0, 'invalid' => 0]; + } + $summaries[$root][$action] = ($summaries[$root][$action] ?? 0) + 1; + } + } + + $simRows[] = [ + 'row_number' => $rowNum, + 'raw_data' => $verbose ? $rawAssoc : null, + 'entities' => $rowResult['entities'], + 'warnings' => $rowResult['warnings'] ?? [], + 'errors' => $rowResult['errors'] ?? [], + ]; + } catch (\Throwable $e) { + $simRows[] = [ + 'row_number' => $rowNum, + 'raw_data' => $verbose ? ($rawAssoc ?? null) : null, + 'entities' => [], + 'warnings' => [], + 'errors' => [$e->getMessage()], + ]; + } + } + + fclose($fh); + + return [ + 'success' => true, + 'total_simulated' => $rowCount, + 'limit' => $limit, + 'summaries' => $summaries, + 'rows' => $simRows, + 'meta' => [ + 'has_header' => $hasHeader, + 'delimiter' => $delimiter, + 'mappings_count' => count($mappings), + ], + ]; + } catch (\Throwable $e) { + return $this->errorPayload('Napaka pri simulaciji: '.$e->getMessage()); + } + } + + /** + * Simulate processing a single row without database writes. + * + * Updated to match ImportServiceV2 logic: + * - Process entities in priority order from entity configs + * - Accumulate entity results in context for chain resolution + * - Pass proper context to handlers for EntityResolutionService + */ + protected function simulateRow(Import $import, array $mapped, array $raw, array $context, bool $verbose): array + { + $entities = []; + $warnings = []; + $errors = []; + $entityResults = []; + + // Process entities in configured priority order (like ImportServiceV2) + foreach ($this->entityConfigs as $root => $config) { + // Check if this entity exists in mapped data + $mappedKey = $this->findMappedKey($mapped, $root, $config); + + if (!$mappedKey || !isset($mapped[$mappedKey])) { + continue; + } + + $handler = $this->handlers[$root] ?? null; + + if (!$handler) { + continue; + } + + try { + // Check if this is an array of entities (grouped) + $entityDataArray = is_array($mapped[$mappedKey]) && isset($mapped[$mappedKey][0]) + ? $mapped[$mappedKey] + : [$mapped[$mappedKey]]; + + $results = []; + + foreach ($entityDataArray as $entityData) { + // Validate + $validation = $handler->validate($entityData); + if (!$validation['valid']) { + $results[] = [ + 'action' => 'invalid', + 'data' => $entityData, + 'errors' => $validation['errors'], + ]; + continue; + } + + // Skip empty/invalid data that handlers would skip during real import + // Phone: skip if nu is 0, empty, or #N/A + if ($root === 'phone') { + $nu = $entityData['nu'] ?? null; + if (empty($nu) || $nu === '0' || $nu === '#N/A' || trim((string)$nu) === '') { + continue; // Skip this phone entirely + } + } + + // Address: skip if address is empty or #N/A + if ($root === 'address') { + $address = $entityData['address'] ?? null; + if (empty($address) || $address === '#N/A' || trim((string)$address) === '') { + continue; // Skip this address entirely + } + } + + // Email: skip if value is 0, empty, or #N/A + if ($root === 'email') { + $email = $entityData['value'] ?? null; + if (empty($email) || $email === '0' || $email === '#N/A' || trim((string)$email) === '') { + continue; // Skip this email entirely + } + } + + // DEBUG: Log context for grouped entities + if (in_array($root, ['phone', 'address'])) { + Log::info("ImportSimulation: Resolving grouped entity", [ + 'entity' => $root, + 'data' => $entityData, + 'has_person_in_context' => isset($entityResults['person']), + 'person_id' => $entityResults['person']['entity']->id ?? null, + 'context_keys' => array_keys(array_merge($context, $entityResults)), + ]); + } + + // Resolve existing entity (uses EntityResolutionService internally) + // Pass accumulated entityResults as context for chain resolution + $existingEntity = $handler->resolve($entityData, array_merge($context, $entityResults)); + + if ($existingEntity) { + // Would update existing + $results[] = [ + 'action' => 'update', + 'reference' => $this->getEntityReference($existingEntity, $root), + 'existing_id' => $existingEntity->id ?? null, + 'data' => $entityData, + 'existing_data' => $verbose ? $this->extractExistingData($existingEntity) : null, + 'changes' => $verbose ? $this->detectChanges($existingEntity, $entityData) : null, + ]; + + // Add to entityResults for subsequent handlers + $entityResults[$root] = [ + 'entity' => $existingEntity, + 'action' => 'updated', + ]; + } else { + // Would create new + $results[] = [ + 'action' => 'create', + 'data' => $entityData, + ]; + + // Simulate entity creation for context (no actual ID) + $entityResults[$root] = [ + 'entity' => (object) $entityData, + 'action' => 'inserted', + ]; + } + } + + // Store results (single or array) + $entities[$mappedKey] = (count($results) === 1) ? $results[0] : $results; + } catch (\Throwable $e) { + $entities[$mappedKey] = [ + 'action' => 'error', + 'errors' => [$e->getMessage()], + ]; + $errors[] = "{$root}: {$e->getMessage()}"; + } + } + + return compact('entities', 'warnings', 'errors'); + } + + /** + * Find the mapped key for an entity (supports aliases and common variations). + */ + protected function findMappedKey(array $mapped, string $canonicalRoot, $config): ?string + { + // Check canonical root exactly + if (isset($mapped[$canonicalRoot])) { + return $canonicalRoot; + } + + // Build comprehensive list of variations + $variations = [$canonicalRoot]; + + // Generate plural variations (handle -y endings correctly) + if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { + // activity -> activities + $variations[] = substr($canonicalRoot, 0, -1) . 'ies'; + } else { + // address -> addresses + $variations[] = $canonicalRoot . 's'; + } + + // Add singular form (remove trailing s or ies) + if (str_ends_with($canonicalRoot, 'ies')) { + $variations[] = substr($canonicalRoot, 0, -3) . 'y'; // activities -> activity + } else { + $variations[] = rtrim($canonicalRoot, 's'); // addresses -> address + } + + // Add person_ prefixed versions + $variations[] = 'person_' . $canonicalRoot; + + // person_activity -> person_activities + if (str_ends_with($canonicalRoot, 'y') && !str_ends_with($canonicalRoot, 'ay') && !str_ends_with($canonicalRoot, 'ey')) { + $variations[] = 'person_' . substr($canonicalRoot, 0, -1) . 'ies'; + } else { + $variations[] = 'person_' . $canonicalRoot . 's'; + } + + // Special handling: if canonical is 'address', also check 'person_addresses' + if ($canonicalRoot === 'address') { + $variations[] = 'person_addresses'; + } + // Special handling: if canonical is 'phone', also check 'person_phones' + if ($canonicalRoot === 'phone') { + $variations[] = 'person_phones'; + } + // Reverse: if canonical has 'person_', also check without it + if (str_starts_with($canonicalRoot, 'person_')) { + $withoutPerson = str_replace('person_', '', $canonicalRoot); + $variations[] = $withoutPerson; + // Handle plural variations + if (str_ends_with($withoutPerson, 'y') && !str_ends_with($withoutPerson, 'ay') && !str_ends_with($withoutPerson, 'ey')) { + $variations[] = substr($withoutPerson, 0, -1) . 'ies'; + } else { + $variations[] = rtrim($withoutPerson, 's'); + $variations[] = $withoutPerson . 's'; + } + } + + $variations = array_unique($variations); + + foreach ($variations as $variation) { + if (isset($mapped[$variation])) { + \Log::debug("ImportSimulation: Matched entity", [ + 'canonical_root' => $canonicalRoot, + 'matched_key' => $variation, + ]); + return $variation; + } + } + + // Check aliases if configured + if (isset($config->options['aliases'])) { + $aliases = is_array($config->options['aliases']) ? $config->options['aliases'] : []; + foreach ($aliases as $alias) { + if (isset($mapped[$alias])) { + return $alias; + } + } + } + + \Log::debug("ImportSimulation: No match found for entity", [ + 'canonical_root' => $canonicalRoot, + 'tried_variations' => array_slice($variations, 0, 5), + 'available_keys' => array_keys($mapped), + ]); + + return null; + } + + /** + * Group mapped data by entity from "entity.field" format to nested structure. + * Handles both single values and arrays (for grouped entities like multiple addresses). + * + * Special handling: + * - activity.note arrays are kept together (single activity with multiple notes) + * - Other array values create separate entity instances (e.g., multiple addresses) + * + * Input: ['person.first_name' => 'John', 'person.last_name' => 'Doe', 'email.value' => ['a@b.com', 'c@d.com']] + * Output: ['person' => ['first_name' => 'John', 'last_name' => 'Doe'], 'email' => [['value' => 'a@b.com'], ['value' => 'c@d.com']]] + */ + protected function groupMappedDataByEntity(array $mapped): array + { + $grouped = []; + + foreach ($mapped as $key => $value) { + if (!str_contains($key, '.')) { + continue; + } + + [$entity, $field] = explode('.', $key, 2); + + // Handle array values + if (is_array($value)) { + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; + } + + // Special case: activity.note should be kept as array in single instance + if ($entity === 'activity' || $entity === 'activities') { + if (!isset($grouped[$entity][0])) { + $grouped[$entity][0] = []; + } + $grouped[$entity][0][$field] = $value; // Keep as array + } else { + // Create separate entity instances for each array value + foreach ($value as $idx => $val) { + if (!isset($grouped[$entity][$idx])) { + $grouped[$entity][$idx] = []; + } + $grouped[$entity][$idx][$field] = $val; + } + } + } else { + // Single value + if (!isset($grouped[$entity])) { + $grouped[$entity] = []; + } + + // If entity is already an array (from previous grouped field), add to all instances + if (isset($grouped[$entity][0]) && is_array($grouped[$entity][0])) { + foreach ($grouped[$entity] as &$instance) { + $instance[$field] = $value; + } + unset($instance); + } else { + // Simple associative array + $grouped[$entity][$field] = $value; + } + } + } + + return $grouped; + } + + /** + * Determine if entity data is grouped (array of instances). + */ + protected function isGroupedEntity($data): bool + { + if (!is_array($data)) { + return false; + } + + // Check if numeric array (multiple instances) + $keys = array_keys($data); + return isset($keys[0]) && is_int($keys[0]); + } + + /** + * Extract existing entity data as array. + */ + protected function extractExistingData($entity): array + { + if (method_exists($entity, 'toArray')) { + return $entity->toArray(); + } + + return (array) $entity; + } + + /** + * Detect changes between existing entity and new data. + */ + protected function detectChanges($existingEntity, array $newData): array + { + $changes = []; + + foreach ($newData as $key => $newValue) { + $oldValue = $existingEntity->{$key} ?? null; + + // Convert to comparable formats + if ($oldValue instanceof \Carbon\Carbon) { + $oldValue = $oldValue->format('Y-m-d'); + } + + if ($oldValue != $newValue && ! ($oldValue === null && $newValue === '')) { + $changes[$key] = [ + 'old' => $oldValue, + 'new' => $newValue, + ]; + } + } + + return $changes; + } + + /** + * Get a reference string for an entity. + */ + protected function getEntityReference($entity, string $root): string + { + if (isset($entity->reference)) { + return (string) $entity->reference; + } + if (isset($entity->value)) { + return (string) $entity->value; + } + if (isset($entity->title)) { + return (string) $entity->title; + } + if (isset($entity->id)) { + return "{$root}#{$entity->id}"; + } + + return 'N/A'; + } + + /** + * Load entity configurations from database. + */ + protected function loadEntityConfigurations(): void + { + $entities = ImportEntity::where('is_active', true) + ->orderBy('priority', 'desc') + ->get(); + + foreach ($entities as $entity) { + $this->entityConfigs[$entity->canonical_root] = $entity; + + // Instantiate handler if configured + if ($entity->handler_class && class_exists($entity->handler_class)) { + $this->handlers[$entity->canonical_root] = app($entity->handler_class, ['entity' => $entity]); + } + } + } + + /** + * Get handler for entity root. + */ + protected function getHandler(string $root) + { + return $this->handlers[$root] ?? null; + } + + /** + * Load mappings from import_mappings table. + * Uses target_field in "entity.field" format. + * Supports multiple sources mapping to same target (for groups). + */ + protected function loadMappings(Import $import): array + { + $rows = \DB::table('import_mappings') + ->where('import_id', $import->id) + ->orderBy('position') + ->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']); + + $mappings = []; + foreach ($rows as $row) { + $source = trim((string) $row->source_column); + $target = trim((string) $row->target_field); + + if ($source === '' || $target === '') { + continue; + } + + // Use unique key combining source and target to avoid overwriting + $key = $source . '→' . $target; + + // target_field is in "entity.field" format + $mappings[$key] = [ + 'source' => $source, + 'target' => $target, + 'transform' => $row->transform ?? null, + 'apply_mode' => $row->apply_mode ?? 'both', + 'options' => $row->options ? json_decode($row->options, true) : [], + ]; + } + + return $mappings; + } + + /** + * Build associative array from row data. + */ + protected function buildRowAssoc(array $row, ?array $header): array + { + if ($header) { + return array_combine($header, array_pad($row, count($header), null)); + } + + // Use numeric indices if no header + return array_combine( + array_map(fn ($i) => "col_{$i}", array_keys($row)), + $row + ); + } + + /** + * Check if row is effectively empty. + */ + protected function rowIsEffectivelyEmpty(array $assoc): bool + { + foreach ($assoc as $value) { + if ($value !== null && $value !== '') { + return false; + } + } + + return true; + } + + /** + * Apply mappings to raw row data. + * Returns array keyed by "entity.field". + * + * Updated to match ImportServiceV2: + * - Supports group option for concatenating multiple sources + * - Uses setNestedValue for proper array handling + */ + protected function applyMappings(array $raw, array $mappings): array + { + $mapped = []; + + // Group mappings by target field to handle concatenation (same as ImportServiceV2) + $groupedMappings = []; + foreach ($mappings as $mapping) { + $target = $mapping['target']; + if (!isset($groupedMappings[$target])) { + $groupedMappings[$target] = []; + } + $groupedMappings[$target][] = $mapping; + } + + foreach ($groupedMappings as $targetField => $fieldMappings) { + // Group by group number from options + $valuesByGroup = []; + + foreach ($fieldMappings as $mapping) { + $source = $mapping['source']; + + if (!isset($raw[$source])) { + continue; + } + + $value = $raw[$source]; + + // Apply transform if specified + if (!empty($mapping['transform'])) { + $value = $this->applyTransform($value, $mapping['transform']); + } + + // Get group from options + $options = $mapping['options'] ?? []; + $group = $options['group'] ?? null; + + // Group values by their group number (same logic as ImportServiceV2) + if ($group !== null) { + // Same group = concatenate + if (!isset($valuesByGroup[$group])) { + $valuesByGroup[$group] = []; + } + $valuesByGroup[$group][] = $value; + } else { + // No group = each gets its own group + $valuesByGroup[] = [$value]; + } + } + + // Now set the values (same logic as ImportServiceV2) + foreach ($valuesByGroup as $values) { + if (count($values) === 1) { + // Single value - set directly + $this->setNestedValue($mapped, $targetField, $values[0]); + } else { + // Multiple values in same group - concatenate with newline + $concatenated = implode("\n", array_filter($values, fn($v) => !empty($v) && trim((string)$v) !== '')); + if (!empty($concatenated)) { + $this->setNestedValue($mapped, $targetField, $concatenated); + } + } + } + } + + return $mapped; + } + + /** + * Set nested value in array using dot notation. + * If the key already exists, convert to array and append the new value. + * + * Same logic as ImportServiceV2. + */ + protected function setNestedValue(array &$array, string $key, mixed $value): void + { + $keys = explode('.', $key); + $current = &$array; + + foreach ($keys as $i => $k) { + if ($i === count($keys) - 1) { + // If key already exists, convert to array and append + if (isset($current[$k])) { + // Convert existing single value to array if needed + if (!is_array($current[$k])) { + $current[$k] = [$current[$k]]; + } + // Append new value + $current[$k][] = $value; + } else { + // Set as single value + $current[$k] = $value; + } + } else { + if (!isset($current[$k]) || !is_array($current[$k])) { + $current[$k] = []; + } + $current = &$current[$k]; + } + } + } + + /** + * Apply transform to a value. + */ + protected function applyTransform(mixed $value, string $transform): mixed + { + return match ($transform) { + 'trim' => trim((string) $value), + 'upper' => strtoupper((string) $value), + 'lower' => strtolower((string) $value), + 'decimal' => (float) str_replace(',', '.', (string) $value), + default => $value, + }; + } + + /** + * Initialize summary counters. + */ + protected function initSummaries(): array + { + $summaries = []; + + foreach (array_keys($this->entityConfigs) as $root) { + $summaries[$root] = [ + 'create' => 0, + 'update' => 0, + 'skip' => 0, + 'invalid' => 0, + ]; + } + + return $summaries; + } + + /** + * Create error payload. + */ + protected function errorPayload(string $message): array + { + return [ + 'success' => false, + 'error' => $message, + 'total_simulated' => 0, + 'summaries' => [], + 'rows' => [], + ]; + } +} diff --git a/app/Services/Import/README.md b/app/Services/Import/README.md new file mode 100644 index 0000000..d2d3487 --- /dev/null +++ b/app/Services/Import/README.md @@ -0,0 +1,347 @@ +# Import System V2 Architecture + +## Overview + +ImportServiceV2 is a refactored, database-driven import processing system that replaces the monolithic ImportProcessor.php with a modular, maintainable architecture. + +## Key Features + +- **Database-driven configuration**: Entity processing rules, validation, and handlers configured in `import_entities` table +- **Pluggable handlers**: Each entity type has its own handler class implementing `EntityHandlerInterface` +- **Queue support**: Large imports can be processed asynchronously via `ProcessLargeImportJob` +- **Validation**: Entity-level validation rules stored in database +- **Priority-based processing**: Entities processed in configured priority order +- **Extensible**: Easy to add new entity types without modifying core service + +## Directory Structure + +``` +app/Services/Import/ +├── Contracts/ +│ └── EntityHandlerInterface.php # Handler contract +├── Handlers/ +│ ├── ContractHandler.php # Contract entity handler +│ ├── AccountHandler.php # Account entity handler +│ ├── PaymentHandler.php # Payment handler (to be implemented) +│ ├── ActivityHandler.php # Activity handler (to be implemented) +│ └── ... # Additional handlers +├── BaseEntityHandler.php # Base handler with common logic +└── ImportServiceV2.php # Main import service +``` + +## Database Schema + +### import_entities Table + +| Column | Type | Description | +|--------|------|-------------| +| id | bigint | Primary key | +| key | string | UI key (plural, e.g., "contracts") | +| canonical_root | string | Canonical root for processor (singular, e.g., "contract") | +| label | string | Human-readable label | +| fields | json | Array of field names | +| field_aliases | json | Field alias mappings | +| aliases | json | Root aliases | +| supports_multiple | boolean | Whether entity supports multiple items per row | +| meta | boolean | Whether entity is metadata | +| rules | json | Suggestion rules | +| ui | json | UI configuration | +| handler_class | string | Fully qualified handler class name | +| validation_rules | json | Laravel validation rules | +| processing_options | json | Handler-specific options | +| is_active | boolean | Whether entity is enabled | +| priority | integer | Processing priority (higher = first) | +| created_at | timestamp | Creation timestamp | +| updated_at | timestamp | Update timestamp | + +## Handler Interface + +All entity handlers must implement `EntityHandlerInterface`: + +```php +interface EntityHandlerInterface +{ + public function process(Import $import, array $mapped, array $raw, array $context = []): array; + public function validate(array $mapped): array; + public function getEntityClass(): string; + public function resolve(array $mapped, array $context = []): mixed; +} +``` + +### Handler Methods + +- **process()**: Main processing method, returns result with action (inserted/updated/skipped) and entity +- **validate()**: Validates mapped data before processing +- **getEntityClass()**: Returns the model class name this handler manages +- **resolve()**: Resolves existing entity by key/reference + +## Creating a New Handler + +1. Create handler class extending `BaseEntityHandler`: + +```php +first(); + } + + public function process(Import $import, array $mapped, array $raw, array $context = []): array + { + $existing = $this->resolve($mapped, $context); + + if ($existing) { + // Update logic + $payload = $this->buildPayload($mapped, $existing); + $appliedFields = $this->trackAppliedFields($existing, $payload); + + if (empty($appliedFields)) { + return [ + 'action' => 'skipped', + 'entity' => $existing, + 'message' => 'No changes detected', + ]; + } + + $existing->fill($payload); + $existing->save(); + + return [ + 'action' => 'updated', + 'entity' => $existing, + 'applied_fields' => $appliedFields, + ]; + } + + // Create logic + $entity = new YourEntity; + $payload = $this->buildPayload($mapped, $entity); + $entity->fill($payload); + $entity->save(); + + return [ + 'action' => 'inserted', + 'entity' => $entity, + 'applied_fields' => array_keys($payload), + ]; + } + + protected function buildPayload(array $mapped, $model): array + { + // Map fields to model attributes + return [ + 'field1' => $mapped['field1'] ?? null, + 'field2' => $mapped['field2'] ?? null, + ]; + } +} +``` + +2. Add configuration to `import_entities` table: + +```php +ImportEntity::create([ + 'key' => 'your_entities', + 'canonical_root' => 'your_entity', + 'label' => 'Your Entities', + 'fields' => ['field1', 'field2'], + 'handler_class' => \App\Services\Import\Handlers\YourEntityHandler::class, + 'validation_rules' => [ + 'field1' => 'required|string', + 'field2' => 'nullable|integer', + ], + 'processing_options' => [ + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 100, +]); +``` + +## Usage + +### Synchronous Processing + +```php +use App\Services\Import\ImportServiceV2; + +$service = app(ImportServiceV2::class); +$results = $service->process($import, $user); +``` + +### Queue Processing (Large Imports) + +```php +use App\Jobs\ProcessLargeImportJob; + +ProcessLargeImportJob::dispatch($import, $user->id); +``` + +## Processing Options + +Handler-specific options stored in `processing_options` JSON column: + +### Contract Handler +- `update_mode`: 'update' | 'skip' | 'error' +- `create_missing`: boolean + +### Account Handler +- `update_mode`: 'update' | 'skip' +- `require_contract`: boolean + +### Payment Handler (planned) +- `deduplicate_by`: array of fields +- `create_booking`: boolean +- `create_activity`: boolean + +## Migration Path + +### Phase 1: Setup (Current) +- ✅ Create directory structure +- ✅ Add v2 columns to import_entities +- ✅ Create base interfaces and classes +- ✅ Implement ContractHandler and AccountHandler +- ✅ Create ProcessLargeImportJob +- ✅ Create seeder for entity configurations + +### Phase 2: Implementation +- [ ] Implement remaining handlers (Payment, Activity, Person, Contacts) +- [ ] Add comprehensive tests +- [ ] Update controllers to use ImportServiceV2 +- [ ] Add feature flag to toggle between v1 and v2 + +### Phase 3: Migration +- [ ] Run both systems in parallel +- [ ] Compare results and fix discrepancies +- [ ] Migrate all imports to v2 +- [ ] Remove ImportProcessor.php (v1) + +## Testing + +```bash +# Run migrations +php artisan migrate + +# Seed entity configurations +php artisan db:seed --class=ImportEntitiesV2Seeder + +# Run tests +php artisan test --filter=ImportServiceV2 +``` + +## Benefits Over V1 + +1. **Maintainability**: Each entity has its own handler, easier to understand and modify +2. **Testability**: Handlers can be tested independently +3. **Extensibility**: New entities added without touching core service +4. **Configuration**: Business rules in database, no code deployment needed +5. **Queue Support**: Built-in queue support for large imports +6. **Validation**: Entity-level validation separate from processing logic +7. **Priority Control**: Process entities in configurable order +8. **Reusability**: Handlers can be reused across different import scenarios + +## Simulation Service + +ImportSimulationServiceV2 provides a way to preview what an import would do without persisting any data to the database. This is useful for: +- Validating mappings before processing +- Previewing create/update actions +- Detecting errors before running actual import +- Testing handler logic + +### Usage + +```php +use App\Services\Import\ImportSimulationServiceV2; + +$service = app(ImportSimulationServiceV2::class); + +// Simulate first 100 rows (default) +$result = $service->simulate($import); + +// Simulate 50 rows with verbose output +$result = $service->simulate($import, limit: 50, verbose: true); + +// Result structure: +// [ +// 'success' => true, +// 'total_simulated' => 50, +// 'limit' => 50, +// 'summaries' => [ +// 'contract' => ['create' => 10, 'update' => 5, 'skip' => 0, 'invalid' => 1], +// 'account' => ['create' => 20, 'update' => 3, 'skip' => 0, 'invalid' => 0], +// ], +// 'rows' => [ +// [ +// 'row_number' => 2, +// 'entities' => [ +// 'contract' => [ +// 'action' => 'update', +// 'reference' => 'CNT-001', +// 'existing_id' => 123, +// 'data' => ['reference', 'title', 'amount'], +// 'changes' => ['title' => ['old' => 'Old', 'new' => 'New']], +// ], +// ], +// 'warnings' => [], +// 'errors' => [], +// ], +// ], +// 'meta' => [ +// 'has_header' => true, +// 'delimiter' => ',', +// 'mappings_count' => 8, +// ], +// ] +``` + +### CLI Command + +```bash +# Simulate import with ID 123 +php artisan import:simulate-v2 123 + +# Simulate with custom limit +php artisan import:simulate-v2 123 --limit=50 + +# Verbose mode shows field-level changes +php artisan import:simulate-v2 123 --verbose +``` + +### Action Types + +- **create**: Entity doesn't exist, would be created +- **update**: Entity exists, would be updated +- **skip**: Entity exists but update_mode is 'skip' +- **invalid**: Validation failed +- **error**: Processing error occurred + +### Comparison with V1 Simulation + +| Feature | ImportSimulationService (V1) | ImportSimulationServiceV2 | +|---------|------------------------------|---------------------------| +| Handler-based | ❌ Hardcoded logic | ✅ Uses V2 handlers | +| Configuration | ❌ In code | ✅ From database | +| Validation | ❌ Manual | ✅ Handler validation | +| Extensibility | ❌ Modify service | ✅ Add handlers | +| Change detection | ✅ Yes | ✅ Yes | +| Priority ordering | ❌ Fixed | ✅ Configurable | +| Error handling | ✅ Basic | ✅ Comprehensive | + +## Original ImportProcessor.php + +The original file remains at `app/Services/ImportProcessor.php` and can be used as reference for implementing remaining handlers. diff --git a/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php b/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php new file mode 100644 index 0000000..2f21ab2 --- /dev/null +++ b/database/migrations/2025_12_22_201007_add_v2_columns_to_import_entities_table.php @@ -0,0 +1,32 @@ +string('handler_class')->nullable()->after('meta'); + $table->json('validation_rules')->nullable()->after('handler_class'); + $table->json('processing_options')->nullable()->after('validation_rules'); + $table->boolean('is_active')->default(true)->after('processing_options'); + $table->integer('priority')->default(0)->after('is_active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('import_entities', function (Blueprint $table) { + $table->dropColumn(['handler_class', 'validation_rules', 'processing_options', 'is_active', 'priority']); + }); + } +}; diff --git a/database/migrations/2025_12_25_000001_create_delete_client_case_function.php b/database/migrations/2025_12_25_000001_create_delete_client_case_function.php new file mode 100644 index 0000000..bba7d76 --- /dev/null +++ b/database/migrations/2025_12_25_000001_create_delete_client_case_function.php @@ -0,0 +1,87 @@ + 'contracts', + 'canonical_root' => 'contract', + 'label' => 'Pogodbe', + 'fields' => ['reference', 'title', 'description', 'amount', 'currency', 'start_date', 'end_date', 'active'], + 'field_aliases' => [], + 'aliases' => ['contract', 'contracts'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 1], + 'handler_class' => \App\Services\Import\Handlers\ContractHandler::class, + 'validation_rules' => [ + 'reference' => 'required|string|max:255', + ], + 'processing_options' => [ + 'update_mode' => 'update', // update, skip, error + 'create_missing' => true, + 'supports_reactivation' => true, + 'merge_json_fields' => ['meta'], + 'post_actions' => [ + 'attach_segment_id' => null, + 'create_activity' => false, + ], + ], + 'is_active' => true, + 'priority' => 100, // Highest - process first to establish chain + ], + [ + 'key' => 'accounts', + 'canonical_root' => 'account', + 'label' => 'Računi', + 'fields' => ['contract_id', 'reference', 'title', 'description', 'balance_amount', 'currency'], + 'field_aliases' => [], + 'aliases' => ['account', 'accounts'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 2], + 'handler_class' => \App\Services\Import\Handlers\AccountHandler::class, + 'validation_rules' => [ + 'reference' => 'required|string|max:255', + 'contract_id' => 'required|integer|exists:contracts,id', + ], + 'processing_options' => [ + 'update_mode' => 'update', + 'require_contract' => true, + 'track_balance_changes' => true, + 'create_activity_on_balance_change' => true, + 'history_import' => [ + 'skip_updates' => true, + 'force_zero_balances' => true, + 'auto_create_for_contract' => true, + ], + ], + 'is_active' => true, + 'priority' => 50, // After Person and contacts + ], + [ + 'key' => 'payments', + 'canonical_root' => 'payment', + 'label' => 'Plačila', + 'fields' => ['account_id', 'reference', 'amount', 'currency', 'paid_at', 'payment_date'], + 'field_aliases' => ['payment_date' => 'paid_at'], + 'aliases' => ['payment', 'payments'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'reference', 'order' => 3], + 'handler_class' => \App\Services\Import\Handlers\PaymentHandler::class, + 'validation_rules' => [ + 'amount' => 'required|numeric', + ], + 'processing_options' => [ + 'deduplicate_by' => ['account_id', 'reference'], + 'create_booking' => true, + 'create_activity' => false, // Based on PaymentSetting + 'track_balance' => true, + 'activity_note_template' => 'Prejeto plačilo [amount] [currency]', + 'payments_import' => [ + 'require_fields' => ['contract.reference', 'payment.amount', 'payment.payment_date'], + 'contract_key_mode' => 'reference', + ], + ], + 'is_active' => true, + 'priority' => 40, // After Account + ], + [ + 'key' => 'activities', + 'canonical_root' => 'activity', + 'label' => 'Aktivnosti', + 'fields' => ['client_case_id', 'contract_id', 'due_date', 'amount', 'note', 'action_id', 'decision_id'], + 'field_aliases' => [], + 'aliases' => ['activity', 'activities'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'note', 'order' => 4], + 'handler_class' => \App\Services\Import\Handlers\ActivityHandler::class, + 'validation_rules' => [], + 'processing_options' => [ + 'require_contract' => false, + 'require_client_case' => false, + ], + 'is_active' => true, + 'priority' => 30, // After all primary entities + ], + [ + 'key' => 'person', + 'canonical_root' => 'person', + 'label' => 'Osebe', + 'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'], + 'field_aliases' => [], + 'aliases' => ['person'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'full_name', 'order' => 5], + 'handler_class' => \App\Services\Import\Handlers\PersonHandler::class, + 'validation_rules' => [], + 'processing_options' => [ + 'deduplicate_by' => ['tax_number', 'social_security_number'], + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 90, // Third - derive from Contract/ClientCase chain if exists + ], + [ + 'key' => 'emails', + 'canonical_root' => 'email', + 'label' => 'Email naslovi', + 'fields' => ['value', 'is_primary', 'label'], + 'field_aliases' => [], + 'aliases' => ['email', 'emails'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [], + 'ui' => ['default_field' => 'value', 'order' => 6], + 'handler_class' => \App\Services\Import\Handlers\EmailHandler::class, + 'validation_rules' => [ + 'value' => 'required|email', + ], + 'processing_options' => [ + 'deduplicate' => true, + ], + 'is_active' => true, + 'priority' => 80, // After Person + ], + [ + 'key' => 'person_addresses', + 'canonical_root' => 'address', + 'label' => 'Naslovi oseb', + 'fields' => ['address', 'city', 'postal_code', 'country', 'type_id', 'description'], + 'field_aliases' => [ + 'ulica' => 'address', + 'naslov' => 'address', + 'mesto' => 'city', + 'posta' => 'postal_code', + 'pošta' => 'postal_code', + 'zip' => 'postal_code', + 'drzava' => 'country', + 'država' => 'country', + 'opis' => 'description', + ], + 'aliases' => ['person_addresses', 'address', 'addresses'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'], + ['pattern' => '/^(mesto|city|kraj)\b/i', 'field' => 'city'], + ['pattern' => '/^(posta|pošta|zip|postal)\b/i', 'field' => 'postal_code'], + ['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ], + 'ui' => ['default_field' => 'address', 'order' => 7], + 'handler_class' => \App\Services\Import\Handlers\AddressHandler::class, + 'validation_rules' => [ + 'address' => 'required|string|max:255', + ], + 'processing_options' => [ + 'deduplicate' => true, + 'parent_entity' => 'person', + ], + 'is_active' => true, + 'priority' => 70, // After Person + ], + [ + 'key' => 'person_phones', + 'canonical_root' => 'phone', + 'label' => 'Telefoni oseb', + 'fields' => ['nu', 'country_code', 'type_id', 'description'], + 'field_aliases' => ['number' => 'nu'], + 'aliases' => ['phone', 'person_phones'], + 'supports_multiple' => true, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'], + ], + 'ui' => ['default_field' => 'nu', 'order' => 8], + 'handler_class' => \App\Services\Import\Handlers\PhoneHandler::class, + 'validation_rules' => [ + 'nu' => 'required|string|max:50', + ], + 'processing_options' => [ + 'deduplicate' => true, + 'parent_entity' => 'person', + ], + 'is_active' => true, + 'priority' => 60, // After Person + ], + [ + 'key' => 'client_cases', + 'canonical_root' => 'client_case', + 'label' => 'Primeri', + 'fields' => ['client_ref'], + 'field_aliases' => [], + 'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'], + ], + 'ui' => ['default_field' => 'client_ref', 'order' => 9], + 'handler_class' => \App\Services\Import\Handlers\ClientCaseHandler::class, + 'validation_rules' => [ + 'client_ref' => 'required|string|max:255', + ], + 'processing_options' => [ + 'deduplicate_by' => ['client_ref'], + 'update_mode' => 'update', + ], + 'is_active' => true, + 'priority' => 95, // Second - process after Contract to establish chain + ], + [ + 'key' => 'case_objects', + 'canonical_root' => 'case_object', + 'label' => 'Predmeti', + 'fields' => ['reference', 'name', 'description', 'type', 'contract_id'], + 'field_aliases' => [], + 'aliases' => ['case_object', 'case_objects', 'object', 'objects', 'predmet', 'predmeti'], + 'supports_multiple' => false, + 'meta' => false, + 'rules' => [ + ['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'], + ['pattern' => '/^(ime|naziv|name|title)\b/i', 'field' => 'name'], + ['pattern' => '/^(tip|vrsta|type|kind)\b/i', 'field' => 'type'], + ['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'], + ['pattern' => '/^(contract\s*id|contract_id|pogodba\s*id|pogodba_id)\b/i', 'field' => 'contract_id'], + ], + 'ui' => ['default_field' => 'name', 'order' => 10], + 'handler_class' => \App\Services\Import\Handlers\CaseObjectHandler::class, + 'validation_rules' => [ + 'name' => 'required|string|max:255', + ], + 'processing_options' => [ + 'require_contract' => false, + ], + 'is_active' => true, + 'priority' => 10, + ], + ]; + + foreach ($entities as $entity) { + ImportEntity::updateOrCreate( + ['key' => $entity['key']], + $entity + ); + } + + $this->command->info('Import entities v2 seeded successfully.'); + } +} diff --git a/resources/js/Components/DataTable/DataTablePagination.vue b/resources/js/Components/DataTable/DataTablePagination.vue index f3722b8..653c77f 100644 --- a/resources/js/Components/DataTable/DataTablePagination.vue +++ b/resources/js/Components/DataTable/DataTablePagination.vue @@ -1,20 +1,24 @@ @@ -288,155 +276,199 @@ const assignedRows = computed(() =>
- Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve → - Nastavitve terenskih opravil. + +

+ Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve + → Nastavitve terenskih opravil. +

- -
- - -
- -
-

Pogodbe (nedodeljene)

-
- - -
- {{ form.errors.assigned_user_id }} + + +
+
+

Pogodbe (nedodeljene)

+
+
+
+ + +
+ {{ form.errors.assigned_user_id }} +
-
- - + +
- + + -