This commit is contained in:
Simon Pocrnjič 2025-12-26 22:39:58 +01:00
parent f8623a6071
commit dea7432deb
55 changed files with 7977 additions and 1983 deletions

11
.gitignore vendored
View File

@ -19,4 +19,13 @@ yarn-error.log
/.idea
/.vscode
/.zed
/shadcn-vue
/shadcn-vue
# Development/Testing Scripts
check-*.php
test-*.php
fix-*.php
# Development Documentation
IMPORT_*.md
V2_*.md

654
DEDUPLICATION_PLAN_V2.md Normal file
View File

@ -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

View File

@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FixImportMappingEntities extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:fix-mapping-entities {--dry-run : Show changes without applying them}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix entity names in import_mappings table to use canonical roots';
/**
* Entity name mappings from incorrect to correct canonical roots
*/
protected array $entityMapping = [
'contracts' => '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;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PopulateImportMappingEntities extends Command
{
protected $signature = 'import:populate-mapping-entities {--dry-run : Show changes without applying them}';
protected $description = 'Populate entity column from target_field for mappings where entity is null';
protected array $entityMap = [
'contracts' => '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;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\Import;
use App\Services\Import\ImportSimulationServiceV2;
use Illuminate\Console\Command;
class SimulateImportV2Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:simulate-v2 {import_id} {--limit=100 : Number of rows to simulate} {--verbose : Include detailed information}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Simulate ImportServiceV2 without persisting data';
/**
* Execute the console command.
*/
public function handle(ImportSimulationServiceV2 $service): int
{
$importId = $this->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}: <fg={$color}>{$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;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ProcessLargeImportJob;
use App\Models\Import;
use App\Services\Import\ImportServiceV2;
use Illuminate\Console\Command;
class TestImportV2Command extends Command
{
protected $signature = 'import:test-v2 {import_id : The import ID to process} {--queue : Process via queue}';
protected $description = 'Test ImportServiceV2 with an existing import';
public function handle()
{
$importId = $this->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;
}
}
}

View File

@ -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,
],
]);
}

View File

@ -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);

View File

@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
use App\Models\Import;
use App\Models\ImportEvent;
use App\Services\Import\ImportServiceV2;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessLargeImportJob implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 3600; // 1 hour
public $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public Import $import,
public ?int $userId = null
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info('ProcessLargeImportJob started', [
'import_id' => $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(),
]);
}
}

View File

@ -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)

View File

@ -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',
];
}

View File

@ -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
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\Import;
use App\Models\ImportEntity;
use App\Services\Import\Contracts\EntityHandlerInterface;
use Illuminate\Support\Facades\Validator;
abstract class BaseEntityHandler implements EntityHandlerInterface
{
protected ?ImportEntity $entityConfig;
public function __construct(?ImportEntity $entityConfig = null)
{
$this->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;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services\Import\Contracts;
use App\Models\Import;
interface EntityHandlerInterface
{
/**
* Process a single row for this entity.
*
* @param Import $import The import instance
* @param array $mapped Mapped data for this entity
* @param array $raw Raw row data
* @param array $context Additional context (previous entity results, etc.)
* @return array Result with action, entity instance, applied_fields, etc.
*/
public function process(Import $import, array $mapped, array $raw, array $context = []): array;
/**
* Validate mapped data before processing.
*
* @param array $mapped Mapped data for this entity
* @return array Validation result ['valid' => 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;
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Import;
class 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);
}
}

View File

@ -0,0 +1,394 @@
<?php
namespace App\Services\Import;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Email;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Person\PersonPhone;
use Illuminate\Support\Facades\Log;
/**
* EntityResolutionService - Resolves existing entities to prevent duplication.
*
* This service checks for existing entities before creating new ones,
* following the V1 deduplication hierarchy:
* 1. Contract reference ClientCase Person
* 2. ClientCase client_ref Person
* 3. Contact values (email/phone/address) Person
* 4. Person identifiers (tax_number/ssn) Person
*/
class EntityResolutionService
{
/**
* Resolve Person ID from import context (existing entities).
* Returns Person ID if found, null otherwise.
*
* @param Import $import
* @param array $mapped Mapped data from CSV row
* @param array $context Processing context with previously processed entities
* @return int|null Person ID if found, null if should create new
*/
public function resolvePersonFromContext(Import $import, array $mapped, array $context): ?int
{
// 1. Check if Contract already processed in this row
if ($contract = $context['contract']['entity'] ?? null) {
$personId = $this->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;
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class AccountHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Account::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$contractId = $mapped['contract_id'] ?? $context['contract']?->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(),
]);
}
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Activity;
use App\Models\Import;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
class ActivityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Activity::class;
}
/**
* Override validate to skip validation if note is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$note = $mapped['note'] ?? null;
// If array, check if all values are empty
if (is_array($note)) {
$hasValue = false;
foreach ($note as $n) {
if (!empty($n) && trim((string)$n) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => 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);
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonAddress;
use App\Services\Import\BaseEntityHandler;
class AddressHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonAddress::class;
}
/**
* Override validate to skip validation if address is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$address = $mapped['address'] ?? null;
// If array, check if all values are empty
if (is_array($address)) {
$hasValue = false;
foreach ($address as $addr) {
if (!empty($addr) && trim((string)$addr) !== '') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => 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
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\CaseObject;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class CaseObjectHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return CaseObject::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
$reference = $mapped['reference'] ?? null;
$name = $mapped['name'] ?? null;
if (! $reference && ! $name) {
return null;
}
// Try to find by reference first
if ($reference) {
$object = CaseObject::where('reference', $reference)->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;
}
}

View File

@ -0,0 +1,163 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ClientCaseHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->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;
}
}

View File

@ -0,0 +1,343 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class ContractHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->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}";
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Email;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class EmailHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Email::class;
}
/**
* Override validate to skip validation if email is empty.
*/
public function validate(array $mapped): array
{
$email = $mapped['value'] ?? null;
if (empty($email) || trim((string)$email) === '') {
return ['valid' => 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;
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Account;
use App\Models\Booking;
use App\Models\Import;
use App\Models\Payment;
use App\Models\PaymentSetting;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use Illuminate\Support\Facades\Log;
class PaymentHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return Payment::class;
}
/**
* Override validate to skip validation if amount is empty.
*/
public function validate(array $mapped): array
{
$amount = $mapped['amount'] ?? null;
if (empty($amount) || !is_numeric($amount)) {
return ['valid' => 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(),
]);
}
}
}

View File

@ -0,0 +1,187 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\Person;
use App\Models\Person\PersonGroup;
use App\Models\Person\PersonType;
use App\Services\Import\DateNormalizer;
use App\Services\Import\BaseEntityHandler;
use App\Services\Import\EntityResolutionService;
use Illuminate\Support\Facades\Log;
class PersonHandler extends BaseEntityHandler
{
protected EntityResolutionService $resolutionService;
public function __construct($entityConfig = null)
{
parent::__construct($entityConfig);
$this->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);
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Services\Import\Handlers;
use App\Models\Import;
use App\Models\Person\PersonPhone;
use App\Services\Import\BaseEntityHandler;
class PhoneHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return PersonPhone::class;
}
/**
* Override validate to skip validation if phone is empty.
* Handles both single values and arrays.
*/
public function validate(array $mapped): array
{
$phone = $mapped['nu'] ?? null;
// If array, check if all values are empty/invalid
if (is_array($phone)) {
$hasValue = false;
foreach ($phone as $ph) {
if (!empty($ph) && trim((string)$ph) !== '' && $ph !== '0') {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return ['valid' => 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);
}
}

View File

@ -0,0 +1,759 @@
<?php
namespace App\Services\Import;
use App\Models\Import;
use App\Models\ImportEntity;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Services\Import\Contracts\EntityHandlerInterface;
use App\Services\Import\DateNormalizer;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* ImportServiceV2 - Generic, database-driven import processor.
*
* Refactored from ImportProcessor to use entity handlers and config from import_entities table.
*
* PHASE 6: EntityResolutionService is integrated via handler constructors.
* Each handler (PersonHandler, ContractHandler, ClientCaseHandler) instantiates
* the service and uses it to prevent duplicate Person creation.
*/
class ImportServiceV2
{
protected array $handlers = [];
protected array $entityConfigs = [];
protected array $templateMeta = [];
protected bool $paymentsImport = false;
protected bool $historyImport = false;
protected ?string $contractKeyMode = null;
/**
* Process an import using v2 architecture.
*/
public function process(Import $import, ?Authenticatable $user = null): array
{
$started = now();
$total = 0;
$skipped = 0;
$imported = 0;
$invalid = 0;
try {
// Load template meta flags
$this->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(),
]);
}
}

View File

@ -0,0 +1,786 @@
<?php
namespace App\Services\Import;
use App\Models\Import;
use App\Models\ImportEntity;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* ImportSimulationServiceV2 - Simulates imports using V2 handler architecture.
*
* Processes rows using entity handlers without persisting any data to the database.
* Returns preview data showing what would be created/updated for each row.
*
* Deduplication: Uses EntityResolutionService through handlers to accurately simulate
* Person resolution from Contract/ClientCase chains, matching production behavior.
*/
class ImportSimulationServiceV2
{
protected array $handlers = [];
protected array $entityConfigs = [];
/**
* Simulate an import and return preview data.
*
* @param Import $import Import record with mappings
* @param int $limit Maximum number of rows to simulate (default: 100)
* @param bool $verbose Include detailed information (default: false)
* @return array Simulation results with row previews and statistics
*/
public function simulate(Import $import, int $limit = 100, bool $verbose = false): array
{
try {
// Load entity configurations and handlers
$this->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' => [],
];
}
}

View File

@ -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
<?php
namespace App\Services\Import\Handlers;
use App\Models\YourEntity;
use App\Models\Import;
use App\Services\Import\BaseEntityHandler;
class YourEntityHandler extends BaseEntityHandler
{
public function getEntityClass(): string
{
return YourEntity::class;
}
public function resolve(array $mapped, array $context = []): mixed
{
// Implement entity resolution logic
return YourEntity::where('key', $mapped['key'])->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.

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('import_entities', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::unprepared('
CREATE OR REPLACE FUNCTION delete_client_case_cascade(case_id INTEGER)
RETURNS TABLE(deleted_table TEXT, deleted_count INTEGER) AS $$
DECLARE
v_deleted_count INTEGER;
BEGIN
-- Delete bookings related to payments in this case
WITH deleted AS (
DELETE FROM bookings
WHERE payment_id IN (
SELECT p.id FROM payments p
INNER JOIN accounts a ON p.account_id = a.id
INNER JOIN contracts c ON a.contract_id = c.id
WHERE c.client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'bookings\'::TEXT, v_deleted_count;
-- Delete payments related to accounts in this case
WITH deleted AS (
DELETE FROM payments
WHERE account_id IN (
SELECT a.id FROM accounts a
INNER JOIN contracts c ON a.contract_id = c.id
WHERE c.client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'payments\'::TEXT, v_deleted_count;
-- Delete activities
WITH deleted AS (
DELETE FROM activities WHERE client_case_id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'activities\'::TEXT, v_deleted_count;
-- Delete accounts
WITH deleted AS (
DELETE FROM accounts
WHERE contract_id IN (
SELECT id FROM contracts WHERE client_case_id = case_id
)
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'accounts\'::TEXT, v_deleted_count;
-- Delete contracts
WITH deleted AS (
DELETE FROM contracts WHERE client_case_id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'contracts\'::TEXT, v_deleted_count;
-- Delete the client_case itself
WITH deleted AS (
DELETE FROM client_cases WHERE id = case_id
RETURNING *
)
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
RETURN QUERY SELECT \'client_cases\'::TEXT, v_deleted_count;
END;
$$ LANGUAGE plpgsql;
');
}
public function down(): void
{
DB::unprepared('DROP FUNCTION IF EXISTS delete_client_case_cascade(INTEGER);');
}
};

View File

@ -0,0 +1,288 @@
<?php
namespace Database\Seeders;
use App\Models\ImportEntity;
use Illuminate\Database\Seeder;
class ImportEntitiesV2Seeder extends Seeder
{
/**
* Seed import_entities with v2 handler configurations.
*/
public function run(): void
{
$entities = [
[
'key' => '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.');
}
}

View File

@ -1,20 +1,24 @@
<script setup>
import { computed } from 'vue';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
import { Button } from '@/Components/ui/button';
import { computed } from "vue";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
import { Button } from "@/Components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
} from "@/Components/ui/select";
const props = defineProps({
table: {
type: Object,
required: true,
},
showPerPageSelector: {
type: Boolean,
default: true,
},
});
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
@ -27,13 +31,13 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
</div>
<div class="flex items-center space-x-6 lg:space-x-8">
<div class="flex items-center space-x-2">
<div v-if="showPerPageSelector" class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<Select
:model-value="`${table.getState().pagination.pageSize}`"
@update:model-value="(value) => table.setPageSize(Number(value))"
>
<SelectTrigger class="h-8 w-[70px]">
<SelectTrigger class="h-8 w-17.5">
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
</SelectTrigger>
<SelectContent side="top">
@ -47,7 +51,7 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
</SelectContent>
</Select>
</div>
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
<div class="flex w-25 items-center justify-center text-sm font-medium">
Page {{ table.getState().pagination.pageIndex + 1 }} of
{{ table.getPageCount() }}
</div>
@ -92,4 +96,3 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
</div>
</div>
</template>

View File

@ -20,6 +20,13 @@ import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
import { hasPermission } from "@/Services/permissions";
import { Badge } from "@/Components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
const props = defineProps({
client: Object,
@ -523,15 +530,16 @@ const submitAttachSegment = () => {
>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">Segment</label>
<select
v-model="attachForm.segment_id"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
>
<option :value="null" disabled>-- izberi segment --</option>
<option v-for="s in availableSegments" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
<Select v-model="attachForm.segment_id">
<SelectTrigger class="w-full">
<SelectValue placeholder="-- izberi segment --" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="s in availableSegments" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">
{{ attachForm.errors.segment_id }}
</div>

View File

@ -163,7 +163,7 @@ function applySearch() {
<Input
v-model="search"
placeholder="Išči po primeru, davčni, osebi..."
class="w-[260px]"
class="w-65"
@keydown.enter="applySearch"
/>
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>

View File

@ -1,14 +1,31 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, useForm } from "@inertiajs/vue3";
import { Link, useForm, router } from "@inertiajs/vue3";
import { computed, ref, watch } from "vue";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { Card } from "@/Components/ui/card";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { AlertCircle } from "lucide-vue-next";
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
import Pagination from "@/Components/Pagination.vue";
import { watchDebounced } from "@vueuse/core";
const props = defineProps({
setting: Object,
contracts: Array,
unassignedContracts: Object,
assignedContracts: Object,
users: Array,
assignments: Object,
filters: Object,
});
const form = useForm({
@ -24,10 +41,113 @@ const bulkForm = useForm({
assigned_user_id: null,
});
// Global search (applies to both tables)
const search = ref("");
// Separate reactive state for selected UUIDs (for UI reactivity)
const selectedContractUuids = ref([]);
// Select all state for unassigned table (current page only)
const isAllUnassignedSelected = computed({
get: () => {
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
return (
pageUuids.length > 0 &&
pageUuids.every((uuid) => selectedContractUuids.value.includes(uuid))
);
},
set: (value) => {
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
if (value) {
// Add all page items to selection
selectedContractUuids.value = [
...new Set([...selectedContractUuids.value, ...pageUuids]),
];
} else {
// Remove all page items from selection
selectedContractUuids.value = selectedContractUuids.value.filter(
(uuid) => !pageUuids.includes(uuid)
);
}
},
});
// Helper to toggle contract selection
function toggleContractSelection(uuid, checked) {
if (checked) {
if (!selectedContractUuids.value.includes(uuid)) {
selectedContractUuids.value = [...selectedContractUuids.value, uuid];
}
} else {
selectedContractUuids.value = selectedContractUuids.value.filter((id) => id !== uuid);
}
console.log(selectedContractUuids.value);
}
// Format helpers (Slovenian formatting)
// Initialize search and filter from URL params
const search = ref(props.filters?.search || "");
const assignedFilterUserId = ref(props.filters?.assigned_user_id || "all");
// Navigation helpers
function navigateWithParams(params) {
router.visit(route("fieldjobs.index"), {
data: params,
preserveState: true,
preserveScroll: true,
only: ["unassignedContracts", "assignedContracts", "filters"],
});
}
const applySearch = async function () {
const params = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
const term = (search.value || "").trim();
if (term) {
params.search = term;
} else {
delete params.search;
}
delete params.page;
router.get(route("fieldjobs.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
only: ["unassignedContracts"],
});
};
watchDebounced(
() => search.value,
(val) => {
applySearch();
},
{
debounce: 200,
maxWait: 1000,
}
);
// Watch search and filter changes
/*watch(search, (value) => {
navigateWithParams({
search: value || undefined,
assigned_user_id:
assignedFilterUserId.value !== "all" ? assignedFilterUserId.value : undefined,
page_contracts: 1, // Reset to first page on search
page_assignments: 1,
});
});*/
watch(assignedFilterUserId, (value) => {
navigateWithParams({
search: search.value || undefined,
assigned_user_id: value !== "all" ? value : undefined,
page_contracts: props.unassignedContracts?.current_page,
page_assignments: 1, // Reset to first page on filter change
});
});
function formatDate(value) {
if (!value) {
return "-";
@ -77,8 +197,10 @@ function assign(contract) {
function assignSelected() {
// Use the same selected user as in the single-assign dropdown
bulkForm.assigned_user_id = form.assigned_user_id;
bulkForm.contract_uuids = selectedContractUuids.value;
bulkForm.post(route("fieldjobs.assign-bulk"), {
onSuccess: () => {
selectedContractUuids.value = [];
bulkForm.contract_uuids = [];
},
});
@ -89,185 +211,50 @@ function cancelAssignment(contract) {
form.transform(() => payload).post(route("fieldjobs.cancel"));
}
function isAssigned(contract) {
return !!(props.assignments && props.assignments[contract.uuid]);
}
function assignedTo(contract) {
return props.assignments?.[contract.uuid]?.assigned_to?.name || null;
}
function assignedBy(contract) {
return props.assignments?.[contract.uuid]?.assigned_by?.name || null;
}
// removed window.open behavior; default SPA navigation via Inertia Link
// Derived lists
const unassignedContracts = computed(() => {
return (props.contracts || []).filter((c) => !isAssigned(c));
});
const assignedContracts = computed(() => {
return (props.contracts || []).filter((c) => isAssigned(c));
});
// Apply search to lists
function matchesSearch(c) {
if (!search.value) {
return true;
}
const q = String(search.value).toLowerCase();
const ref = String(c.reference || "").toLowerCase();
const casePerson = String(c.client_case?.person?.full_name || "").toLowerCase();
// Optionally include client person in search as well for convenience
const clientPerson = String(c.client?.person?.full_name || "").toLowerCase();
// Include address fields
const primaryAddr = String(primaryCaseAddress(c) || "").toLowerCase();
const allAddrs = String(
(c.client_case?.person?.addresses || [])
.map((a) => `${a?.address || ""} ${a?.country || ""}`.trim())
.join(" ")
).toLowerCase();
return (
ref.includes(q) ||
casePerson.includes(q) ||
clientPerson.includes(q) ||
primaryAddr.includes(q) ||
allAddrs.includes(q)
);
}
const unassignedFiltered = computed(() =>
unassignedContracts.value.filter(matchesSearch)
);
// Filter for assigned table
const assignedFilterUserId = ref("");
const assignedContractsFiltered = computed(() => {
let list = assignedContracts.value;
if (assignedFilterUserId.value) {
list = list.filter((c) => {
const uid = props.assignments?.[c.uuid]?.assigned_to?.id;
return String(uid) === String(assignedFilterUserId.value);
});
}
return list.filter(matchesSearch);
});
// DataTableClient state per table
const unassignedSort = ref({ key: null, direction: null });
const unassignedPage = ref(1);
const unassignedPageSize = ref(10);
const assignedSort = ref({ key: null, direction: null });
const assignedPage = ref(1);
const assignedPageSize = ref(10);
watch([search, assignedFilterUserId], () => {
unassignedPage.value = 1;
assignedPage.value = 1;
});
// Column definitions for DataTableClient
// Column definitions for DataTableNew2
const unassignedColumns = [
{ key: "_select", label: "", class: "w-8" },
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
{
key: "case_person",
label: "Primer",
sortable: true,
formatter: (c) => c.client_case?.person?.full_name || "-",
key: "_select",
label: "",
sortable: false,
class: "w-8",
},
{
key: "address",
label: "Naslov",
sortable: true,
formatter: (c) => primaryCaseAddress(c),
},
{
key: "client_person",
label: "Stranka",
sortable: true,
formatter: (c) => c.client?.person?.full_name || "-",
},
{
key: "start_date",
label: "Začetek",
sortable: true,
formatter: (c) => formatDate(c.start_date),
},
{
key: "balance_amount",
label: "Stanje",
align: "right",
sortable: true,
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
},
{ key: "_actions", label: "Dejanje", class: "w-32" },
{ key: "reference", label: "Pogodba", sortable: false },
{ key: "case_person", label: "Primer", sortable: false },
{ key: "address", label: "Naslov", sortable: false },
{ key: "client_person", label: "Stranka", sortable: false },
{ key: "start_date", label: "Začetek", sortable: false },
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
{ key: "_actions", label: "Dejanje", sortable: false },
];
const assignedColumns = [
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
{
key: "case_person",
label: "Primer",
sortable: true,
formatter: (c) => c.client_case?.person?.full_name || "-",
},
{
key: "address",
label: "Naslov",
sortable: true,
formatter: (c) => primaryCaseAddress(c),
},
{
key: "client_person",
label: "Stranka",
sortable: true,
formatter: (c) => c.client?.person?.full_name || "-",
},
{
key: "assigned_at",
label: "Dodeljeno dne",
sortable: true,
formatter: (c) => formatDate(props.assignments?.[c.uuid]?.assigned_at),
},
{
key: "assigned_to",
label: "Dodeljeno komu",
sortable: true,
formatter: (c) => assignedTo(c) || "-",
},
{
key: "balance_amount",
label: "Stanje",
align: "right",
sortable: true,
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
},
{ key: "_actions", label: "Dejanje", class: "w-32" },
{ key: "reference", label: "Pogodba", sortable: false },
{ key: "case_person", label: "Primer", sortable: false },
{ key: "address", label: "Naslov", sortable: false },
{ key: "client_person", label: "Stranka", sortable: false },
{ key: "assigned_at", label: "Dodeljeno dne", sortable: false },
{ key: "assigned_to", label: "Dodeljeno komu", sortable: false },
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
{ key: "_actions", label: "Dejanje", sortable: false },
];
// Provide derived row arrays for DataTable (already filtered)
// Add a flat numeric property `balance_amount` so the generic table sorter can sort by value
// (original data nests it under account.balance_amount which the sorter cannot reach).
// Prepare rows with flattened fields for display
const unassignedRows = computed(() =>
unassignedFiltered.value.map((c) => ({
(props.unassignedContracts?.data || []).map((c) => ({
...c,
// Ensure numeric so sorter treats it as number (server often returns string)
balance_amount:
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
? null
: Number(c.account.balance_amount),
// Flatten derived text fields so DataTable sorting/searching works
case_person: c.client_case?.person?.full_name || null,
client_person: c.client?.person?.full_name || null,
address: primaryCaseAddress(c) || null,
assigned_to: null, // not assigned yet
}))
);
const assignedRows = computed(() =>
assignedContractsFiltered.value.map((c) => ({
(props.assignedContracts?.data || []).map((c) => ({
...c,
balance_amount:
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
@ -276,7 +263,8 @@ const assignedRows = computed(() =>
case_person: c.client_case?.person?.full_name || null,
client_person: c.client?.person?.full_name || null,
address: primaryCaseAddress(c) || null,
assigned_to: assignedTo(c) || null,
assigned_to: c.last_field_jobs.assigned_user.name || null,
assigned_at_formatted: formatDate(c.last_field_jobs.assigned_at),
}))
);
</script>
@ -288,155 +276,199 @@ const assignedRows = computed(() =>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div
v-if="!setting"
class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6"
class="mb-6 flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-4"
>
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve
Nastavitve terenskih opravil.
<AlertCircle class="h-5 w-5 text-yellow-600 shrink-0 mt-0.5" />
<p class="text-sm text-yellow-800">
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve
Nastavitve terenskih opravil.
</p>
</div>
<!-- Global search -->
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Iskanje (št. pogodbe, nazivu ali naslovu)</label
>
<input
v-model="search"
type="text"
placeholder="Išči po številki pogodbe, nazivu ali naslovu"
class="border rounded px-3 py-2 w-full max-w-xl"
/>
</div>
<!-- Unassigned (Assignable) Contracts via DataTableClient -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Dodeli uporabniku</label
>
<select
v-model="form.assigned_user_id"
class="border rounded px-3 py-2 w-full max-w-xs"
>
<option :value="null" disabled>Izberite uporabnika</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">
{{ u.name }}
</option>
</select>
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
{{ form.errors.assigned_user_id }}
<!-- Unassigned (Assignable) Contracts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg mb-8">
<div class="p-4 border-b">
<h2 class="text-xl font-semibold">Pogodbe (nedodeljene)</h2>
</div>
<div class="p-4 border-b space-y-4">
<div class="space-y-2">
<Label for="assign-user">Dodeli uporabniku</Label>
<Select v-model="form.assigned_user_id">
<SelectTrigger id="assign-user" class="max-w-xs">
<SelectValue placeholder="Izberite uporabnika" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="u in users || []" :key="u.id" :value="u.id">
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm">
{{ form.errors.assigned_user_id }}
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button
class="px-3 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="!bulkForm.contract_uuids.length || !form.assigned_user_id"
<div class="flex items-center gap-2">
<Button
:disabled="!selectedContractUuids.length || !form.assigned_user_id"
@click="assignSelected"
>
Dodeli izbrane ({{ bulkForm.contract_uuids.length }})
</button>
<button
class="px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50"
:disabled="!bulkForm.contract_uuids.length"
@click="bulkForm.contract_uuids = []"
Dodeli izbrane
<Badge
v-if="selectedContractUuids.length"
variant="secondary"
class="ml-2"
>
{{ selectedContractUuids.length }}
</Badge>
</Button>
<Button
variant="outline"
:disabled="!selectedContractUuids.length"
@click="selectedContractUuids = []"
>
Počisti izbor
</button>
</Button>
</div>
</div>
<DataTableClient
<DataTable
:columns="unassignedColumns"
:rows="unassignedRows"
:search-keys="['reference', 'case_person', 'client_person', 'address']"
v-model:sort="unassignedSort"
v-model:search="search"
v-model:page="unassignedPage"
v-model:pageSize="unassignedPageSize"
:data="unassignedRows"
:meta="{
current_page: unassignedContracts.current_page,
per_page: unassignedContracts.per_page,
total: unassignedContracts.total,
last_page: unassignedContracts.last_page,
from: unassignedContracts.from,
to: unassignedContracts.to,
links: unassignedContracts.links,
}"
row-key="uuid"
:page-size="props.unassignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true"
route-name="fieldjobs.index"
page-param-name="page_contracts"
per-page-param-name="per_page_contracts"
>
<template #toolbar-filters>
<div class="flex items-center gap-2 w-full">
<Input
v-model="search"
placeholder="Išči po pogodbi, primeru, stranki, naslovu..."
class="w-[320px]"
/>
</div>
</template>
<template #cell-_select="{ row }">
<input
type="checkbox"
class="h-4 w-4"
:value="row.uuid"
v-model="bulkForm.contract_uuids"
<Checkbox
@update:model-value="
(checked) => toggleContractSelection(row.uuid, checked)
"
/>
</template>
<template #cell-case_person="{ row }">
<Link
v-if="row.client_case?.uuid"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="text-indigo-600 hover:underline"
class="font-semibold hover:underline text-primary-700"
>
{{ row.client_case?.person?.full_name || "Primer stranke" }}
</Link>
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
</template>
<template #cell-_actions="{ row }">
<button
class="px-3 py-1 text-xs rounded bg-indigo-600 text-white"
@click="assign(row)"
>
Dodeli
</button>
<template #cell-start_date="{ row }">
{{ formatDate(row.start_date) }}
</template>
<template #empty>
<div class="text-sm text-gray-500 py-4 text-center">
Ni najdenih pogodb.
<template #cell-balance_amount="{ row }">
<div class="text-right">
{{ formatCurrencyEUR(row.account?.balance_amount) }}
</div>
</template>
</DataTableClient>
<template #cell-_actions="{ row }">
<Button size="sm" @click="assign(row)">Dodeli</Button>
</template>
</DataTable>
</div>
<!-- Assigned Contracts via DataTableClient -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<!-- Assigned Contracts -->
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="p-4 border-b">
<h2 class="text-xl font-semibold">Dodeljene pogodbe</h2>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700">Filter po uporabniku</label>
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
<option value="">Vsi</option>
<option v-for="u in users || []" :key="u.id" :value="u.id">
{{ u.name }}
</option>
</select>
</div>
</div>
<DataTableClient
<DataTable
:columns="assignedColumns"
:rows="assignedRows"
:search-keys="[
'reference',
'case_person',
'client_person',
'address',
'assigned_to',
]"
v-model:sort="assignedSort"
v-model:search="search"
v-model:page="assignedPage"
v-model:pageSize="assignedPageSize"
:data="assignedRows"
:meta="{
current_page: assignedContracts.current_page,
per_page: assignedContracts.per_page,
total: assignedContracts.total,
last_page: assignedContracts.last_page,
from: assignedContracts.from,
to: assignedContracts.to,
links: assignedContracts.links,
}"
row-key="uuid"
:page-size="props.assignedContracts?.per_page || 10"
:page-size-options="[10, 15, 25, 50, 100]"
:show-toolbar="true"
route-name="fieldjobs.index"
page-param-name="page_assignments"
per-page-param-name="per_page_assignments"
>
<template #toolbar-filters>
<div class="flex items-center gap-2 w-full">
<Input
v-model="search_contract"
placeholder="Išči po pogodbi, primeru, stranki..."
class="w-[320px]"
/>
<div class="flex items-center gap-2 ml-4">
<Label for="filter-user" class="text-sm whitespace-nowrap"
>Filter po uporabniku</Label
>
<Select v-model="assignedFilterUserId">
<SelectTrigger id="filter-user" class="w-48">
<SelectValue placeholder="Vsi" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Vsi</SelectItem>
<SelectItem
v-for="u in users || []"
:key="u.id"
:value="String(u.id)"
>
{{ u.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<template #cell-case_person="{ row }">
<Link
v-if="row.client_case?.uuid"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="text-indigo-600 hover:underline"
class="font-semibold hover:underline text-primary-700"
>
{{ row.client_case?.person?.full_name || "Primer stranke" }}
</Link>
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
</template>
<template #cell-_actions="{ row }">
<button
class="px-3 py-1 text-xs rounded bg-red-600 text-white"
@click="cancelAssignment(row)"
>
Prekliči
</button>
<template #cell-assigned_at="{ row }">
{{ row.assigned_at_formatted }}
</template>
<template #empty>
<div class="text-sm text-gray-500 py-4 text-center">
Ni dodeljenih pogodb za izbran filter.
<template #cell-balance_amount="{ row }">
<div class="text-right">
{{ formatCurrencyEUR(row.account?.balance_amount) }}
</div>
</template>
</DataTableClient>
<template #cell-_actions="{ row }">
<Button variant="destructive" size="sm" @click="cancelAssignment(row)">
Prekliči
</Button>
</template>
</DataTable>
</div>
</div>
</div>

View File

@ -2,16 +2,24 @@
import AppLayout from "@/Layouts/AppLayout.vue";
import { useForm, router } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import Multiselect from "vue-multiselect";
import axios from "axios";
import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
// Props: provided by controller (clients + templates collections)
const props = defineProps({
templates: Array,
clients: Array,
});
// Basic create form (rest of workflow handled on the Continue page)
const form = useForm({
client_uuid: null,
import_template_id: null,
@ -19,36 +27,12 @@ const form = useForm({
file: null,
});
// Multiselect bridge: client
const selectedClientOption = computed({
get() {
if (!form.client_uuid) return null;
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
},
set(val) {
form.client_uuid = val ? val.uuid : null;
},
});
// Multiselect bridge: template
const selectedTemplateOption = computed({
get() {
if (form.import_template_id == null) return null;
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
},
set(val) {
form.import_template_id = val ? val.id : null;
},
});
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
// Filter templates: show globals when no client; when client selected show only that client's templates
const filteredTemplates = computed(() => {
const cuuid = form.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter((t) => t.client_id == null);
if (!form.client_uuid) {
return props.templates.filter((t) => !t.client_id);
}
return list.filter((t) => t.client_uuid === cuuid || t.client_id == null);
return props.templates.filter((t) => t.client_uuid === form.client_uuid || !t.client_id);
});
const uploading = ref(false);
@ -57,7 +41,7 @@ const uploadError = ref(null);
function onFileChange(e) {
const files = e.target.files;
if (files && files.length) {
if (files?.length) {
form.file = files[0];
uploadError.value = null;
}
@ -65,50 +49,51 @@ function onFileChange(e) {
function onFileDrop(e) {
const files = e.dataTransfer?.files;
if (files && files.length) {
if (files?.length) {
form.file = files[0];
uploadError.value = null;
}
dragActive.value = false;
}
function clearFile() {
form.file = null;
uploadError.value = null;
}
async function startImport() {
uploadError.value = null;
if (!form.file) {
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
uploadError.value = "Najprej izberite datoteko.";
return;
}
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", form.file);
if (form.import_template_id != null) {
if (form.import_template_id) {
fd.append("import_template_id", String(form.import_template_id));
}
if (form.client_uuid) {
fd.append("client_uuid", form.client_uuid);
}
fd.append("has_header", form.has_header ? "1" : "0");
const { data } = await axios.post(route("imports.store"), fd, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (data?.uuid) {
router.visit(route("imports.continue", { import: data.uuid }));
return;
}
if (data?.id) {
// Fallback if only numeric id returned
} else if (data?.id) {
router.visit(route("imports.continue", { import: data.id }));
return;
}
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
} catch (e) {
if (e.response?.data?.message) {
uploadError.value = e.response.data.message;
} else {
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
uploadError.value = "Nepričakovan odgovor strežnika.";
}
} catch (e) {
uploadError.value = e.response?.data?.message || "Nalaganje ni uspelo.";
console.error("Import upload failed", e.response?.status, e.response?.data || e);
} finally {
uploading.value = false;
@ -119,13 +104,14 @@ async function startImport() {
<template>
<AppLayout title="Nov uvoz">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
<h2 class="text-xl font-semibold leading-tight text-gray-800">Nov uvoz</h2>
</template>
<div class="py-6">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
<!-- Intro / guidance -->
<div class="text-sm text-gray-600 leading-relaxed">
<div class="mx-auto max-w-4xl sm:px-6 lg:px-8">
<div class="space-y-8 rounded-lg bg-white p-6 shadow sm:rounded-lg">
<!-- Intro -->
<div class="text-sm leading-relaxed text-gray-600">
<p class="mb-2">
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
@ -137,130 +123,138 @@ async function startImport() {
</div>
<!-- Client & Template selection -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Poišči stranko..."
:searchable="true"
:allow-empty="true"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
<Multiselect
v-model="selectedTemplateOption"
:options="filteredTemplates"
track-by="id"
label="name"
placeholder="Poišči predlogo..."
:searchable="true"
:allow-empty="true"
>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<span class="truncate">{{ option.name }}</span>
<span
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
>{{ option.client_id ? "Client" : "Global" }}</span
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Client Select -->
<div class="space-y-2">
<Label>Stranka</Label>
<Select v-model="form.client_uuid">
<SelectTrigger>
<SelectValue placeholder="Izberite stranko..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="client in clients"
:key="client.uuid"
:value="client.uuid"
>
</div>
</template>
</Multiselect>
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
{{ client.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p class="text-xs text-gray-500">
Če stranka ni izbrana, bo uvoz globalen.
</p>
</div>
<!-- Template Select -->
<div class="space-y-2">
<Label>Predloga</Label>
<Select v-model="form.import_template_id">
<SelectTrigger>
<SelectValue placeholder="Izberite predlogo..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="template in filteredTemplates"
:key="template.id"
:value="template.id"
>
<div class="flex w-full items-center justify-between">
<span class="truncate">{{ template.name }}</span>
<span
class="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
>
{{ template.client_id ? "Client" : "Global" }}
</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p v-if="!form.client_uuid" class="mt-1 text-xs text-gray-500">
Prikazane so samo globalne predloge dokler ne izberete stranke.
</p>
</div>
</div>
<!-- File + Header -->
<div class="grid grid-cols-1 gap-6 items-start">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
<div
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
:class="{
'border-indigo-400 bg-indigo-50': dragActive,
'border-gray-300 hover:border-gray-400': !dragActive,
}"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onFileDrop"
>
<input
type="file"
class="hidden"
id="import-file-input"
@change="onFileChange"
/>
<label for="import-file-input" class="block cursor-pointer select-none">
<div v-if="!form.file" class="text-sm text-gray-600">
Povlecite datoteko sem ali
<span class="text-indigo-600 underline">kliknite za izbiro</span>
</div>
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
<span class="font-medium">{{ form.file.name }}</span>
<span class="text-xs text-gray-500"
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
>
<span
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
>Zamenjaj</span
>
</div>
</label>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="checkbox" v-model="form.has_header" class="rounded" />
<span>Prva vrstica je glava</span>
<!-- File Upload -->
<div class="space-y-4">
<Label>Datoteka</Label>
<div
class="cursor-pointer rounded-md border-2 border-dashed p-6 text-center transition-colors"
:class="{
'border-indigo-400 bg-indigo-50': dragActive,
'border-gray-300 hover:border-gray-400': !dragActive,
}"
@dragover.prevent="dragActive = true"
@dragleave.prevent="dragActive = false"
@drop.prevent="onFileDrop"
>
<input
id="import-file-input"
type="file"
class="hidden"
@change="onFileChange"
/>
<label for="import-file-input" class="block cursor-pointer select-none">
<div v-if="!form.file" class="text-sm text-gray-600">
Povlecite datoteko sem ali
<span class="text-indigo-600 underline">kliknite za izbiro</span>
</div>
<div v-else class="flex flex-col gap-1 text-sm text-gray-800">
<span class="font-medium">{{ form.file.name }}</span>
<span class="text-xs text-gray-500">
{{ (form.file.size / 1024).toFixed(1) }} kB
</span>
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px]">
Zamenjaj
</span>
</div>
</label>
<div class="text-xs text-gray-500 leading-relaxed">
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
</div>
</div>
<!-- Has Header Checkbox -->
<div class="flex items-center space-x-2">
<Checkbox id="has-header" v-model:checked="form.has_header" />
<Label
for="has-header"
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Prva vrstica je glava
</Label>
</div>
<p class="text-xs leading-relaxed text-gray-500">
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
</p>
</div>
<!-- Errors -->
<div v-if="uploadError" class="text-sm text-red-600">
<!-- Error Message -->
<div v-if="uploadError" class="rounded-md bg-red-50 p-3 text-sm text-red-600">
{{ uploadError }}
</div>
<!-- Actions -->
<div class="flex flex-wrap justify-end gap-3 pt-2">
<button
type="button"
@click="
() => {
form.file = null;
uploadError = null;
}
"
<div class="flex flex-wrap justify-end gap-3 border-t pt-4">
<Button
variant="outline"
:disabled="uploading || !form.file"
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
@click="clearFile"
>
Počisti
</button>
<button
type="button"
@click="startImport"
:disabled="uploading"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
>
</Button>
<Button :disabled="uploading" @click="startImport">
<span
v-if="uploading"
class="h-4 w-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin"
></span>
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
</button>
class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
/>
{{ uploading ? "Nalagam..." : "Začni uvoz" }}
</Button>
</div>
<div class="text-xs text-gray-400 pt-4 border-t">
<div class="border-t pt-4 text-xs text-gray-400">
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
preslikave, simulacijo in končno obdelavo.
</div>

View File

@ -9,13 +9,19 @@ import LogsTable from "./Partials/LogsTable.vue";
import ProcessResult from "./Partials/ProcessResult.vue";
import { ref, computed, onMounted, watch } from "vue";
import { router } from "@inertiajs/vue3";
import Multiselect from "vue-multiselect";
import axios from "axios";
import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
import Modal from "@/Components/Modal.vue";
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
import SimulationModal from "./Partials/SimulationModal.vue";
import MissingContractsModal from "./Partials/MissingContractsModal.vue";
import FoundContractsModal from "./Partials/FoundContractsModal.vue";
import UnresolvedRowsModal from "./Partials/UnresolvedRowsModal.vue";
import { useCurrencyFormat } from "./useCurrencyFormat.js";
import DialogModal from "@/Components/DialogModal.vue";
import { Switch } from "@/Components/ui/switch";
import { Label } from "@/Components/ui/label";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Checkbox } from "@/Components/ui/checkbox";
// Reintroduce props definition lost during earlier edits
const props = defineProps({
@ -180,11 +186,6 @@ async function openUnresolved() {
unresolvedLoading.value = false;
}
}
function downloadUnresolvedCsv() {
if (!importId.value) return;
// Direct download
window.location.href = route("imports.missing-keyref-csv", { import: importId.value });
}
// History import: list of contracts that already existed in DB and were matched
const isHistoryImport = computed(() => {
@ -592,32 +593,39 @@ const statusInfo = computed(() => {
completed: {
label: "Zaključeno",
classes: "bg-emerald-100 text-emerald-700 border border-emerald-300",
variant: "default",
},
processing: {
label: "Obdelava",
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
variant: "default",
},
validating: {
label: "Preverjanje",
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
variant: "default",
},
failed: {
label: "Neuspešno",
classes: "bg-red-100 text-red-700 border border-red-300",
variant: "destructive",
},
parsed: {
label: "Razčlenjeno",
classes: "bg-slate-100 text-slate-700 border border-slate-300",
variant: "secondary",
},
uploaded: {
label: "Naloženo",
classes: "bg-slate-100 text-slate-700 border border-slate-300",
variant: "secondary",
},
};
return (
map[raw] || {
label: raw || "Status",
classes: "bg-gray-100 text-gray-700 border border-gray-300",
variant: "outline",
}
);
});
@ -1117,11 +1125,19 @@ async function fetchSimulation() {
headers: { Accept: "application/json" },
withCredentials: true,
});
// V2 format
paymentSimRows.value = Array.isArray(data?.rows) ? data.rows : [];
paymentSimEntities.value = Array.isArray(data?.entities) ? data.entities : [];
// Summaries keys vary (payment, contract, account, etc.). Keep existing behaviour for payment summary exposure.
paymentSimSummary.value = data?.summaries?.payment || null;
paymentSimSummarySl.value = data?.povzetki?.payment || null;
paymentSimSummary.value = data?.summaries || null;
// Extract unique entity types from rows for SimulationModal
const entitySet = new Set();
for (const row of data?.rows || []) {
if (row.entities && typeof row.entities === 'object') {
Object.keys(row.entities).forEach(key => entitySet.add(key));
}
}
paymentSimEntities.value = Array.from(entitySet);
} catch (e) {
console.error("Simulation failed", e.response?.status || "", e.response?.data || e);
} finally {
@ -1142,20 +1158,20 @@ async function fetchSimulation() {
selectedClientOption?.name || selectedClientOption?.uuid || "—"
}}</strong>
</span>
<span
v-if="templateApplied"
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
>uporabljena</span
<Badge v-if="templateApplied" variant="secondary" class="text-[10px]"
>uporabljena</Badge
>
<span
<Badge
v-if="props.import?.status"
:class="['px-2 py-0.5 rounded-full text-xs font-medium', statusInfo.classes]"
>{{ statusInfo.label }}</span
:variant="statusInfo.variant || 'default'"
class="text-xs"
>{{ statusInfo.label }}</Badge
>
<span
<Badge
v-if="showMissingEnabled"
class="text-[10px] px-1 py-0.5 rounded bg-amber-100 text-amber-700 align-middle"
>seznam manjkajočih</span
variant="outline"
class="text-[10px] bg-amber-50 text-amber-700 border-amber-200"
>seznam manjkajočih</Badge
>
</div>
</div>
@ -1167,13 +1183,15 @@ async function fetchSimulation() {
v-if="isHistoryImport || historyFoundContracts.length"
class="flex flex-wrap items-center gap-2 text-sm"
>
<button
class="px-3 py-1.5 bg-emerald-700 text-white text-xs rounded"
<Button
variant="default"
size="sm"
class="bg-emerald-700 hover:bg-emerald-800 text-xs"
@click.prevent="showFoundContracts = true"
title="Prikaži pogodbe, ki so bile najdene in že obstajajo v bazi"
>
Najdene pogodbe
</button>
</Button>
<span v-if="historyFoundContracts.length" class="text-xs text-gray-600">
{{ historyFoundContracts.length }} že obstoječih
</span>
@ -1210,28 +1228,34 @@ async function fetchSimulation() {
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
<Button
variant="secondary"
size="sm"
class="text-xs"
@click.prevent="openPreview"
>
Ogled CSV
</button>
<button
</Button>
<Button
v-if="canShowMissingButton"
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded"
variant="default"
size="sm"
class="bg-indigo-600 hover:bg-indigo-700 text-xs"
@click.prevent="openMissingContracts"
title="Prikaži aktivne pogodbe, ki niso bile prisotne v uvozu (samo keyref)"
>
Ogled manjkajoče
</button>
<button
</Button>
<Button
v-if="isCompleted && contractRefIsKeyref"
class="px-3 py-1.5 bg-amber-600 text-white text-xs rounded"
variant="default"
size="sm"
class="bg-amber-600 hover:bg-amber-700 text-xs"
@click.prevent="openUnresolved"
title="Prikaži vrstice, kjer pogodba (keyref) ni bila najdena"
>
Neobstoječi
</button>
</Button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@ -1265,22 +1289,34 @@ async function fetchSimulation() {
@apply-template="applyTemplateToImport"
/>
<!-- Import options -->
<div v-if="!isCompleted" class="mt-2 p-3 rounded border bg-gray-50">
<div class="flex items-center gap-3">
<label class="inline-flex items-center text-sm text-gray-700">
<input
type="checkbox"
class="rounded mr-2"
v-model="showMissingEnabled"
@change="saveImportOptions"
/>
<span>Seznam manjkajočih (po končanem uvozu)</span>
</label>
<div
v-if="!isCompleted"
class="mt-2 p-4 rounded-lg border bg-linear-to-br from-gray-50 to-gray-100"
>
<div class="flex items-start gap-3">
<Checkbox
:id="'show-missing-checkbox'"
:checked="showMissingEnabled"
@update:checked="
(val) => {
showMissingEnabled = val;
saveImportOptions();
}
"
/>
<div class="flex-1">
<Label
:for="'show-missing-checkbox'"
class="text-sm font-medium text-gray-700 cursor-pointer"
>
Seznam manjkajočih (po končanem uvozu)
</Label>
<p class="mt-1 text-xs text-gray-500">
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
</p>
</div>
</div>
<p class="mt-1 text-xs text-gray-500">
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
</p>
</div>
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
</div>
@ -1356,160 +1392,37 @@ async function fetchSimulation() {
:truncated="previewTruncated"
:has-header="detected.has_header"
@close="showPreview = false"
@change-limit="(val) => (previewLimit = val)"
@change-limit="
async (val) => {
previewLimit = val;
await fetchPreview();
}
"
@refresh="fetchPreview"
/>
<!-- Missing contracts modal -->
<Modal
<MissingContractsModal
:show="showMissingContracts"
max-width="2xl"
:loading="missingContractsLoading"
:contracts="missingContracts"
:format-money="formatMoney"
@close="showMissingContracts = false"
>
<div class="p-4 max-h-[70vh] overflow-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg">Manjkajoče pogodbe (aktivne, ne-arhivirane)</h3>
<button
class="text-gray-500 hover:text-gray-700"
@click.prevent="showMissingContracts = false"
>
Zapri
</button>
</div>
<div v-if="missingContractsLoading" class="py-8 text-center text-sm text-gray-500">
Nalagam
</div>
<div v-else>
<div v-if="!missingContracts.length" class="py-6 text-sm text-gray-600">
Ni zadetkov.
</div>
<ul v-else class="divide-y divide-gray-200">
<li
v-for="row in missingContracts"
:key="row.uuid"
class="py-2 text-sm flex items-center justify-between"
>
<div class="min-w-0">
<div class="font-mono text-gray-800">{{ row.reference }}</div>
<div class="text-xs text-gray-500 truncate">
<span class="font-medium text-gray-600">Primer: </span>
<span>{{ row.full_name || "—" }}</span>
<span v-if="row.balance_amount != null" class="ml-2"
> {{ formatMoney(row.balance_amount) }}</span
>
</div>
</div>
<div class="flex-shrink-0">
<a
:href="route('clientCase.show', { client_case: row.case_uuid })"
class="text-blue-600 hover:underline text-xs"
>Odpri primer</a
>
</div>
</li>
</ul>
</div>
</div>
</Modal>
/>
<!-- History import: existing contracts found -->
<DialogModal :show="showFoundContracts" max-width="3xl" @close="showFoundContracts = false">
<template #title>Obstoječe pogodbe najdene v zgodovinskem uvozu</template>
<template #content>
<div v-if="!historyFoundContracts.length" class="text-sm text-gray-600">Ni zadetkov.</div>
<ul v-else class="divide-y divide-gray-200 max-h-[70vh] overflow-auto">
<li
v-for="item in historyFoundContracts"
:key="item.contract_uuid || item.reference"
class="py-3 flex items-center justify-between gap-4"
>
<div class="min-w-0">
<div class="font-mono text-sm text-gray-900">{{ item.reference }}</div>
<div class="text-xs text-gray-600 truncate">
<span>{{ item.full_name || "—" }}</span>
</div>
</div>
<div class="flex-shrink-0">
<a
v-if="item.case_uuid"
:href="route('clientCase.show', { client_case: item.case_uuid })"
class="text-blue-600 hover:underline text-xs"
>
Odpri primer
</a>
</div>
</li>
</ul>
</template>
<template #footer>
<button
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
@click.prevent="showFoundContracts = false"
>
Zapri
</button>
</template>
</DialogModal>
<FoundContractsModal
:show="showFoundContracts"
:contracts="historyFoundContracts"
@close="showFoundContracts = false"
/>
<!-- Unresolved keyref rows modal -->
<Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false">
<div class="p-4 max-h-[75vh] overflow-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg">
Vrstice z neobstoječim contract.reference (KEYREF)
</h3>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded"
@click.prevent="downloadUnresolvedCsv"
>
Prenesi CSV
</button>
<button
class="text-gray-500 hover:text-gray-700"
@click.prevent="showUnresolved = false"
>
Zapri
</button>
</div>
</div>
<div v-if="unresolvedLoading" class="py-8 text-center text-sm text-gray-500">
Nalagam
</div>
<div v-else>
<div v-if="!unresolvedRows.length" class="py-6 text-sm text-gray-600">
Ni zadetkov.
</div>
<div v-else class="overflow-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left w-24"># vrstica</th>
<th
v-for="(c, i) in unresolvedColumns"
:key="i"
class="px-3 py-2 text-left"
>
{{ c }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="r in unresolvedRows" :key="r.id" class="border-t">
<td class="px-3 py-2 text-gray-500">{{ r.row_number }}</td>
<td
v-for="(c, i) in unresolvedColumns"
:key="i"
class="px-3 py-2 whitespace-pre-wrap break-words"
>
{{ r.values?.[i] ?? "" }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</Modal>
<UnresolvedRowsModal
:show="showUnresolved"
:loading="unresolvedLoading"
:columns="unresolvedColumns"
:rows="unresolvedRows"
:import-id="importId"
@close="showUnresolved = false"
/>
<SimulationModal
:show="showPaymentSim"
:rows="paymentSimRows"
@ -1522,8 +1435,9 @@ async function fetchSimulation() {
:money-formatter="formatMoney"
@close="showPaymentSim = false"
@change-limit="
(val) => {
async (val) => {
paymentSimLimit = val;
await fetchSimulation();
}
"
@toggle-verbose="

View File

@ -5,6 +5,9 @@ import {
BeakerIcon,
ArrowDownOnSquareIcon,
} from "@heroicons/vue/24/outline";
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
const props = defineProps({
importId: [Number, String],
isCompleted: Boolean,
@ -17,47 +20,50 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
</script>
<template>
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
<button
<Button
variant="secondary"
@click.prevent="$emit('preview')"
:disabled="!importId"
class="px-4 py-2 bg-gray-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
>
<EyeIcon class="h-4 w-4" />
<EyeIcon class="h-4 w-4 mr-2" />
Predogled vrstic
</button>
<button
</Button>
<Button
variant="default"
class="bg-orange-600 hover:bg-orange-700"
@click.prevent="$emit('save-mappings')"
:disabled="!importId || processing || savingMappings || isCompleted"
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
title="Shrani preslikave za ta uvoz"
>
<span
v-if="savingMappings"
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
></span>
<ArrowPathIcon v-else class="h-4 w-4" />
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
<span>Shrani preslikave</span>
<span
<Badge
v-if="selectedMappingsCount"
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
>{{ selectedMappingsCount }}</span
>
</button>
<button
variant="secondary"
class="ml-2 text-xs"
>{{ selectedMappingsCount }}</Badge>
</Button>
<Button
variant="default"
class="bg-purple-600 hover:bg-purple-700"
@click.prevent="$emit('process-import')"
:disabled="!canProcess"
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
>
<BeakerIcon class="h-4 w-4" />
<BeakerIcon class="h-4 w-4 mr-2" />
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
</button>
<button
</Button>
<Button
variant="default"
class="bg-blue-600 hover:bg-blue-700"
@click.prevent="$emit('simulate')"
:disabled="!importId || processing"
class="px-4 py-2 bg-blue-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
>
<ArrowDownOnSquareIcon class="h-4 w-4" />
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
Simulacija vnosa
</button>
</Button>
</div>
</template>

View File

@ -1,16 +1,21 @@
<script setup>
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
import { Badge } from '@/Components/ui/badge'
const props = defineProps({ steps: Array, missingCritical: Array })
</script>
<template>
<div class="bg-gray-50 border rounded p-3 text-xs flex flex-col gap-1 h-fit">
<div class="font-semibold text-gray-700 mb-1">Kontrolni seznam</div>
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-gray-500'">
<div class="bg-muted/50 border rounded-lg p-4 text-xs flex flex-col gap-2 h-fit">
<div class="font-semibold text-foreground mb-1">Kontrolni seznam</div>
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-muted-foreground'">
<CheckCircleIcon v-if="s.done" class="h-4 w-4 text-emerald-600" />
<span v-else class="h-4 w-4 rounded-full border border-gray-300 inline-block"></span>
<span v-else class="h-4 w-4 rounded-full border-2 border-muted-foreground/30 inline-block"></span>
<span>{{ s.label }}</span>
</div>
<div v-if="missingCritical?.length" class="mt-2 text-red-600 font-medium">Manjkajo kritične: {{ missingCritical.join(', ') }}</div>
<div v-else class="mt-2 text-emerald-600">Kritične preslikave prisotne</div>
<div v-if="missingCritical?.length" class="mt-2">
<Badge variant="destructive" class="text-[10px]">Manjkajo kritične: {{ missingCritical.join(', ') }}</Badge>
</div>
<div v-else class="mt-2">
<Badge variant="default" class="text-[10px] bg-emerald-600">Kritične preslikave prisotne</Badge>
</div>
</div>
</template>

View File

@ -1,5 +1,10 @@
<script setup>
import Modal from '@/Components/Modal.vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label";
const props = defineProps({
show: Boolean,
limit: Number,
@ -13,49 +18,69 @@ const emits = defineEmits(['close','change-limit','refresh'])
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
</script>
<template>
<Modal :show="show" max-width="wide" @close="$emit('close')">
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-lg">CSV Preview ({{ rows.length }} / {{ limit }})</h3>
<button class="text-sm px-2 py-1 rounded border" @click="$emit('close')">Close</button>
</div>
<div class="mb-2 flex items-center gap-3 text-sm">
<div>
<label class="mr-1 text-gray-600">Limit:</label>
<select :value="limit" class="border rounded p-1" @change="onLimit">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="200">200</option>
<option :value="300">300</option>
<option :value="500">500</option>
</select>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
</DialogHeader>
<div class="flex items-center gap-3 pb-3 border-b">
<div class="flex items-center gap-2">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
<SelectTrigger id="limit-select" class="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
<button @click="$emit('refresh')" class="px-2 py-1 border rounded" :disabled="loading">{{ loading ? 'Loading…' : 'Refresh' }}</button>
<span v-if="truncated" class="text-xs text-amber-600">Truncated at limit</span>
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</Button>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit
</Badge>
</div>
<div class="overflow-auto max-h-[60vh] border rounded">
<table class="min-w-full text-xs">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="p-2 border bg-white">#</th>
<th v-for="col in columns" :key="col" class="p-2 border text-left">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">Loading</td>
</tr>
<tr v-for="(r, idx) in rows" :key="idx" class="border-t hover:bg-gray-50">
<td class="p-2 border text-gray-500">{{ idx + 1 }}</td>
<td v-for="col in columns" :key="col" class="p-2 border whitespace-pre-wrap">{{ r[col] }}</td>
</tr>
<tr v-if="!loading && !rows.length">
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">No rows</td>
</tr>
</tbody>
</table>
<div class="flex-1 overflow-auto border rounded-lg">
<Table>
<TableHeader class="sticky top-0 bg-white z-10">
<TableRow>
<TableHead class="w-16">#</TableHead>
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
Loading
</TableCell>
</TableRow>
<TableRow v-for="(r, idx) in rows" :key="idx">
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
{{ r[col] }}
</TableCell>
</TableRow>
<TableRow v-if="!loading && !rows.length">
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
No rows
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<p class="mt-2 text-xs text-gray-500">Showing up to {{ limit }} rows from source file. Header detection: {{ hasHeader ? 'header present' : 'no header' }}.</p>
</div>
</Modal>
<div class="text-xs text-gray-500 pt-3 border-t">
Showing up to {{ limit }} rows from source file.
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,63 @@
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
show: { type: Boolean, default: false },
contracts: { type: Array, default: () => [] },
});
const emit = defineEmits(["close"]);
</script>
<template>
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
<DialogContent class="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Obstoječe pogodbe najdene v zgodovinskem uvozu</DialogTitle>
</DialogHeader>
<div class="flex-1 overflow-auto">
<div v-if="!contracts.length" class="py-12 text-center">
<p class="text-sm text-gray-500">Ni zadetkov.</p>
</div>
<div v-else class="divide-y">
<div
v-for="item in contracts"
:key="item.contract_uuid || item.reference"
class="p-4 hover:bg-gray-50 transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<code class="text-sm font-medium text-gray-900">{{ item.reference }}</code>
<Badge variant="outline" class="text-[10px]">Najdena</Badge>
</div>
<div class="text-xs text-gray-600">
<span>{{ item.full_name || "—" }}</span>
</div>
</div>
<Button
v-if="item.case_uuid"
variant="outline"
size="sm"
as="a"
:href="route('clientCase.show', { client_case: item.case_uuid })"
class="shrink-0"
>
Odpri primer
</Button>
</div>
</div>
</div>
</div>
<div class="border-t pt-4 flex justify-end">
<Button variant="secondary" @click="emit('close')">Zapri</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -1,6 +1,12 @@
<script setup>
import { ref, computed } from "vue";
import Dropdown from "@/Components/Dropdown.vue";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/Components/ui/accordion';
const props = defineProps({
events: Array,
@ -8,8 +14,8 @@ const props = defineProps({
limit: Number,
});
const emits = defineEmits(["update:limit", "refresh"]);
function onLimit(e) {
emits("update:limit", Number(e.target.value));
function onLimit(val) {
emits("update:limit", Number(val));
emits("refresh");
}
@ -46,6 +52,32 @@ function toggleExpand(id) {
expanded.value = new Set(expanded.value);
}
// Entity details dialog
const detailsDialog = ref(false);
const selectedEvent = ref(null);
function hasEntityDetails(ev) {
const ctx = tryJson(ev.context);
return ctx && Array.isArray(ctx.entity_details) && ctx.entity_details.length > 0;
}
function showEntityDetails(ev) {
selectedEvent.value = ev;
detailsDialog.value = true;
}
function getEntityDetails(ev) {
if (!ev) return [];
const ctx = tryJson(ev.context);
return ctx?.entity_details || [];
}
function getRawData(ev) {
if (!ev) return {};
const ctx = tryJson(ev.context);
return ctx?.raw_data || {};
}
function isLong(msg) {
return msg && String(msg).length > 160;
}
@ -138,68 +170,72 @@ function formattedContext(ctx) {
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Logs</h3>
<div class="flex items-center flex-wrap gap-2 text-sm">
<label class="text-gray-600">Show</label>
<select :value="limit" class="border rounded p-1" @change="onLimit">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="200">200</option>
<option :value="500">500</option>
</select>
<label class="text-gray-600 ml-2">Level</label>
<select v-model="levelFilter" class="border rounded p-1">
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button
<span class="text-muted-foreground">Show</span>
<Select :model-value="limit.toString()" @update:model-value="onLimit">
<SelectTrigger class="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span class="text-muted-foreground ml-2">Level</span>
<Select v-model="levelFilter">
<SelectTrigger class="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
@click.prevent="$emit('refresh')"
class="px-2 py-1 border rounded text-sm"
:disabled="loading"
>
{{ loading ? "Refreshing…" : "Refresh" }}
</button>
</Button>
</div>
</div>
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
<table class="min-w-full bg-white text-sm table-fixed">
<colgroup>
<col class="w-40" />
<col class="w-20" />
<col class="w-40" />
<col />
<col class="w-16" />
</colgroup>
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
<tr class="text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Time</th>
<th class="p-2 border">Level</th>
<th class="p-2 border">Event</th>
<th class="p-2 border">Message</th>
<th class="p-2 border">Row</th>
</tr>
</thead>
<tbody>
<tr v-for="ev in filteredEvents" :key="ev.id" class="border-t align-top">
<td class="p-2 border whitespace-nowrap">
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded-lg border">
<Table>
<TableHeader class="sticky top-0 z-10">
<TableRow>
<TableHead class="w-[160px]">Time</TableHead>
<TableHead class="w-[80px]">Level</TableHead>
<TableHead class="w-[160px]">Event</TableHead>
<TableHead>Message</TableHead>
<TableHead class="w-[64px]">Row</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="ev in filteredEvents" :key="ev.id">
<TableCell class="whitespace-nowrap">
{{ new Date(ev.created_at).toLocaleString() }}
</td>
<td class="p-2 border">
<span
</TableCell>
<TableCell>
<Badge
:variant="ev.level === 'error' ? 'destructive' : ev.level === 'warning' ? 'default' : 'secondary'"
:class="[
'px-2 py-0.5 rounded text-xs',
ev.level === 'error'
? 'bg-red-100 text-red-800'
: ev.level === 'warning'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-700',
'text-xs',
ev.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : ''
]"
>{{ ev.level }}</span
>
</td>
<td class="p-2 border break-words max-w-[9rem]">
>{{ ev.level }}</Badge>
</TableCell>
<TableCell class="max-w-[9rem]">
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
</td>
<td class="p-2 border align-top max-w-[28rem]">
</TableCell>
<TableCell class="max-w-[28rem]">
<div class="space-y-1 break-words">
<div class="leading-snug whitespace-pre-wrap">
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
@ -215,7 +251,15 @@ function formattedContext(ctx) {
</button>
</span>
</div>
<div v-if="ev.context" class="text-xs text-gray-600">
<div v-if="ev.context" class="text-xs text-gray-600 flex items-center gap-2">
<button
v-if="hasEntityDetails(ev)"
type="button"
class="px-2 py-1 rounded border border-indigo-300 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 transition text-[11px] font-medium"
@click="showEntityDetails(ev)"
>
📋 Entity Details
</button>
<Dropdown
align="left"
width="wide"
@ -255,14 +299,91 @@ function formattedContext(ctx) {
</Dropdown>
</div>
</div>
</td>
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
</tr>
<tr v-if="!filteredEvents.length">
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
</tr>
</tbody>
</table>
</TableCell>
<TableCell>{{ ev.import_row_id ?? "—" }}</TableCell>
</TableRow>
<TableRow v-if="!filteredEvents.length">
<TableCell colspan="5" class="text-center text-muted-foreground">No events yet</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Entity Details Dialog -->
<Dialog v-model:open="detailsDialog">
<DialogContent class="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Entity Processing Details</DialogTitle>
<DialogDescription v-if="selectedEvent">
Row {{ tryJson(selectedEvent.context)?.row || '—' }} - {{ selectedEvent.event }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedEvent" class="space-y-3 mt-4">
<div
v-for="(detail, idx) in getEntityDetails(selectedEvent)"
:key="idx"
class="p-3 rounded-lg border"
:class="{
'bg-red-50 border-red-200': detail.level === 'error',
'bg-amber-50 border-amber-200': detail.level === 'warning',
'bg-green-50 border-green-200': detail.level === 'info' && detail.action === 'inserted',
'bg-blue-50 border-blue-200': detail.level === 'info' && detail.action === 'updated',
'bg-gray-50 border-gray-200': detail.level === 'info' && detail.action === 'skipped'
}"
>
<div class="flex items-start justify-between mb-2">
<div class="font-medium text-sm capitalize">{{ detail.entity }}</div>
<Badge
:variant="detail.level === 'error' ? 'destructive' : detail.level === 'warning' ? 'default' : 'secondary'"
:class="[
'text-xs',
detail.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : '',
detail.action === 'inserted' ? 'bg-green-100 text-green-800 hover:bg-green-100' : '',
detail.action === 'updated' ? 'bg-blue-100 text-blue-800 hover:bg-blue-100' : '',
detail.action === 'skipped' ? 'bg-gray-200 text-gray-700 hover:bg-gray-200' : ''
]"
>
{{ detail.action }}{{ detail.count > 1 ? ` (${detail.count})` : '' }}
</Badge>
</div>
<div v-if="detail.message" class="text-sm text-gray-700 mb-1">
{{ detail.message }}
</div>
<div v-if="detail.errors && detail.errors.length" class="mt-2 space-y-1">
<div class="text-xs font-medium text-red-700">Errors:</div>
<div
v-for="(err, errIdx) in detail.errors"
:key="errIdx"
class="text-xs text-red-600 pl-3"
>
{{ err }}
</div>
</div>
<div v-if="detail.exception" class="mt-2 p-2 bg-red-100 rounded border border-red-200">
<div class="text-xs font-semibold text-red-800 mb-1">Exception:</div>
<div class="text-xs text-red-700">{{ detail.exception.message }}</div>
<div v-if="detail.exception.file" class="text-xs text-red-600 mt-1">
{{ detail.exception.file }}:{{ detail.exception.line }}
</div>
</div>
</div>
<div v-if="getEntityDetails(selectedEvent).length === 0" class="text-center text-muted-foreground py-4">
No entity details available
</div>
<!-- Raw Row Data Accordion -->
<Accordion type="single" collapsible class="mt-4 border-t pt-4">
<AccordionItem value="raw-data" class="border-b-0">
<AccordionTrigger class="text-sm font-medium hover:no-underline py-2">
📄 Raw Row Data (JSON)
</AccordionTrigger>
<AccordionContent>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto mt-2">{{ JSON.stringify(getRawData(selectedEvent), null, 2) }}</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -1,4 +1,11 @@
<script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
import { Checkbox } from '@/Components/ui/checkbox';
import { Input } from '@/Components/ui/input';
import { Badge } from '@/Components/ui/badge';
import { ScrollArea } from '@/Components/ui/scroll-area';
const props = defineProps({
rows: Array,
entityOptions: Array,
@ -17,97 +24,145 @@ const emits = defineEmits(['update:rows','save'])
function duplicateTarget(row){
if(!row || !row.entity || !row.field) return false
// parent already marks duplicates in duplicateTargets set keyed as record.field
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
}
</script>
<template>
<div v-if="show && rows?.length" class="pt-4">
<h3 class="font-semibold mb-2">
Detected Columns ({{ detected?.has_header ? 'header' : 'positional' }})
<span class="ml-2 text-xs text-gray-500">detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}</span>
</h3>
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
<div class="relative border rounded overflow-auto max-h-[420px]">
<table class="min-w-full bg-white">
<thead class="sticky top-0 z-10">
<tr class="bg-gray-50/95 backdrop-blur text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Entity</th>
<th class="p-2 border">Field</th>
<th class="p-2 border">Meta key</th>
<th class="p-2 border">Meta type</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in rows" :key="idx" class="border-t" :class="duplicateTarget(row) ? 'bg-red-50' : ''">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value=""></option>
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.field" :class="['border rounded p-1 w-full', duplicateTarget(row) ? 'border-red-500 bg-red-50' : '']" :disabled="isCompleted">
<option value=""></option>
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
</select>
</td>
<td class="p-2 border">
<input
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">
Detected Columns
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
</h3>
<div class="text-xs text-muted-foreground">
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
</div>
</div>
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
<div class="relative border rounded-lg">
<ScrollArea class="h-[420px]">
<Table>
<TableHeader class="sticky top-0 z-10 bg-background">
<TableRow class="hover:bg-transparent">
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
<TableCell>
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue placeholder="Select entity..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
:model-value="row.field || ''"
@update:model-value="(val) => row.field = val || ''"
:disabled="isCompleted"
:class="duplicateTarget(row) ? 'border-destructive' : ''"
>
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).key"
type="text"
class="border rounded p-1 w-full"
class="h-8 text-xs"
placeholder="e.g. monthly_rent"
:disabled="isCompleted"
/>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select
v-if="row.field === 'meta'"
v-model="(row.options ||= {}).type"
class="border rounded p-1 w-full"
:model-value="(row.options ||= {}).type || 'string'"
@update:model-value="(val) => (row.options ||= {}).type = val"
:disabled="isCompleted"
>
<option :value="null">Default (string)</option>
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
<option value="boolean">boolean</option>
</select>
<span v-else class="text-gray-400 text-xs"></span>
</td>
<td class="p-2 border">
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="">None</option>
<option value="trim">Trim</option>
<option value="upper">Uppercase</option>
<option value="lower">Lowercase</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.apply_mode" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="keyref">Keyref</option>
<option value="both">Both</option>
<option value="insert">Insert only</option>
<option value="update">Update only</option>
</select>
</td>
<td class="p-2 border text-center">
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
</td>
</tr>
</tbody>
</table>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span v-else class="text-muted-foreground text-xs"></span>
</TableCell>
<TableCell>
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="none">None</SelectItem>
<SelectItem value="trim">Trim</SelectItem>
<SelectItem value="upper">Uppercase</SelectItem>
<SelectItem value="lower">Lowercase</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="keyref">Keyref</SelectItem>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="insert">Insert only</SelectItem>
<SelectItem value="update">Update only</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</TableCell>
<TableCell class="text-center">
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</ScrollArea>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
<span>{{ mappingSavedCount }} mappings saved</span>
</div>
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
<div v-if="missingCritical?.length" class="mt-2">
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
<div v-if="missingCritical?.length" class="text-xs text-amber-600 mt-1">Missing critical: {{ missingCritical.join(', ') }}</div>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Skeleton } from "@/Components/ui/skeleton";
const props = defineProps({
show: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
contracts: { type: Array, default: () => [] },
formatMoney: { type: Function, required: true },
});
const emit = defineEmits(["close"]);
</script>
<template>
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
<DialogContent class="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Manjkajoče pogodbe (aktivne, ne-arhivirane)</DialogTitle>
</DialogHeader>
<div class="flex-1 overflow-auto">
<div v-if="loading" class="space-y-3 p-4">
<Skeleton v-for="i in 5" :key="i" class="h-16 w-full" />
</div>
<div v-else-if="!contracts.length" class="py-12 text-center">
<p class="text-sm text-gray-500">Ni zadetkov.</p>
</div>
<div v-else class="divide-y">
<div
v-for="row in contracts"
:key="row.uuid"
class="p-4 hover:bg-gray-50 transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<code class="text-sm font-medium text-gray-900">{{
row.reference
}}</code>
<Badge variant="secondary" class="text-[10px]">Aktivna</Badge>
</div>
<div class="text-xs text-gray-600 space-y-0.5">
<div class="flex items-center gap-2">
<span class="font-medium">Primer:</span>
<span class="truncate">{{ row.full_name || "—" }}</span>
</div>
<div v-if="row.balance_amount != null" class="flex items-center gap-2">
<span class="font-medium">Stanje:</span>
<span class="font-mono">{{ formatMoney(row.balance_amount) }}</span>
</div>
</div>
</div>
<Button
variant="outline"
size="sm"
as="a"
:href="route('clientCase.show', { client_case: row.case_uuid })"
class="shrink-0"
>
Odpri primer
</Button>
</div>
</div>
</div>
</div>
<div class="border-t pt-4 flex justify-end">
<Button variant="secondary" @click="emit('close')">Zapri</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -1,9 +1,14 @@
<script setup>
const props = defineProps({ result: [String, Object] })
import { Badge } from "@/Components/ui/badge";
const props = defineProps({ result: [String, Object] });
</script>
<template>
<div v-if="result" class="pt-4">
<h3 class="font-semibold mb-2">Import Result</h3>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ result }}</pre>
<div class="flex items-center gap-2 mb-2">
<h3 class="font-semibold">Import Result</h3>
<Badge variant="default" class="bg-emerald-600">Complete</Badge>
</div>
<pre class="bg-muted border rounded-lg p-4 text-sm overflow-x-auto">{{ result }}</pre>
</div>
</template>

View File

@ -1,44 +1,53 @@
<script setup>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Badge } from '@/Components/ui/badge';
const props = defineProps({ mappings: Array });
</script>
<template>
<div v-if="mappings?.length" class="pt-4">
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white text-sm">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Target field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Mode</th>
<th class="p-2 border">Options</th>
</tr>
</thead>
<tbody>
<tr
<div class="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Source column</TableHead>
<TableHead>Target field</TableHead>
<TableHead>Transform</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Options</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="m in mappings"
:key="m.id || m.source_column + m.target_field"
class="border-t"
>
<td class="p-2 border">{{ m.source_column }}</td>
<td class="p-2 border">{{ m.target_field }}</td>
<td class="p-2 border">{{ m.transform || "—" }}</td>
<td class="p-2 border">{{ m.apply_mode || "both" }}</td>
<td class="p-2 border">
<TableCell class="font-medium">{{ m.source_column }}</TableCell>
<TableCell>{{ m.target_field }}</TableCell>
<TableCell>
<Badge v-if="m.transform" variant="outline" class="text-xs">{{ m.transform }}</Badge>
<span v-else class="text-muted-foreground"></span>
</TableCell>
<TableCell>
<Badge variant="secondary" class="text-xs">{{ m.apply_mode || "both" }}</Badge>
</TableCell>
<TableCell>
<template v-if="m.options">
<span v-if="m.options.key" class="inline-block mr-2"
>key: <strong>{{ m.options.key }}</strong></span
>
<span v-if="m.options.type" class="inline-block"
>type: <strong>{{ m.options.type }}</strong></span
>
<div class="flex flex-wrap gap-1">
<Badge v-if="m.options.key" variant="outline" class="text-[10px]">
key: {{ m.options.key }}
</Badge>
<Badge v-if="m.options.type" variant="outline" class="text-[10px]">
type: {{ m.options.type }}
</Badge>
</div>
</template>
<span v-else></span>
</td>
</tr>
</tbody>
</table>
<span v-else class="text-muted-foreground"></span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,10 @@
<script setup>
import Multiselect from "vue-multiselect";
import { computed } from "vue";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
isCompleted: Boolean,
@ -19,11 +23,11 @@ const emits = defineEmits([
"preview",
]);
function onHeaderChange(e) {
emits("update:hasHeader", e.target.value === "true");
function onHeaderChange(val) {
emits("update:hasHeader", val === "true");
}
function onDelimiterMode(e) {
emits("update:delimiterMode", e.target.value);
function onDelimiterMode(val) {
emits("update:delimiterMode", val);
}
function onDelimiterCustom(e) {
emits("update:delimiterCustom", e.target.value);
@ -44,116 +48,119 @@ const selectedTemplateProxy = computed({
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700">Template</label>
<Multiselect
v-model="selectedTemplateProxy"
:options="filteredTemplates"
track-by="id"
label="name"
placeholder="Izberi predlogo..."
:searchable="true"
:allow-empty="true"
class="mt-1"
:custom-label="(o) => o.name"
:disabled="filteredTemplates?.length === 0"
:show-no-results="true"
:clear-on-select="false"
<Label class="text-sm font-medium">Template</Label>
<Select
:model-value="selectedTemplateProxy?.id?.toString()"
@update:model-value="(val) => {
const tpl = filteredTemplates.find(t => t.id.toString() === val);
selectedTemplateProxy = tpl || null;
}"
>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span v-if="option.source_type" class="ml-2 text-xs text-gray-500"
>({{ option.source_type }})</span
>
<SelectTrigger class="mt-1">
<SelectValue placeholder="Izberi predlogo...">
<div v-if="selectedTemplateProxy" class="flex items-center gap-2">
<span>{{ selectedTemplateProxy.name }}</span>
<span v-if="selectedTemplateProxy.source_type" class="text-xs text-muted-foreground">({{ selectedTemplateProxy.source_type }})</span>
<Badge variant="outline" class="text-[10px]">{{ selectedTemplateProxy.client_id ? 'Client' : 'Global' }}</Badge>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
option.client_id ? "Client" : "Global"
}}</span>
</div>
</template>
<template #singleLabel="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span v-if="option.source_type" class="ml-1 text-xs text-gray-500"
>({{ option.source_type }})</span
>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{
option.client_id ? "Client" : "Global"
}}</span>
</div>
</template>
<template #noResult>
<div class="px-2 py-1 text-xs text-gray-500">Ni predlog.</div>
</template>
</Multiselect>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in filteredTemplates" :key="option.id" :value="option.id.toString()">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span v-if="option.source_type" class="text-xs text-muted-foreground">({{ option.source_type }})</span>
</div>
<Badge variant="outline" class="text-[10px]">{{
option.client_id ? "Client" : "Global"
}}</Badge>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div v-if="isCompleted" class="mt-2">
<button
type="button"
<Button
variant="default"
size="sm"
class="w-full sm:w-auto"
@click="$emit('preview')"
class="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-500 w-full sm:w-auto"
>
Ogled CSV
</button>
</Button>
</div>
</div>
</div>
<div v-if="!isCompleted" class="flex flex-col gap-3">
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600">Header row</label>
<select
:value="hasHeader"
@change="onHeaderChange"
class="mt-1 block w-full border rounded p-2 text-sm"
<Label class="text-xs font-medium">Header row</Label>
<Select
:model-value="hasHeader.toString()"
@update:model-value="onHeaderChange"
>
<option value="true">Has header</option>
<option value="false">No header (positional)</option>
</select>
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="true">Has header</SelectItem>
<SelectItem value="false">No header (positional)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600">Delimiter</label>
<select
:value="delimiterState.mode"
@change="onDelimiterMode"
class="mt-1 block w-full border rounded p-2 text-sm"
<Label class="text-xs font-medium">Delimiter</Label>
<Select
:model-value="delimiterState.mode"
@update:model-value="onDelimiterMode"
>
<option value="auto">Auto-detect</option>
<option value="comma">Comma ,</option>
<option value="semicolon">Semicolon ;</option>
<option value="tab">Tab \t</option>
<option value="pipe">Pipe |</option>
<option value="space">Space </option>
<option value="custom">Custom</option>
</select>
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="comma">Comma ,</SelectItem>
<SelectItem value="semicolon">Semicolon ;</SelectItem>
<SelectItem value="tab">Tab \t</SelectItem>
<SelectItem value="pipe">Pipe |</SelectItem>
<SelectItem value="space">Space </SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div v-if="delimiterState.mode === 'custom'" class="flex items-end gap-3">
<div class="w-40">
<label class="block text-xs font-medium text-gray-600">Custom delimiter</label>
<input
:value="delimiterState.custom"
<Label class="text-xs font-medium">Custom delimiter</Label>
<Input
:model-value="delimiterState.custom"
@input="onDelimiterCustom"
maxlength="4"
placeholder=","
class="mt-1 block w-full border rounded p-2 text-sm"
class="mt-1"
/>
</div>
<p class="text-xs text-gray-500">
<p class="text-xs text-muted-foreground">
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
</p>
</div>
<p v-else class="text-xs text-gray-500">
<p v-else class="text-xs text-muted-foreground">
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
</p>
</div>
<button
v-if="!isCompleted"
class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm"
:disabled="!form.import_template_id"
<Button
v-if="!isCompleted && form.import_template_id"
variant="default"
@click="$emit('apply-template')"
class="w-full"
>
{{ templateApplied ? "Ponovno uporabi predlogo" : "Uporabi predlogo" }}
</button>
{{ templateApplied ? 'Re-apply Template' : 'Apply Template' }}
</Button>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Skeleton } from "@/Components/ui/skeleton";
import { ArrowDownTrayIcon } from "@heroicons/vue/24/outline";
const props = defineProps({
show: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
columns: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
importId: { type: Number, required: true },
});
const emit = defineEmits(["close"]);
function downloadCsv() {
if (!props.importId) return;
window.location.href = route("imports.missing-keyref-csv", { import: props.importId });
}
</script>
<template>
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
<DialogContent class="max-w-6xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<div class="flex items-center justify-between">
<DialogTitle>Vrstice z neobstoječim contract.reference (KEYREF)</DialogTitle>
<Button
variant="outline"
size="sm"
@click="downloadCsv"
class="gap-2"
>
<ArrowDownTrayIcon class="h-4 w-4" />
Prenesi CSV
</Button>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto">
<div v-if="loading" class="space-y-3 p-4">
<Skeleton v-for="i in 10" :key="i" class="h-12 w-full" />
</div>
<div v-else-if="!rows.length" class="py-12 text-center">
<p class="text-sm text-gray-500">Ni zadetkov.</p>
</div>
<div v-else class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-24"># vrstica</TableHead>
<TableHead v-for="(c, i) in columns" :key="i">{{ c }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="r in rows" :key="r.id">
<TableCell class="font-medium text-gray-500">{{ r.row_number }}</TableCell>
<TableCell
v-for="(c, i) in columns"
:key="i"
class="whitespace-pre-wrap wrap-break-word"
>
{{ r.values?.[i] ?? "" }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div class="border-t pt-4 flex justify-end gap-2">
<Button variant="secondary" @click="emit('close')">Zapri</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -331,7 +331,12 @@ watch(
/>
<!-- Import Mode Settings -->
<ImportModeSettings :form="form" :entities="entities" />
<ImportModeSettings
:form="form"
:entities="entities"
:actions="props.actions"
:decisions="props.decisions"
/>
<!-- Unassigned Mappings -->
<UnassignedMappings

View File

@ -160,7 +160,7 @@ function getEntityMappings(entity) {
:key="m.id"
class="p-3 border rounded-lg bg-muted/30"
>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-center">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
<div class="space-y-1">
<Label class="text-xs">Izvor</Label>
<Input v-model="m.source_column" class="text-sm" />
@ -196,6 +196,18 @@ function getEntityMappings(entity) {
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label class="text-xs">Group</Label>
<Input
:value="m.options?.group ?? ''"
@input="e => {
if (!m.options) m.options = {};
m.options.group = e.target.value || null;
}"
class="text-sm"
placeholder="1, 2, ..."
/>
</div>
<div class="flex items-end gap-2">
<div class="flex flex-col gap-1">
<Button
@ -233,7 +245,7 @@ function getEntityMappings(entity) {
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="space-y-3">
<div class="text-sm font-medium">Dodaj novo preslikavo</div>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-3">
<div class="space-y-2">
<Label class="text-xs">Izvorno polje</Label>
<Input
@ -289,6 +301,13 @@ function getEntityMappings(entity) {
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label class="text-xs">Group</Label>
<Input
v-model="(newRows[entity] ||= {}).group"
placeholder="1, 2, ..."
/>
</div>
</div>
<Button @click="addRow(entity)" size="sm">Dodaj preslikavo</Button>
</div>

View File

@ -1,4 +1,5 @@
<script setup>
import { computed } from "vue";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
@ -8,6 +9,17 @@ import { Badge } from "@/Components/ui/badge";
const props = defineProps({
form: { type: Object, required: true },
entities: { type: Array, default: () => [] },
actions: { type: Array, default: () => [] },
decisions: { type: Array, default: () => [] },
});
const hasActivities = computed(() => {
return Array.isArray(props.entities) && props.entities.includes('activities');
});
const decisionsForActivitiesAction = computed(() => {
const act = (props.actions || []).find((a) => a.id === props.form.meta.activity_action_id);
return act?.decisions || [];
});
</script>
@ -68,6 +80,47 @@ const props = defineProps({
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Plačila</Badge>
</div>
</div>
<!-- Activities Settings -->
<div v-if="hasActivities" class="space-y-4 pt-4 border-t">
<div class="text-sm font-medium">Nastavitve aktivnosti</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="activity_action">Dejanje za aktivnosti</Label>
<Select v-model="form.meta.activity_action_id">
<SelectTrigger id="activity_action">
<SelectValue placeholder="Izberi dejanje" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem v-for="a in actions || []" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="activity_decision">Odločitev za aktivnosti</Label>
<Select v-model="form.meta.activity_decision_id" :disabled="!form.meta.activity_action_id">
<SelectTrigger id="activity_decision">
<SelectValue placeholder="Izberi odločitev" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem v-for="d in decisionsForActivitiesAction" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="!form.meta.activity_action_id" class="text-xs text-muted-foreground">
Najprej izberi dejanje, nato odločitev.
</p>
</div>
</div>
<p class="text-xs text-muted-foreground">
Te nastavitve se uporabljajo za aktivnosti, ki so uvožene iz CSV (npr. opombe, zgodovinske aktivnosti).
</p>
</div>
</CardContent>
</Card>
</template>

View File

@ -179,7 +179,7 @@
Route::get('phone', [PhoneViewController::class, 'index'])->name('phone.index');
Route::get('phone/completed', [PhoneViewController::class, 'completedToday'])->name('phone.completed');
Route::get('phone/case/{client_case:uuid}', [PhoneViewController::class, 'showCase'])->name('phone.case');
Route::post('phone/case/{client_case:uuid}/complete', [\App\Http\Controllers\FieldJobController::class, 'complete'])->name('phone.case.complete');
Route::post('phone/case/{client_case:uuid}/complete', [FieldJobController::class, 'complete'])->name('phone.case.complete');
Route::get('search', function (Request $request) {