changes 0230092025

This commit is contained in:
Simon Pocrnjič 2025-09-30 00:06:47 +02:00
parent 1fddf959f0
commit a2bb75fdcc
31 changed files with 2729 additions and 628 deletions

View File

@ -4,16 +4,15 @@
use App\Models\Client;
use App\Models\Import;
use App\Models\ImportTemplate;
use App\Models\ImportRow;
use App\Models\ImportEvent;
use App\Models\ImportTemplate;
use App\Services\CsvImportService;
use App\Services\ImportProcessor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use App\Services\CsvImportService;
class ImportController extends Controller
{
@ -64,7 +63,7 @@ public function index(Request $request)
'full_name' => $imp->client->person->full_name,
] : null,
] : null,
'template' => $imp->template ? [ 'id' => $imp->template->id, 'name' => $imp->template->name ] : null,
'template' => $imp->template ? ['id' => $imp->template->id, 'name' => $imp->template->name] : null,
];
}, $imports['data']);
@ -99,7 +98,6 @@ public function create(Request $request)
DB::raw('person.full_name as name'),
]);
return Inertia::render('Imports/Create', [
'templates' => $templates,
'clients' => $clients,
@ -129,7 +127,7 @@ public function store(Request $request)
// Resolve client_uuid to client_id if provided
$clientId = null;
if (!empty($validated['client_uuid'] ?? null)) {
if (! empty($validated['client_uuid'] ?? null)) {
$clientId = Client::where('uuid', $validated['client_uuid'])->value('id');
}
@ -163,6 +161,7 @@ public function process(Import $import, Request $request, ImportProcessor $proce
{
$import->update(['status' => 'validating', 'started_at' => now()]);
$result = $processor->process($import, user: $request->user());
return response()->json($result);
}
@ -188,24 +187,43 @@ public function columns(Request $request, Import $import, CsvImportService $csv)
if ($tplDelimiter) {
$explicitDelimiter = (string) $tplDelimiter;
}
} elseif (!empty($import->meta['forced_delimiter'] ?? null)) {
} elseif (! empty($import->meta['forced_delimiter'] ?? null)) {
$explicitDelimiter = (string) $import->meta['forced_delimiter'];
}
// Only implement CSV/TSV detection for now; others can be added later
if (!in_array($import->source_type, ['csv','txt'])) {
return response()->json([
'columns' => [],
'note' => 'Column preview supported for CSV/TXT at this step.',
]);
// Prefer CSV/TXT; if source_type is unknown, attempt best-effort based on file extension
$treatAsText = in_array($import->source_type, ['csv', 'txt']);
if (! $treatAsText) {
$orig = strtolower(pathinfo($import->original_name ?? '', PATHINFO_EXTENSION));
if (in_array($orig, ['csv', 'txt'])) {
$treatAsText = true;
}
}
$fullPath = Storage::disk($import->disk)->path($import->path);
$note = '';
if ($treatAsText) {
if ($explicitDelimiter !== null && $explicitDelimiter !== '') {
$columns = $csv->parseColumnsFromCsv($fullPath, $explicitDelimiter, $hasHeader);
$delimiter = $explicitDelimiter;
} else {
[$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader);
// Backstop: if single column but file clearly has separators, try common ones
if (is_array($columns) && count($columns) <= 1) {
foreach ([';', "\t", '|', ' ', ','] as $try) {
$alt = $csv->parseColumnsFromCsv($fullPath, $try, $hasHeader);
if (is_array($alt) && count($alt) > 1) {
$delimiter = $try;
$columns = $alt;
$note = 'Delimiter auto-detection backstopped to '.json_encode($try);
break;
}
}
}
}
} else {
// Best-effort: try detect anyway
[$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader);
}
// Save meta
@ -225,6 +243,7 @@ public function columns(Request $request, Import $import, CsvImportService $csv)
'columns' => $columns,
'has_header' => $hasHeader,
'detected_delimiter' => $delimiter,
'note' => $note,
]);
}
@ -236,9 +255,9 @@ public function saveMappings(Request $request, Import $import)
$data = $request->validate([
'mappings' => 'required|array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
'mappings.*.target_field' => 'required|string',
'mappings.*.transform' => 'nullable|string|in:trim,upper,lower',
'mappings.*.transform' => 'nullable|string|in:trim,upper,lower,decimal,ref',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
'mappings.*.options' => 'nullable|array',
]);
@ -247,14 +266,14 @@ public function saveMappings(Request $request, Import $import)
$now = now();
$existing = \DB::table('import_mappings')
->where('import_id', $import->id)
->get(['id','source_column','position']);
->get(['id', 'source_column', 'position']);
$bySource = [];
$dupes = [];
foreach ($existing as $row) {
$src = (string) $row->source_column;
if (!array_key_exists($src, $bySource)) {
$bySource[$src] = [ 'id' => $row->id, 'position' => $row->position ];
if (! array_key_exists($src, $bySource)) {
$bySource[$src] = ['id' => $row->id, 'position' => $row->position];
} else {
$dupes[$src] = ($dupes[$src] ?? []);
$dupes[$src][] = $row->id;
@ -262,7 +281,9 @@ public function saveMappings(Request $request, Import $import)
}
$basePosition = (int) (\DB::table('import_mappings')->where('import_id', $import->id)->max('position') ?? -1);
$inserted = 0; $updated = 0; $deduped = 0;
$inserted = 0;
$updated = 0;
$deduped = 0;
foreach ($data['mappings'] as $pos => $m) {
$src = (string) $m['source_column'];
@ -281,7 +302,7 @@ public function saveMappings(Request $request, Import $import)
\DB::table('import_mappings')->where('id', $bySource[$src]['id'])->update($payload);
$updated++;
// Remove duplicates if any
if (!empty($dupes[$src])) {
if (! empty($dupes[$src])) {
$deleted = \DB::table('import_mappings')->whereIn('id', $dupes[$src])->delete();
$deduped += (int) $deleted;
unset($dupes[$src]);
@ -325,8 +346,9 @@ public function getMappings(Import $import)
'transform',
'apply_mode',
'options',
'position'
'position',
]);
return response()->json(['mappings' => $rows]);
}
@ -339,7 +361,8 @@ public function getEvents(Import $import)
->where('import_id', $import->id)
->orderByDesc('id')
->limit($limit)
->get(['id','created_at','level','event','message','import_row_id','context']);
->get(['id', 'created_at', 'level', 'event', 'message', 'import_row_id', 'context']);
return response()->json(['events' => $events]);
}
@ -363,7 +386,6 @@ public function show(Import $import)
'clients.uuid as client_uuid',
]);
$clients = Client::query()
->join('person', 'person.id', '=', 'clients.person_id')
->orderBy('person.full_name')
@ -371,7 +393,7 @@ public function show(Import $import)
->get([
'clients.id',
'clients.uuid',
'person.full_name as name'
'person.full_name as name',
]);
// Import client
@ -383,7 +405,6 @@ public function show(Import $import)
'person.full_name as name',
]);
// Render a dedicated page to continue the import
return Inertia::render('Imports/Import', [
'import' => [
@ -398,11 +419,11 @@ public function show(Import $import)
'imported_rows' => $import->imported_rows,
'invalid_rows' => $import->invalid_rows,
'valid_rows' => $import->valid_rows,
'finished_at' => $import->finished_at
'finished_at' => $import->finished_at,
],
'templates' => $templates,
'clients' => $clients,
'client' => $client
'client' => $client,
]);
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Models\ImportEntity;
use Illuminate\Http\Request;
class ImportEntityController extends Controller
{
public function index()
{
$entities = ImportEntity::query()
->orderByRaw("(ui->>'order')::int nulls last")
->get();
// Fallback: if no entities are seeded yet, return a sensible default set
if ($entities->isEmpty()) {
$entities = collect([
[
'key' => 'person',
'canonical_root' => 'person',
'label' => 'Person',
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
'ui' => ['order' => 1],
],
[
'key' => 'person_addresses',
'canonical_root' => 'address',
'label' => 'Person Addresses',
'fields' => ['address', 'country', 'type_id', 'description'],
'ui' => ['order' => 2],
],
[
'key' => 'person_phones',
'canonical_root' => 'phone',
'label' => 'Person Phones',
'fields' => ['nu', 'country_code', 'type_id', 'description'],
'ui' => ['order' => 3],
],
[
'key' => 'emails',
'canonical_root' => 'email',
'label' => 'Emails',
'fields' => ['value', 'is_primary', 'label'],
'ui' => ['order' => 4],
],
[
'key' => 'contracts',
'canonical_root' => 'contract',
'label' => 'Contracts',
'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id'],
'ui' => ['order' => 5],
],
[
'key' => 'accounts',
'canonical_root' => 'account',
'label' => 'Accounts',
'fields' => ['reference', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'ui' => ['order' => 6],
],
]);
} else {
// Ensure fields are arrays for frontend consumption
$entities = $entities->map(function ($ent) {
$ent->fields = is_array($ent->fields) ? $ent->fields : [];
return $ent;
});
}
return response()->json(['entities' => $entities]);
}
public function suggest(Request $request)
{
$cols = $request->input('columns', []);
if (! is_array($cols)) {
$cols = [];
}
$entities = ImportEntity::all();
$suggestions = [];
foreach ($cols as $col) {
$s = $this->suggestFor($col, $entities);
if ($s) {
$suggestions[$col] = $s;
}
}
return response()->json(['suggestions' => $suggestions]);
}
private function suggestFor(string $source, $entities): ?array
{
$s = trim(mb_strtolower($source));
foreach ($entities as $ent) {
$rules = (array) ($ent->rules ?? []);
foreach ($rules as $rule) {
$pattern = $rule['pattern'] ?? null;
$field = $rule['field'] ?? null;
if (! $pattern || ! $field) {
continue;
}
if (@preg_match($pattern, $s)) {
return [
'entity' => $ent->key, // UI key (plural except person)
'field' => $field,
'canonical_root' => $ent->canonical_root,
];
}
}
}
return null;
}
}

View File

@ -2,10 +2,13 @@
namespace App\Http\Controllers;
use App\Models\Action;
use App\Models\Client;
use App\Models\Decision;
use App\Models\Import;
use App\Models\ImportTemplate;
use App\Models\ImportTemplateMapping;
use App\Models\Client;
use App\Models\Segment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -21,7 +24,7 @@ public function index()
->get();
return Inertia::render('Imports/Templates/Index', [
'templates' => $templates->map(fn($t) => [
'templates' => $templates->map(fn ($t) => [
'uuid' => $t->uuid,
'name' => $t->name,
'description' => $t->description,
@ -48,8 +51,22 @@ public function create()
DB::raw('person.full_name as name'),
]);
$segments = Segment::query()->orderBy('name')->get(['id', 'name']);
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Imports/Templates/Create', [
'clients' => $clients,
'segments' => $segments,
'decisions' => $decisions,
'actions' => $actions,
]);
}
@ -57,8 +74,15 @@ public function store(Request $request)
{
// Normalize payload to be resilient to UI variations
$raw = $request->all();
// Allow passing default segment/decision either inside meta or as top-level
if (isset($raw['segment_id']) && ! isset($raw['meta']['segment_id'])) {
$raw['meta']['segment_id'] = $raw['segment_id'];
}
if (isset($raw['decision_id']) && ! isset($raw['meta']['decision_id'])) {
$raw['meta']['decision_id'] = $raw['decision_id'];
}
// Resolve client by uuid if provided, or cast string numeric to int
if (!empty($raw['client_uuid'] ?? null)) {
if (! empty($raw['client_uuid'] ?? null)) {
$raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id');
} elseif (isset($raw['client_id']) && is_string($raw['client_id']) && ctype_digit($raw['client_id'])) {
$raw['client_id'] = (int) $raw['client_id'];
@ -66,8 +90,13 @@ public function store(Request $request)
// Normalize entities to array of strings
if (isset($raw['entities']) && is_array($raw['entities'])) {
$raw['entities'] = array_values(array_filter(array_map(function ($e) {
if (is_string($e)) return $e;
if (is_array($e) && array_key_exists('value', $e)) return (string) $e['value'];
if (is_string($e)) {
return $e;
}
if (is_array($e) && array_key_exists('value', $e)) {
return (string) $e['value'];
}
return null;
}, $raw['entities'])));
}
@ -84,14 +113,29 @@ public function store(Request $request)
'entities.*' => 'string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings' => 'array',
'mappings.*.source_column' => 'required|string',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'mappings.*.entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
'mappings.*.target_field' => 'nullable|string',
'mappings.*.transform' => 'nullable|string|max:50',
'mappings.*.apply_mode' => 'nullable|string|in:insert,update,both',
'mappings.*.options' => 'nullable|array',
'mappings.*.position' => 'nullable|integer',
'meta' => 'nullable|array',
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id',
])->validate();
// Ensure decision belongs to action if both provided in meta
$meta = $data['meta'] ?? [];
if (! empty($meta['action_id']) && ! empty($meta['decision_id'])) {
$belongs = \DB::table('action_decision')
->where('action_id', $meta['action_id'])
->where('decision_id', $meta['decision_id'])
->exists();
if (! $belongs) {
return back()->withErrors(['meta.decision_id' => 'Selected decision is not associated with the chosen action.'])->withInput();
}
}
$template = null;
DB::transaction(function () use (&$template, $request, $data) {
$template = ImportTemplate::create([
@ -104,9 +148,12 @@ public function store(Request $request)
'user_id' => $request->user()?->id,
'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? true,
'meta' => [
'meta' => array_filter([
'entities' => $data['entities'] ?? [],
],
'segment_id' => data_get($data, 'meta.segment_id'),
'decision_id' => data_get($data, 'meta.decision_id'),
'action_id' => data_get($data, 'meta.action_id'),
], fn ($v) => ! is_null($v) && $v !== ''),
]);
foreach (($data['mappings'] ?? []) as $m) {
@ -144,6 +191,17 @@ public function edit(ImportTemplate $template)
DB::raw('person.full_name as name'),
]);
$segments = Segment::query()->orderBy('name')->get(['id', 'name']);
$decisions = Decision::query()->orderBy('name')->get(['id', 'name']);
$actions = Action::with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name'])
->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Imports/Templates/Edit', [
'template' => [
'uuid' => $template->uuid,
@ -155,9 +213,12 @@ public function edit(ImportTemplate $template)
'client_uuid' => $template->client?->uuid,
'sample_headers' => $template->sample_headers,
'meta' => $template->meta,
'mappings' => $template->mappings()->orderBy('position')->get(['id','entity','source_column','target_field','transform','apply_mode','options','position']),
'mappings' => $template->mappings()->orderBy('position')->get(['id', 'entity', 'source_column', 'target_field', 'transform', 'apply_mode', 'options', 'position']),
],
'clients' => $clients,
'segments' => $segments,
'decisions' => $decisions,
'actions' => $actions,
]);
}
@ -171,7 +232,7 @@ public function addMapping(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'source_column' => 'required|string',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
'target_field' => 'nullable|string',
'transform' => 'nullable|string|in:trim,upper,lower',
'apply_mode' => 'nullable|string|in:insert,update,both',
@ -193,6 +254,7 @@ public function addMapping(Request $request, ImportTemplate $template)
'options' => $data['options'] ?? $existing->options,
'position' => $data['position'] ?? $existing->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('info', 'Mapping already existed. Updated existing mapping.');
} else {
@ -207,6 +269,7 @@ public function addMapping(Request $request, ImportTemplate $template)
'options' => $data['options'] ?? null,
'position' => $position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping added');
}
@ -216,7 +279,7 @@ public function addMapping(Request $request, ImportTemplate $template)
public function update(Request $request, ImportTemplate $template)
{
$raw = $request->all();
if (!empty($raw['client_uuid'] ?? null)) {
if (! empty($raw['client_uuid'] ?? null)) {
$raw['client_id'] = Client::where('uuid', $raw['client_uuid'])->value('id');
}
$data = validator($raw, [
@ -229,16 +292,35 @@ public function update(Request $request, ImportTemplate $template)
'sample_headers' => 'nullable|array',
'meta' => 'nullable|array',
'meta.delimiter' => 'nullable|string|max:4',
'meta.segment_id' => 'nullable|integer|exists:segments,id',
'meta.decision_id' => 'nullable|integer|exists:decisions,id',
'meta.action_id' => 'nullable|integer|exists:actions,id',
])->validate();
// Validate decision/action consistency on update as well
$meta = $data['meta'] ?? [];
if (! empty($meta['action_id']) && ! empty($meta['decision_id'])) {
$belongs = \DB::table('action_decision')
->where('action_id', $meta['action_id'])
->where('decision_id', $meta['decision_id'])
->exists();
if (! $belongs) {
return back()->withErrors(['meta.decision_id' => 'Selected decision is not associated with the chosen action.'])->withInput();
}
}
// Merge meta safely, preserving existing keys when not provided
$newMeta = $template->meta ?? [];
if (array_key_exists('meta', $data) && is_array($data['meta'])) {
$newMeta = array_merge($newMeta, $data['meta']);
// Drop empty delimiter to allow auto-detect
if (array_key_exists('delimiter', $newMeta) && (!is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) {
if (array_key_exists('delimiter', $newMeta) && (! is_string($newMeta['delimiter']) || trim((string) $newMeta['delimiter']) === '')) {
unset($newMeta['delimiter']);
}
foreach (['segment_id', 'decision_id', 'action_id'] as $k) {
if (array_key_exists($k, $newMeta) && ($newMeta[$k] === '' || is_null($newMeta[$k]))) {
unset($newMeta[$k]);
}
}
}
$template->update([
@ -266,14 +348,14 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
}
$data = validator($raw, [
'sources' => 'required|string', // comma and/or newline separated
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts',
'entity' => 'nullable|string|in:person,person_addresses,person_phones,emails,accounts,contracts,client_cases',
'default_field' => 'nullable|string', // if provided, used as the field name for all entries
'apply_mode' => 'nullable|string|in:insert,update,both',
'transform' => 'nullable|string|in:trim,upper,lower',
])->validate();
$list = preg_split('/\r?\n|,/', $data['sources']);
$list = array_values(array_filter(array_map(fn($s) => trim($s), $list), fn($s) => $s !== ''));
$list = array_values(array_filter(array_map(fn ($s) => trim($s), $list), fn ($s) => $s !== ''));
if (empty($list)) {
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
@ -286,14 +368,15 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
$entity = $data['entity'] ?? null;
$defaultField = $data['default_field'] ?? null; // allows forcing a specific field for all
$created = 0; $updated = 0;
$created = 0;
$updated = 0;
DB::transaction(function () use ($template, $list, $apply, $transform, $entity, $defaultField, $basePosition, &$created, &$updated) {
foreach ($list as $idx => $source) {
$targetField = null;
if ($defaultField) {
$targetField = $entity ? ($entity . '.' . $defaultField) : $defaultField;
$targetField = $entity ? ($entity.'.'.$defaultField) : $defaultField;
} elseif ($entity) {
$targetField = $entity . '.' . $source;
$targetField = $entity.'.'.$source;
}
$existing = ImportTemplateMapping::where('import_template_id', $template->id)
@ -327,10 +410,17 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
});
$msg = [];
if ($created) $msg[] = "$created created";
if ($updated) $msg[] = "$updated updated";
if ($created) {
$msg[] = "$created created";
}
if ($updated) {
$msg[] = "$updated updated";
}
$text = 'Mappings processed';
if (!empty($msg)) $text .= ': ' . implode(', ', $msg);
if (! empty($msg)) {
$text .= ': '.implode(', ', $msg);
}
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', $text);
}
@ -338,7 +428,9 @@ public function bulkAddMappings(Request $request, ImportTemplate $template)
// Update an existing mapping
public function updateMapping(Request $request, ImportTemplate $template, ImportTemplateMapping $mapping)
{
if ($mapping->import_template_id !== $template->id) abort(404);
if ($mapping->import_template_id !== $template->id) {
abort(404);
}
$raw = $request->all();
if (array_key_exists('transform', $raw) && $raw['transform'] === '') {
$raw['transform'] = null;
@ -361,6 +453,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'options' => $data['options'] ?? null,
'position' => $data['position'] ?? $mapping->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping updated');
}
@ -368,8 +461,11 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
// Delete a mapping
public function deleteMapping(ImportTemplate $template, ImportTemplateMapping $mapping)
{
if ($mapping->import_template_id !== $template->id) abort(404);
if ($mapping->import_template_id !== $template->id) {
abort(404);
}
$mapping->delete();
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping deleted');
}
@ -385,11 +481,14 @@ public function reorderMappings(Request $request, ImportTemplate $template)
// Ensure all ids belong to template
$validIds = ImportTemplateMapping::where('import_template_id', $template->id)
->whereIn('id', $ids)->pluck('id')->all();
if (count($validIds) !== count($ids)) abort(422, 'Invalid mapping ids');
if (count($validIds) !== count($ids)) {
abort(422, 'Invalid mapping ids');
}
// Apply new positions
foreach ($ids as $idx => $id) {
ImportTemplateMapping::where('id', $id)->update(['position' => $idx]);
}
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mappings reordered');
}
@ -422,7 +521,20 @@ public function applyToImport(Request $request, ImportTemplate $template, Import
$copied++;
}
$import->update(['import_template_id' => $template->id]);
// Merge default actions (segment/decision) into import meta for processing
$importMeta = $import->meta ?? [];
$tplMeta = $template->meta ?? [];
$merged = array_merge($importMeta, array_filter([
'segment_id' => $tplMeta['segment_id'] ?? null,
'decision_id' => $tplMeta['decision_id'] ?? null,
'action_id' => $tplMeta['action_id'] ?? null,
'template_name' => $template->name,
], fn ($v) => ! is_null($v) && $v !== ''));
$import->update([
'import_template_id' => $template->id,
'meta' => $merged,
]);
});
return response()->json(['ok' => true, 'copied' => $copied, 'cleared' => $clear]);

View File

@ -23,6 +23,7 @@ class ClientCase extends Model
protected $fillable = [
'client_id',
'person_id',
'client_ref',
];
protected $hidden = [

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ImportEntity extends Model
{
protected $fillable = [
'key',
'canonical_root',
'label',
'fields',
'field_aliases',
'aliases',
'rules',
'ui',
];
protected $casts = [
'fields' => 'array',
'field_aliases' => 'array',
'aliases' => 'array',
'rules' => 'array',
'ui' => 'array',
];
}

View File

@ -5,15 +5,79 @@
class CsvImportService
{
/**
* Read the first line of a file; returns null on failure.
* Normalize a line to UTF-8 and strip BOM / control characters for robust splitting.
*/
public function readFirstLine(string $path): ?string
private function normalizeLine(string $line): string
{
// Strip UTF-8 BOM
if (str_starts_with($line, "\xEF\xBB\xBF")) {
$line = substr($line, 3);
}
// Detect UTF-16 BOMs
$hasNulls = strpos($line, "\x00") !== false;
if (str_starts_with($line, "\xFF\xFE")) {
// UTF-16LE BOM
$line = substr($line, 2);
$line = function_exists('mb_convert_encoding') ? @mb_convert_encoding($line, 'UTF-8', 'UTF-16LE') : preg_replace('/\x00/', '', $line);
} elseif (str_starts_with($line, "\xFE\xFF")) {
// UTF-16BE BOM
$line = substr($line, 2);
$line = function_exists('mb_convert_encoding') ? @mb_convert_encoding($line, 'UTF-8', 'UTF-16BE') : preg_replace('/\x00/', '', $line);
} elseif ($hasNulls) {
// Likely UTF-16 without BOM, try LE then BE
if (function_exists('mb_convert_encoding')) {
$try = @mb_convert_encoding($line, 'UTF-8', 'UTF-16LE');
if ($try !== false) {
$line = $try;
} else {
$try = @mb_convert_encoding($line, 'UTF-8', 'UTF-16BE');
if ($try !== false) {
$line = $try;
} else {
$line = preg_replace('/\x00/', '', $line);
}
}
} else {
$line = preg_replace('/\x00/', '', $line);
}
} else {
// Non UTF-16: try detect common encodings and convert to UTF-8 if needed
if (function_exists('mb_detect_encoding') && function_exists('mb_convert_encoding')) {
// Use default detection order for portability across environments
$enc = @mb_detect_encoding($line, null, true);
if ($enc && strtoupper($enc) !== 'UTF-8') {
$line = @mb_convert_encoding($line, 'UTF-8', $enc) ?: $line;
}
}
}
// Replace non-breaking space with regular space
$line = str_replace("\xC2\xA0", ' ', $line);
return $line;
}
/**
* Read the first meaningful (non-empty after normalization) line of a file; returns null on failure.
* Skips BOM-only lines and leading blank lines. Limits scanning to first 50 lines to be safe.
*/
public function readFirstMeaningfulLine(string $path): ?string
{
$fh = @fopen($path, 'r');
if (!$fh) return null;
$line = fgets($fh);
if (! $fh) {
return null;
}
$line = null;
$limit = 50;
while ($limit-- > 0 && ($raw = fgets($fh)) !== false) {
$normalized = $this->normalizeLine($raw);
if (trim($normalized) !== '') {
$line = $normalized;
break;
}
}
fclose($fh);
return $line === false ? null : $line;
return $line;
}
/**
@ -24,14 +88,15 @@ public function readFirstLine(string $path): ?string
public function detectColumnsFromCsv(string $path, bool $hasHeader): array
{
// Use actual tab character for TSV; keep other common delimiters
$delims = [',',';','|',"\t"];
$delims = [',', ';', '|', "\t"];
$bestDelim = ',';
$bestCols = [];
$firstLine = $this->readFirstLine($path);
$firstLine = $this->readFirstMeaningfulLine($path);
if ($firstLine === null) {
return [$bestDelim, []];
}
// Already normalized by readFirstMeaningfulLine
$maxCount = 0;
foreach ($delims as $d) {
@ -44,12 +109,27 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array
}
}
if (!$hasHeader) {
// Fallback: if str_getcsv failed to split but we clearly see delimiters, do a simple explode
if ($maxCount <= 1) {
foreach (["\t", ';', ',', '|'] as $d) {
if (substr_count($firstLine, $d) >= 1) {
$parts = explode($d, $firstLine);
if (count($parts) > $maxCount) {
$bestDelim = $d;
$bestCols = $parts;
$maxCount = count($parts);
}
}
}
}
if (! $hasHeader) {
// return positional indices 0..N-1
$cols = [];
for ($i = 0; $i < $maxCount; $i++) {
$cols[] = (string) $i;
}
return [$bestDelim, $cols];
}
@ -57,6 +137,7 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array
$clean = array_map(function ($v) {
$v = trim((string) $v);
$v = preg_replace('/\s+/', ' ', $v);
return $v;
}, $bestCols);
@ -69,16 +150,23 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array
*/
public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHeader): array
{
$firstLine = $this->readFirstLine($path);
$firstLine = $this->readFirstMeaningfulLine($path);
if ($firstLine === null) {
return [];
}
// Already normalized by readFirstMeaningfulLine
$row = str_getcsv($firstLine, $delimiter);
$count = is_array($row) ? count($row) : 0;
// Fallback explode if str_getcsv failed to split
if ($count <= 1 && substr_count($firstLine, $delimiter) >= 1) {
$row = explode($delimiter, $firstLine);
$count = count($row);
}
if ($hasHeader) {
return array_map(function ($v) {
$v = trim((string) $v);
$v = preg_replace('/\s+/', ' ', $v);
return $v;
}, $row ?: []);
}
@ -86,6 +174,7 @@ public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHe
for ($i = 0; $i < $count; $i++) {
$cols[] = (string) $i;
}
return $cols;
}
}

View File

@ -4,12 +4,15 @@
use App\Models\Account;
use App\Models\AccountType;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\ContractType;
use App\Models\Decision;
use App\Models\Email;
use App\Models\Import;
use App\Models\ImportEntity;
use App\Models\ImportEvent;
use App\Models\ImportRow;
use App\Models\Person\AddressType;
@ -57,6 +60,13 @@ public function process(Import $import, ?Authenticatable $user = null): array
->where('import_id', $import->id)
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
// Load dynamic entity config
[$rootAliasMap, $fieldAliasMap, $validRoots] = $this->loadImportEntityConfig();
// Normalize aliases (plural/legacy roots, field names) before validation
$mappings = $this->normalizeMappings($mappings, $rootAliasMap, $fieldAliasMap);
// Validate mapping roots early to avoid silent failures due to typos
$this->validateMappingRoots($mappings, $validRoots);
$header = $import->meta['columns'] ?? null;
// Prefer explicitly chosen delimiter, then template meta, else detected
$delimiter = $import->meta['forced_delimiter']
@ -66,6 +76,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
$hasHeader = (bool) ($import->meta['has_header'] ?? true);
$path = Storage::disk($import->disk)->path($import->path);
// Note: Do not auto-detect or infer mappings/fields beyond what the template mapping provides
// Parse file and create import_rows with mapped_data
$fh = @fopen($path, 'r');
if (! $fh) {
@ -95,11 +107,54 @@ public function process(Import $import, ?Authenticatable $user = null): array
if ($hasHeader) {
$first = fgetcsv($fh, 0, $delimiter);
$rowNum++;
// use actual detected header if not already stored
if (! $header) {
$header = array_map(fn ($v) => trim((string) $v), $first ?: []);
// Always use the actual header from the file for parsing
$header = array_map(fn ($v) => $this->sanitizeHeaderName((string) $v), $first ?: []);
// Heuristic: if header parsed as a single column but contains common delimiters, warn about mismatch
if (count($header) === 1) {
$rawHeader = $first[0] ?? '';
if (is_string($rawHeader) && (str_contains($rawHeader, ';') || str_contains($rawHeader, "\t"))) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'delimiter_mismatch_suspected',
'level' => 'warning',
'message' => 'Header parsed as a single column. Suspected delimiter mismatch. Set a forced delimiter in the template or import settings.',
'context' => [
'current_delimiter' => $delimiter,
'raw_header' => $rawHeader,
],
]);
}
}
// Preflight: warn if any mapped source columns are not present in the header (exact match)
$headerSet = [];
foreach ($header as $h) {
$headerSet[$h] = true;
}
$missingSources = [];
foreach ($mappings as $map) {
$src = (string) ($map->source_column ?? '');
if ($src !== '' && ! array_key_exists($src, $headerSet)) {
$missingSources[] = $src;
}
}
if (! empty($missingSources)) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'event' => 'source_columns_missing_in_header',
'level' => 'warning',
'message' => 'Some mapped source columns are not present in the file header (exact match required).',
'context' => [
'missing' => $missingSources,
'header' => $header,
],
]);
}
}
// If mapping contains contract.reference, we require each row to successfully resolve/create a contract
$requireContract = $this->mappingIncludes($mappings, 'contract.reference');
while (($row = fgetcsv($fh, 0, $delimiter)) !== false) {
$rowNum++;
@ -108,6 +163,8 @@ public function process(Import $import, ?Authenticatable $user = null): array
$rawAssoc = $this->buildRowAssoc($row, $header);
[$recordType, $mapped] = $this->applyMappings($rawAssoc, $mappings);
// Do not auto-derive or fallback values; only use explicitly mapped fields
$importRow = ImportRow::create([
'import_id' => $import->id,
'row_number' => $rowNum,
@ -122,6 +179,31 @@ public function process(Import $import, ?Authenticatable $user = null): array
if (isset($mapped['contract'])) {
$contractResult = $this->upsertContractChain($import, $mapped, $mappings);
if ($contractResult['action'] === 'skipped') {
// Even if no contract fields were updated, we may still need to apply template meta
// like attaching a segment or creating an activity. Do that if we have the contract.
if (isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
try {
$this->postContractActions($import, $contractResult['contract']);
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_actions_applied',
'level' => 'info',
'message' => 'Applied template post-actions on existing contract.',
'context' => ['contract_id' => $contractResult['contract']->id],
]);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
}
}
$skipped++;
$importRow->update(['status' => 'skipped']);
ImportEvent::create([
@ -148,15 +230,64 @@ public function process(Import $import, ?Authenticatable $user = null): array
'message' => ucfirst($contractResult['action']).' contract',
'context' => ['id' => $contractResult['contract']->id],
]);
// Post-contract actions from template/import meta
try {
$this->postContractActions($import, $contractResult['contract']);
} catch (\Throwable $e) {
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'post_contract_action_failed',
'level' => 'warning',
'message' => $e->getMessage(),
]);
}
} else {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$contractResult['message'] ?? 'Contract processing failed']]);
}
}
// Enforce hard requirement: if template mapped contract.reference but we didn't resolve/create a contract, mark row invalid and continue
if ($requireContract) {
$contractEnsured = false;
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$contractEnsured = true;
}
if (! $contractEnsured) {
$srcCol = $this->findSourceColumnFor($mappings, 'contract.reference');
$rawVal = $srcCol !== null ? ($rawAssoc[$srcCol] ?? null) : null;
$extra = $srcCol !== null ? ' Source column: "'.$srcCol.'" value: '.(is_null($rawVal) || $rawVal === '' ? '(empty)' : (is_scalar($rawVal) ? (string) $rawVal : json_encode($rawVal))) : '';
$msg = 'Row '.$rowNum.': Contract was required (contract.reference mapped) but not created/resolved. '.($contractResult['message'] ?? '').$extra;
// Avoid double-counting invalid if already set by contract processing
if ($importRow->status !== 'invalid') {
$invalid++;
$importRow->update(['status' => 'invalid', 'errors' => [$msg]]);
}
ImportEvent::create([
'import_id' => $import->id,
'user_id' => $user?->getAuthIdentifier(),
'import_row_id' => $importRow->id,
'event' => 'row_invalid',
'level' => 'error',
'message' => $msg,
]);
// Skip further processing for this row
continue;
}
}
// Accounts
$accountResult = null;
if (isset($mapped['account'])) {
// If a contract was just created or resolved above, pass its id to account mapping for this row
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
$mapped['account']['contract_id'] = $contractResult['contract']->id;
}
$accountResult = $this->upsertAccount($import, $mapped, $mappings);
if ($accountResult['action'] === 'skipped') {
$skipped++;
@ -168,6 +299,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
'event' => 'row_skipped',
'level' => 'info',
'message' => $accountResult['message'] ?? 'Skipped (no changes).',
'context' => $accountResult['context'] ?? null,
]);
} elseif ($accountResult['action'] === 'inserted' || $accountResult['action'] === 'updated') {
$imported++;
@ -191,7 +323,7 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
// Contacts: resolve person strictly via Contract -> ClientCase -> Person, contacts, or identifiers
// Contacts: resolve person via Contract/Account chain, client_case.client_ref, contacts, or identifiers
$personIdForRow = null;
// Prefer person from contract created/updated above
if ($contractResult && isset($contractResult['contract']) && $contractResult['contract'] instanceof Contract) {
@ -222,6 +354,16 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
}
}
// Resolve by client_case.client_ref for this client (prefer reusing existing person)
if (! $personIdForRow && $import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$cc = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->first();
if ($cc) {
$personIdForRow = $cc->person_id ?: null;
}
}
// Resolve by contact values next
if (! $personIdForRow) {
$emailVal = trim((string) ($mapped['email']['value'] ?? ''));
@ -240,6 +382,22 @@ public function process(Import $import, ?Authenticatable $user = null): array
}
// If still no person but we have any contact value, auto-create a minimal person
// BUT if we can map to an existing client_case by client_ref, reuse that case and set person there (avoid separate person rows)
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
if ($import->client_id && ! empty($mapped['client_case']['client_ref'] ?? null)) {
$cc = ClientCase::where('client_id', $import->client_id)
->where('client_ref', $mapped['client_case']['client_ref'])
->first();
if ($cc) {
$pid = $cc->person_id ?: $this->createMinimalPersonId();
if (! $cc->person_id) {
$cc->person_id = $pid;
$cc->save();
}
$personIdForRow = $pid;
}
}
}
if (! $personIdForRow && ($emailVal !== '' || $phoneNu !== '' || $addrLine !== '')) {
$personIdForRow = $this->createMinimalPersonId();
ImportEvent::create([
@ -384,12 +542,29 @@ private function applyMappings(array $raw, $mappings): array
}
$value = $raw[$src] ?? null;
// very basic transforms
if ($map->transform === 'trim') {
// Transform chain support: e.g. "trim|decimal" or "upper|alnum"
$transform = (string) ($map->transform ?? '');
if ($transform !== '') {
$parts = explode('|', $transform);
foreach ($parts as $t) {
$t = trim($t);
if ($t === 'trim') {
$value = is_string($value) ? trim($value) : $value;
}
if ($map->transform === 'upper') {
} elseif ($t === 'upper') {
$value = is_string($value) ? strtoupper($value) : $value;
} elseif ($t === 'lower') {
$value = is_string($value) ? strtolower($value) : $value;
} elseif ($t === 'digits' || $t === 'numeric') {
$value = is_string($value) ? preg_replace('/[^0-9]/', '', $value) : $value;
} elseif ($t === 'decimal') {
$value = is_string($value) ? $this->normalizeDecimal($value) : $value;
} elseif ($t === 'alnum') {
$value = is_string($value) ? preg_replace('/[^A-Za-z0-9]/', '', $value) : $value;
} elseif ($t === 'ref') {
// Reference safe: keep letters+digits only, uppercase
$value = is_string($value) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $value)) : $value;
}
}
}
// detect record type from first segment, e.g., "account.balance_amount"
@ -423,6 +598,16 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$acc = $mapped['account'] ?? [];
$contractId = $acc['contract_id'] ?? null;
$reference = $acc['reference'] ?? null;
// Determine if the template includes any contract mappings; if not, do not create contracts here
$hasContractRoot = $this->mappingsContainRoot($mappings, 'contract');
// Normalize references (remove spaces) for consistent matching
if (! is_null($reference)) {
$reference = preg_replace('/\s+/', '', trim((string) $reference));
$acc['reference'] = $reference;
}
if (! empty($acc['contract_reference'] ?? null)) {
$acc['contract_reference'] = preg_replace('/\s+/', '', trim((string) $acc['contract_reference']));
}
// If contract_id not provided, attempt to resolve by contract reference for the selected client
if (! $contractId) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
@ -436,25 +621,17 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
->first();
if ($existingContract) {
$contractId = $existingContract->id;
} else {
// 2) Not found: attempt to resolve debtor via identifiers or provided person, then create case+contract
// Try strong identifiers first
} elseif ($hasContractRoot) {
// Only create a new contract if the template explicitly includes contract mappings
// Resolve debtor via identifiers or provided person
$personId = $this->findPersonIdByIdentifiers($mapped['person'] ?? []);
// Create from provided person data if unresolved
if (! $personId) {
$personId = $this->findOrCreatePersonId($mapped['person'] ?? []);
}
// Last resort, create minimal
if (! $personId) {
$personId = $this->createMinimalPersonId();
}
// Use the selected client for this import to tie the case/contract
if (! $clientId) {
return ['action' => 'skipped', 'message' => 'Client required to create contract'];
}
$resolvedClientId = $clientId;
$clientCaseId = $this->findOrCreateClientCaseId($resolvedClientId, $personId);
// Build minimal/new contract
$clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId, $mapped['client_case']['client_ref'] ?? null);
$contractFields = $mapped['contract'] ?? [];
$newContractData = [
'client_case_id' => $clientCaseId,
@ -465,11 +642,13 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
$newContractData[$k] = $contractFields[$k];
}
}
// ensure required fields on contracts
$newContractData['start_date'] = $newContractData['start_date'] ?? now()->toDateString();
$newContractData['type_id'] = $newContractData['type_id'] ?? $this->getDefaultContractTypeId();
$createdContract = Contract::create($newContractData);
$contractId = $createdContract->id;
} else {
// Do not create contracts implicitly when not mapped in the template
$contractId = null;
}
if ($contractId) {
$acc['contract_id'] = $contractId;
@ -477,17 +656,35 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
}
}
}
// Default account.reference to contract reference if missing
if (! $reference) {
$contractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
if ($contractRef) {
$reference = $contractRef;
// Fallback: if account.reference is empty but contract.reference is present, use it
if ((is_null($reference) || $reference === '') && ! empty($mapped['contract']['reference'] ?? null)) {
$reference = preg_replace('/\s+/', '', trim((string) $mapped['contract']['reference']));
if ($reference !== '') {
$acc['reference'] = $reference;
$mapped['account'] = $acc;
}
}
// Do not default or infer account.reference from other fields; rely solely on mapped values
if (! $contractId || ! $reference) {
return ['action' => 'skipped', 'message' => 'Missing contract_id/reference'];
$issues = [];
if (! $contractId) {
$issues[] = 'contract_id unresolved';
}
if (! $reference) {
$issues[] = 'account.reference empty';
}
$candidateContractRef = $acc['contract_reference'] ?? ($mapped['contract']['reference'] ?? null);
return [
'action' => 'skipped',
'message' => 'Prerequisite missing: '.implode(' & ', $issues),
'context' => [
'has_contract_root_mapped' => $hasContractRoot,
'candidate_contract_reference' => $candidateContractRef,
'account_reference_provided' => $reference,
'account_fields_present' => array_keys(array_filter($acc, fn ($v) => ! is_null($v) && $v !== '')),
],
];
}
$existing = Account::query()
@ -498,6 +695,7 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
// Build applyable data based on apply_mode
$applyInsert = [];
$applyUpdate = [];
$applyModeByField = [];
foreach ($mappings as $map) {
if (! $map->target_field) {
continue;
@ -511,7 +709,11 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
continue;
}
$value = $acc[$field] ?? null;
if (in_array($field, ['balance_amount','initial_amount'], true) && is_string($value)) {
$value = $this->normalizeDecimal($value);
}
$mode = $map->apply_mode ?? 'both';
$applyModeByField[$field] = $mode;
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $value;
}
@ -535,6 +737,15 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
// also include contract hints for downstream contact resolution
return ['action' => 'updated', 'account' => $existing, 'contract_id' => $contractId];
} else {
// On insert: if initial_amount is not provided but balance_amount is, allow defaulting
// Only when the mapping for initial_amount is 'insert' or 'both', or unmapped (null).
$initMode = $applyModeByField['initial_amount'] ?? null;
if ((! array_key_exists('initial_amount', $applyInsert) || is_null($applyInsert['initial_amount'] ?? null))
&& array_key_exists('balance_amount', $applyInsert)
&& ($applyInsert['balance_amount'] !== null && $applyInsert['balance_amount'] !== '')
&& ($initMode === null || in_array($initMode, ['insert','both'], true))) {
$applyInsert['initial_amount'] = $applyInsert['balance_amount'];
}
if (empty($applyInsert)) {
return ['action' => 'skipped', 'message' => 'No fields marked for insert'];
}
@ -552,6 +763,18 @@ private function upsertAccount(Import $import, array $mapped, $mappings): array
}
}
private function mappingsContainRoot($mappings, string $root): bool
{
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if ($target !== '' && str_starts_with($target, $root.'.')) {
return true;
}
}
return false;
}
private function findPersonIdByIdentifiers(array $p): ?int
{
$tax = $p['tax_number'] ?? null;
@ -576,6 +799,10 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
{
$contractData = $mapped['contract'] ?? [];
$reference = $contractData['reference'] ?? null;
if (! is_null($reference)) {
$reference = preg_replace('/\s+/', '', trim((string) $reference));
$contractData['reference'] = $reference;
}
if (! $reference) {
return ['action' => 'invalid', 'message' => 'Missing contract.reference'];
}
@ -605,6 +832,32 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
// If we still need to insert, we must resolve clientCaseId, but avoid creating new person/case unless necessary
if (! $existing && ! $clientCaseId) {
$clientRef = $mapped['client_case']['client_ref'] ?? null;
// First, 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 and we have mapped person identifiers/data, set it once
if (! $cc->person_id) {
$pid = null;
if (! empty($mapped['person'] ?? [])) {
$pid = $this->findPersonIdByIdentifiers($mapped['person']);
if (! $pid) {
$pid = $this->findOrCreatePersonId($mapped['person']);
}
}
if (! $pid) {
$pid = $this->createMinimalPersonId();
}
$cc->person_id = $pid;
$cc->save();
}
}
}
if (! $clientCaseId) {
// Resolve by identifiers or provided person; do not use Client->person
$personId = null;
if (! empty($mapped['person'] ?? [])) {
@ -619,7 +872,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
if ($clientId && $personId) {
$clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId);
$clientCaseId = $this->findOrCreateClientCaseId($clientId, $personId, $clientRef);
} elseif ($personId) {
// require an import client to attach case/contract
return ['action' => 'invalid', 'message' => 'Import must be linked to a client to create a case'];
@ -627,6 +880,7 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
return ['action' => 'invalid', 'message' => 'Unable to resolve client_case (need import client)'];
}
}
}
// Build applyable data based on apply_mode
$applyInsert = [];
@ -644,6 +898,9 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
continue;
}
$value = $contractData[$field] ?? null;
if ($field === 'reference' && ! is_null($value)) {
$value = preg_replace('/\s+/', '', trim((string) $value));
}
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $value;
@ -655,11 +912,12 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
if ($existing) {
if (empty($applyUpdate)) {
return ['action' => 'skipped', 'message' => 'No contract fields marked for update'];
// Return existing contract reference even when skipped so callers can treat as resolved
return ['action' => 'skipped', 'message' => 'No contract fields marked for update', 'contract' => $existing];
}
$changes = array_filter($applyUpdate, fn ($v) => ! is_null($v));
if (empty($changes)) {
return ['action' => 'skipped', 'message' => 'No non-null contract changes'];
return ['action' => 'skipped', 'message' => 'No non-null contract changes', 'contract' => $existing];
}
$existing->fill($changes);
$existing->save();
@ -681,6 +939,197 @@ private function upsertContractChain(Import $import, array $mapped, $mappings):
}
}
private function sanitizeHeaderName(string $v): string
{
// Strip UTF-8 BOM and trim whitespace/control characters
$v = preg_replace('/^\xEF\xBB\xBF/', '', $v) ?? $v;
return trim($v);
}
private function findSourceColumnFor($mappings, string $targetField): ?string
{
foreach ($mappings as $map) {
if ((string) ($map->target_field ?? '') === $targetField) {
$src = (string) ($map->source_column ?? '');
return $src !== '' ? $src : null;
}
}
return null;
}
// Removed auto-detection helpers by request: no pattern scanning or fallback derivation
private function normalizeDecimal(string $raw): string
{
// Keep digits, comma, dot, and minus to detect separators
$s = preg_replace('/[^0-9,\.-]/', '', $raw) ?? '';
$s = trim($s);
if ($s === '') {
return $s;
}
$lastComma = strrpos($s, ',');
$lastDot = strrpos($s, '.');
// Determine decimal separator by last occurrence
$decimalSep = null;
if ($lastComma !== false || $lastDot !== false) {
if ($lastComma === false) {
$decimalSep = '.';
} elseif ($lastDot === false) {
$decimalSep = ',';
} else {
$decimalSep = $lastComma > $lastDot ? ',' : '.';
}
}
// Remove all thousand separators (the other one) and unify decimal to '.'
if ($decimalSep === ',') {
// remove all dots
$s = str_replace('.', '', $s);
// replace last comma with dot
$pos = strrpos($s, ',');
if ($pos !== false) {
$s[$pos] = '.';
}
// remove any remaining commas (unlikely)
$s = str_replace(',', '', $s);
} elseif ($decimalSep === '.') {
// remove all commas
$s = str_replace(',', '', $s);
// dot already decimal
} else {
// no decimal separator: remove commas/dots entirely
$s = str_replace([',', '.'], '', $s);
}
// Collapse multiple minus signs, keep leading only
$s = ltrim($s, '+');
$neg = false;
if (str_starts_with($s, '-')) {
$neg = true;
$s = ltrim($s, '-');
}
// Remove any stray minus signs
$s = str_replace('-', '', $s);
if ($neg) {
$s = '-'.$s;
}
return $s;
}
/**
* Ensure mapping roots are recognized; fail fast if unknown roots found.
*/
private function validateMappingRoots($mappings, array $validRoots): void
{
foreach ($mappings as $map) {
$target = (string) ($map->target_field ?? '');
if ($target === '') {
continue;
}
$root = explode('.', $target)[0];
if (! in_array($root, $validRoots, true)) {
// Common typos guidance
$hint = '';
if (str_starts_with($root, 'contract')) {
$hint = ' Did you mean "contract"?';
}
throw new \InvalidArgumentException('Unknown mapping root "'.$root.'" in target_field "'.$target.'".'.$hint);
}
}
}
private function mappingIncludes($mappings, string $targetField): bool
{
foreach ($mappings as $map) {
if ((string) ($map->target_field ?? '') === $targetField) {
return true;
}
}
return false;
}
/**
* Normalize mapping target_field to canonical forms.
* Examples:
* - contracts.reference => contract.reference
* - accounts.balance_amount => account.balance_amount
* - person_phones.nu => phone.nu
* - person_addresses.address => address.address
* - emails.email|emails.value => email.value
*/
private function normalizeMappings($mappings, array $rootAliasMap, array $fieldAliasMap)
{
$normalized = [];
foreach ($mappings as $map) {
$clone = clone $map;
$clone->target_field = $this->normalizeTargetField((string) ($map->target_field ?? ''), $rootAliasMap, $fieldAliasMap);
$normalized[] = $clone;
}
return collect($normalized);
}
private function normalizeTargetField(string $target, array $rootAliasMap, array $fieldAliasMap): string
{
if ($target === '') {
return $target;
}
$parts = explode('.', $target);
$root = $parts[0] ?? '';
$field = $parts[1] ?? null;
// Root aliases (plural to canonical) from DB
$root = $rootAliasMap[$root] ?? $root;
// Field aliases per root from DB
$aliases = $fieldAliasMap[$root] ?? [];
if ($field === null && isset($aliases['__default'])) {
$field = $aliases['__default'];
} elseif (isset($aliases[$field])) {
$field = $aliases[$field];
}
// Rebuild
if ($field !== null) {
return $root.'.'.$field;
}
return $root;
}
private function loadImportEntityConfig(): array
{
$entities = ImportEntity::all();
$rootAliasMap = [];
$fieldAliasMap = [];
$validRoots = [];
foreach ($entities as $ent) {
$canonical = $ent->canonical_root;
$validRoots[] = $canonical;
foreach ((array) ($ent->aliases ?? []) as $alias) {
$rootAliasMap[$alias] = $canonical;
}
// Also ensure canonical maps to itself
$rootAliasMap[$canonical] = $canonical;
$aliases = (array) ($ent->field_aliases ?? []);
// Allow default field per entity via '__default'
if (is_array($ent->fields) && count($ent->fields)) {
$aliases['__default'] = $aliases['__default'] ?? null;
}
$fieldAliasMap[$canonical] = $aliases;
}
// sensible defaults when DB empty
if (empty($validRoots)) {
$validRoots = ['person', 'contract', 'account', 'address', 'phone', 'email', 'client_case'];
}
return [$rootAliasMap, $fieldAliasMap, $validRoots];
}
private function findOrCreatePersonId(array $p): ?int
{
// Basic dedup: by tax_number, ssn, else full_name
@ -776,14 +1225,30 @@ private function findOrCreateClientId(int $personId): int
return Client::create(['person_id' => $personId])->id;
}
private function findOrCreateClientCaseId(int $clientId, int $personId): int
private function findOrCreateClientCaseId(int $clientId, int $personId, ?string $clientRef = null): int
{
// Prefer existing by client_ref if provided
if ($clientRef) {
$cc = ClientCase::where('client_id', $clientId)
->where('client_ref', $clientRef)
->first();
if ($cc) {
// Ensure person_id is set (if missing) when matching by client_ref
if (! $cc->person_id) {
$cc->person_id = $personId;
$cc->save();
}
return $cc->id;
}
}
// Fallback: by (client_id, person_id)
$cc = ClientCase::where('client_id', $clientId)->where('person_id', $personId)->first();
if ($cc) {
return $cc->id;
}
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId])->id;
return ClientCase::create(['client_id' => $clientId, 'person_id' => $personId, 'client_ref' => $clientRef])->id;
}
private function upsertEmail(int $personId, array $emailData, $mappings): array
@ -948,4 +1413,77 @@ private function upsertPhone(int $personId, array $phoneData, $mappings): array
return ['action' => 'inserted', 'phone' => $created];
}
}
/**
* After a contract is inserted/updated, attach default segment and create an activity
* using decision_id from import/template meta. Activity note includes template name.
*/
private function postContractActions(Import $import, Contract $contract): void
{
$meta = $import->meta ?? [];
$segmentId = (int) ($meta['segment_id'] ?? 0);
$decisionId = (int) ($meta['decision_id'] ?? 0);
$templateName = (string) ($meta['template_name'] ?? optional($import->template)->name ?? '');
$actionId = (int) ($meta['action_id'] ?? 0);
// Attach segment to contract as the main (active) segment if provided
if ($segmentId > 0) {
// Ensure the segment exists on the client case and is active
$ccSeg = \DB::table('client_case_segment')
->where('client_case_id', $contract->client_case_id)
->where('segment_id', $segmentId)
->first();
if (! $ccSeg) {
\DB::table('client_case_segment')->insert([
'client_case_id' => $contract->client_case_id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
} elseif (! $ccSeg->active) {
\DB::table('client_case_segment')
->where('id', $ccSeg->id)
->update(['active' => true, 'updated_at' => now()]);
}
// Deactivate all other segments for this contract to make this the main one
\DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', '!=', $segmentId)
->update(['active' => false, 'updated_at' => now()]);
// Upsert the selected segment as active for this contract
$pivot = \DB::table('contract_segment')
->where('contract_id', $contract->id)
->where('segment_id', $segmentId)
->first();
if ($pivot) {
if (! $pivot->active) {
\DB::table('contract_segment')
->where('id', $pivot->id)
->update(['active' => true, 'updated_at' => now()]);
}
} else {
\DB::table('contract_segment')->insert([
'contract_id' => $contract->id,
'segment_id' => $segmentId,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// Create activity if decision provided
if ($decisionId > 0) {
Activity::create([
'decision_id' => $decisionId,
'action_id' => $actionId > 0 ? $actionId : null,
'contract_id' => $contract->id,
'client_case_id' => $contract->client_case_id,
'note' => trim('Imported via template'.($templateName ? ': '.$templateName : '')),
]);
}
}
}

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_entities', function (Blueprint $table) {
$table->id();
$table->string('key')->unique(); // UI key (plural except person)
$table->string('canonical_root'); // canonical root for processor (singular)
$table->string('label');
$table->json('fields')->nullable(); // array of field names for UI
$table->json('field_aliases')->nullable(); // map alias -> canonical field
$table->json('aliases')->nullable(); // array of root aliases (e.g., ["contracts","contract"])
$table->json('rules')->nullable(); // array of suggestion rules: { pattern, field, priority? }
$table->json('ui')->nullable(); // optional UI hints (default_field, order, etc.)
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_entities');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('client_cases', function (Blueprint $table) {
if (! Schema::hasColumn('client_cases', 'client_ref')) {
$table->string('client_ref', 191)->nullable()->after('person_id');
}
// Add indexes
$table->index('client_ref');
// Composite unique per client for client_ref
$table->unique(['client_id', 'client_ref']);
});
}
public function down(): void
{
Schema::table('client_cases', function (Blueprint $table) {
if (Schema::hasColumn('client_cases', 'client_ref')) {
// Drop constraints first
try {
$table->dropUnique('client_cases_client_id_client_ref_unique');
} catch (\Throwable $e) {
}
try {
$table->dropIndex('client_cases_client_ref_index');
} catch (\Throwable $e) {
}
$table->dropColumn('client_ref');
}
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
if (! Schema::hasColumn('accounts', 'initial_amount')) {
$table->decimal('initial_amount', 18, 4)->nullable()->after('description');
$table->index('initial_amount');
}
});
}
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
if (Schema::hasColumn('accounts', 'initial_amount')) {
$table->dropIndex(['initial_amount']);
$table->dropColumn('initial_amount');
}
});
}
};

View File

@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('imports')) {
Schema::create('imports', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
// Who initiated the import
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
// Optional template applied to this import (FK can be added separately if needed)
$table->foreignId('import_template_id')->nullable();
// Optional client this import is for (many imports per client)
$table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
// File/source metadata
$table->string('source_type', 12); // csv|xml|xls|xlsx|json
$table->string('file_name', 255);
$table->string('original_name', 255)->nullable();
$table->string('disk', 50)->default('local');
$table->string('path', 2048);
$table->unsignedBigInteger('size')->nullable(); // bytes
$table->string('sheet_name', 64)->nullable(); // for Excel
// Progress/status
$table->string('status', 20)->default('uploaded'); // uploaded|parsing|parsed|validating|importing|completed|failed
$table->unsignedInteger('total_rows')->default(0);
$table->unsignedInteger('valid_rows')->default(0);
$table->unsignedInteger('invalid_rows')->default(0);
$table->unsignedInteger('imported_rows')->default(0);
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamp('failed_at')->nullable();
// Diagnostics and flexibility
$table->json('error_summary')->nullable();
$table->json('meta')->nullable();
// Helpful indexes
$table->index('user_id');
$table->index('import_template_id');
$table->index('status');
$table->index('client_id');
$table->index('source_type');
$table->index(['disk', 'path']);
$table->timestamps();
$table->softDeletes();
});
}
}
public function down(): void
{
// Only drop if this migration created it (to be safe if others depend on it)
if (Schema::hasTable('imports')) {
Schema::drop('imports');
}
}
};

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('import_rows')) {
Schema::create('import_rows', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
$table->unsignedInteger('row_number');
$table->string('sheet_name', 64)->nullable();
// Type of record represented in this row (person, account, etc.)
$table->string('record_type', 50)->nullable();
// Data and results
$table->json('raw_data')->nullable();
$table->json('mapped_data')->nullable();
$table->string('status', 20)->default('pending'); // pending|valid|invalid|imported|skipped
$table->json('errors')->nullable();
$table->json('warnings')->nullable();
// Link to created entity (optional, polymorphic)
$table->nullableMorphs('entity'); // entity_type + entity_id
// Dedup/trace
$table->string('fingerprint', 64)->nullable()->index();
// Helpful indexes
$table->index(['import_id', 'status']);
$table->index(['import_id', 'row_number']);
$table->index('record_type');
$table->timestamps();
});
}
}
public function down(): void
{
if (Schema::hasTable('import_rows')) {
Schema::drop('import_rows');
}
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('import_mappings')) {
Schema::create('import_mappings', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
$table->string('source_column', 255);
$table->string('target_field', 255)->nullable();
$table->string('transform', 50)->nullable();
$table->json('options')->nullable();
$table->index(['import_id', 'source_column']);
$table->index(['import_id', 'target_field']);
$table->timestamps();
});
}
}
public function down(): void
{
if (Schema::hasTable('import_mappings')) {
Schema::drop('import_mappings');
}
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('import_events')) {
Schema::create('import_events', function (Blueprint $table) {
$table->id();
$table->foreignId('import_id')->constrained('imports')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('event', 50);
$table->string('level', 10)->default('info');
$table->text('message')->nullable();
$table->json('context')->nullable();
$table->foreignId('import_row_id')->nullable()->constrained('import_rows')->nullOnDelete();
$table->index(['import_id', 'event']);
$table->index(['import_id', 'level']);
$table->index('user_id');
$table->timestamps();
});
}
}
public function down(): void
{
if (Schema::hasTable('import_events')) {
Schema::drop('import_events');
}
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('contracts', function (Blueprint $table) {
// Increase description length from 255 to 500, keep nullable
$table->string('description', 500)->nullable()->change();
});
}
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
// Revert to original length 255, keep nullable
$table->string('description', 255)->nullable()->change();
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('contracts', function (Blueprint $table) {
// Switch description to TEXT while keeping it nullable
$table->text('description')->nullable()->change();
});
}
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
// Revert back to VARCHAR(500) nullable
$table->string('description', 500)->nullable()->change();
});
}
};

View File

@ -14,6 +14,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// \App\Models\User::factory(10)->create();
$this->call(ImportEntitySeeder::class);
// User::factory(10)->create();
// Ensure a default test user exists (idempotent)

View File

@ -0,0 +1,115 @@
<?php
namespace Database\Seeders;
use App\Models\ImportEntity;
use Illuminate\Database\Seeder;
class ImportEntitySeeder extends Seeder
{
public function run(): void
{
$defs = [
[
'key' => 'person',
'canonical_root' => 'person',
'label' => 'Person',
'fields' => ['first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'],
'field_aliases' => [
'dob' => 'birthday',
'date_of_birth' => 'birthday',
'name' => 'full_name',
],
'aliases' => ['person'],
'rules' => [
['pattern' => '/^(ime|first\s*name|firstname)\b|\bime\b/i', 'field' => 'first_name'],
['pattern' => '/^(priimek|last\s*name|lastname)\b|\bpriimek\b/i', 'field' => 'last_name'],
['pattern' => '/^(naziv|ime\s+in\s+priimek|full\s*name|name)\b|\bnaziv\b/i', 'field' => 'full_name'],
['pattern' => '/^(davcna|davčna|tax|tax\s*number|tin)\b/i', 'field' => 'tax_number'],
['pattern' => '/^(emso|emšo|ssn|social|social\s*security)\b/i', 'field' => 'social_security_number'],
['pattern' => '/^(spol|gender)\b/i', 'field' => 'gender'],
['pattern' => '/^(rojstvo|datum\s*rojstva|dob|birth|birthday|date\s*of\s*birth)\b/i', 'field' => 'birthday'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
],
'ui' => ['order' => 1],
],
[
'key' => 'person_addresses',
'canonical_root' => 'address',
'label' => 'Person Addresses',
'fields' => ['address', 'country', 'type_id', 'description'],
'aliases' => ['address', 'person_addresses'],
'rules' => [
['pattern' => '/^(naslov|ulica|address)\b/i', 'field' => 'address'],
['pattern' => '/^(drzava|država|country)\b/i', 'field' => 'country'],
],
'ui' => ['order' => 2],
],
[
'key' => 'person_phones',
'canonical_root' => 'phone',
'label' => 'Person Phones',
'fields' => ['nu', 'country_code', 'type_id', 'description'],
'field_aliases' => ['number' => 'nu'],
'aliases' => ['phone', 'person_phones'],
'rules' => [
['pattern' => '/^(telefon|tel\.?|gsm|mobile|phone|kontakt)\b/i', 'field' => 'nu'],
],
'ui' => ['order' => 3],
],
[
'key' => 'emails',
'canonical_root' => 'email',
'label' => 'Emails',
'fields' => ['value', 'is_primary', 'label'],
'field_aliases' => ['email' => 'value'],
'aliases' => ['email', 'emails'],
'rules' => [
['pattern' => '/^(email|e-?mail|mail)\b/i', 'field' => 'value'],
],
'ui' => ['order' => 4],
],
[
'key' => 'contracts',
'canonical_root' => 'contract',
'label' => 'Contracts',
'fields' => ['reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id'],
'aliases' => ['contract', 'contracts', 'contracs'],
'rules' => [
['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'],
['pattern' => '/^(od|from|start|zacetek|začetek)\b/i', 'field' => 'start_date'],
['pattern' => '/^(do|to|end|konec)\b/i', 'field' => 'end_date'],
['pattern' => '/^(komentar|opis|opomba|comment|description|note)\b/i', 'field' => 'description'],
],
'ui' => ['order' => 5],
],
[
'key' => 'accounts',
'canonical_root' => 'account',
'label' => 'Accounts',
'fields' => ['reference', 'initial_amount', 'balance_amount', 'contract_id', 'contract_reference', 'type_id', 'active', 'description'],
'aliases' => ['account', 'accounts'],
'rules' => [
['pattern' => '/^(dolg|znesek|amount|saldo|balance|debt)\b/i', 'field' => 'balance_amount'],
['pattern' => '/^(sklic|reference|ref)\b/i', 'field' => 'reference'],
],
'ui' => ['order' => 6],
],
[
'key' => 'client_cases',
'canonical_root' => 'client_case',
'label' => 'Client Cases',
'fields' => ['client_ref'],
'aliases' => ['client_case', 'client_cases', 'case', 'primeri', 'primer'],
'rules' => [
['pattern' => '/^(client\s*ref|client_ref|case\s*ref|case_ref|primer|primeri|zadeva)\b/i', 'field' => 'client_ref'],
],
'ui' => ['order' => 7],
],
];
foreach ($defs as $d) {
ImportEntity::updateOrCreate(['key' => $d['key']], $d);
}
}
}

View File

@ -171,10 +171,14 @@ const submitAttachSegment = () => {
<div
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-red-400"
>
<div class="mx-auto max-w-4x1 p-3">
<div class="mx-auto max-w-4x1 p-3 flex items-center justify-between">
<SectionTitle>
<template #title> Primer - oseba </template>
</SectionTitle>
<div v-if="client_case && client_case.client_ref" class="text-xs text-gray-600">
<span class="mr-1">Ref:</span>
<span class="inline-block px-2 py-0.5 rounded border bg-gray-50 font-mono text-gray-700">{{ client_case.client_ref }}</span>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, watch, computed } from 'vue';
import { ref, watch, computed, onMounted } from 'vue';
import { useForm, router } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
@ -23,6 +23,53 @@ const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.sk
const mappingError = ref('');
const savingMappings = ref(false);
// Dynamic entity definitions and suggestions from API
const entityDefs = ref([]);
const entityOptions = computed(() => entityDefs.value.map(e => ({ value: e.key, label: e.label || e.key })));
const fieldOptionsByEntity = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, (e.fields || []).map(f => ({ value: f, label: f }))])));
const canonicalRootByKey = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.canonical_root || e.key])));
const keyByCanonicalRoot = computed(() => {
const m = {};
for (const e of entityDefs.value) {
if (e.canonical_root) {
m[e.canonical_root] = e.key;
}
}
return m;
});
const suggestions = ref({});
async function loadEntityDefs() {
try {
const { data } = await axios.get('/api/import-entities');
entityDefs.value = data?.entities || [];
} catch (e) {
console.error('Failed to load import entity definitions', e);
}
}
async function refreshSuggestions(columns) {
const cols = Array.isArray(columns) ? columns : (detected.value.columns || []);
if (!cols || cols.length === 0) { return; }
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
} catch (e) {
console.error('Failed to load suggestions', e);
}
}
function applySuggestionToRow(row) {
const s = suggestions.value[row.source_column];
if (!s) return false;
if (!fieldOptionsByEntity.value[s.entity]) return false;
row.entity = s.entity;
row.field = s.field;
// default transform on if missing
if (!row.transform) { row.transform = 'trim'; }
if (!row.apply_mode) { row.apply_mode = 'both'; }
row.skip = false;
return true;
}
const form = useForm({
client_uuid: null,
import_template_id: null,
@ -120,6 +167,7 @@ async function fetchColumns() {
position: idx,
}));
}
await refreshSuggestions(detected.value.columns);
// If there are mappings already (template applied or saved), load them to auto-assign
await loadImportMappings();
}
@ -205,7 +253,7 @@ async function loadImportMappings() {
if (!m) return r;
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || '').split('.', 2);
const entity = recordToEntityKey(record);
const entity = keyByCanonicalRoot.value[record] || record;
return {
...r,
entity,
@ -244,61 +292,7 @@ async function processImport() {
}
}
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
// entity options and fields are dynamic from API
async function saveMappings() {
if (!importId.value) return;
@ -307,7 +301,7 @@ async function saveMappings() {
.filter(r => !r.skip && r.entity && r.field)
.map(r => ({
source_column: r.source_column,
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
target_field: `${(canonicalRootByKey.value[r.entity] || r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || 'both',
options: null,
@ -350,24 +344,9 @@ watch(mappingRows, () => {
mappingError.value = '';
}, { deep: true });
function entityKeyToRecord(key) {
// Map UI entities to record_type nouns used by processor
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
if (record === 'address') return 'person_addresses';
if (record === 'phone') return 'person_phones';
if (record === 'email') return 'emails';
if (record === 'account') return 'accounts';
if (record === 'contract') return 'contracts';
return 'person';
}
onMounted(async () => {
await loadEntityDefs();
});
</script>
@ -472,7 +451,13 @@ function recordToEntityKey(record) {
</div>
<div v-if="detected.columns.length" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<button
class="px-3 py-1.5 border rounded text-sm"
@click.prevent="(async () => { await refreshSuggestions(detected.columns); mappingRows.forEach(r => applySuggestionToRow(r)); })()"
>Auto map suggestions</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
<thead>
@ -487,7 +472,15 @@ function recordToEntityKey(record) {
</thead>
<tbody>
<tr v-for="(row, idx) in mappingRows" :key="idx" class="border-t">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border text-sm">
<div>{{ row.source_column }}</div>
<div class="text-xs mt-1" v-if="suggestions[row.source_column]">
<span class="text-gray-500">Suggest:</span>
<button class="ml-1 underline text-indigo-700 hover:text-indigo-900" @click.prevent="applySuggestionToRow(row)">
{{ suggestions[row.source_column].entity }}.{{ suggestions[row.source_column].field }}
</button>
</div>
</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full">
<option value=""></option>

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,13 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import { computed, watch } from 'vue';
const props = defineProps({
clients: Array,
segments: Array,
decisions: Array,
actions: Array,
});
const form = useForm({
@ -16,6 +20,22 @@ const form = useForm({
is_active: true,
client_uuid: null,
entities: [],
meta: {
segment_id: null,
decision_id: null,
action_id: null,
delimiter: '',
},
});
const decisionsForSelectedAction = computed(() => {
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
return act?.decisions || [];
});
watch(() => form.meta.action_id, () => {
// Clear decision when action changes to enforce valid pair
form.meta.decision_id = null;
});
function submit() {
@ -75,6 +95,32 @@ function submit() {
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
</div>
</div>
<!-- Defaults: Segment / Decision / Action -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Default Segment</label>
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(none)</option>
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Decision</label>
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
<option :value="null">(none)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Select an Action to see its Decisions.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Action (for Activity)</label>
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(none)</option>
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />

View File

@ -1,12 +1,17 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
import { computed as vComputed, watch as vWatch } from 'vue';
const props = defineProps({
template: Object,
clients: Array,
segments: Array,
decisions: Array,
actions: Array,
});
const form = useForm({
@ -24,6 +29,15 @@ const form = useForm({
},
});
const decisionsForSelectedAction = vComputed(() => {
const act = (props.actions || []).find(a => a.id === form.meta.action_id);
return act?.decisions || [];
});
vWatch(() => form.meta.action_id, () => {
form.meta.decision_id = null;
});
const entities = computed(() => (props.template.meta?.entities || []));
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
@ -42,6 +56,31 @@ const unassignedSourceColumns = computed(() => {
});
const unassignedState = ref({});
// Dynamic Import Entity definitions and field options from API
const entityDefs = ref([]);
const entityOptions = computed(() => entityDefs.value.map(e => ({ key: e.key, label: e.label || e.key })));
const fieldOptions = computed(() => Object.fromEntries(entityDefs.value.map(e => [e.key, e.fields || []])));
const ENTITY_ALIASES = computed(() => {
const map = {};
for (const e of entityDefs.value) {
const aliases = Array.isArray(e.aliases) ? [...e.aliases] : [];
if (!aliases.includes(e.key)) {
aliases.push(e.key);
}
map[e.key] = aliases;
}
return map;
});
async function loadEntityDefs() {
try {
const { data } = await axios.get('/api/import-entities');
entityDefs.value = data?.entities || [];
} catch (e) {
console.error('Failed to load import entity definitions', e);
}
}
function saveUnassigned(m) {
const st = unassignedState.value[m.id] || {};
if (st.entity && st.field) {
@ -52,25 +91,24 @@ function saveUnassigned(m) {
updateMapping(m);
}
const entityOptions = [
{ key: 'person', label: 'Person' },
{ key: 'person_addresses', label: 'Person Addresses' },
{ key: 'person_phones', label: 'Person Phones' },
{ key: 'emails', label: 'Emails' },
{ key: 'accounts', label: 'Accounts' },
{ key: 'contracts', label: 'Contracts' },
];
// Suggestions powered by backend API
const suggestions = ref({}); // { [sourceColumn]: { entity, field } }
async function refreshSuggestions(columns) {
const cols = Array.isArray(columns) ? columns : unassignedSourceColumns.value;
if (!cols || cols.length === 0) { return; }
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: cols });
suggestions.value = { ...suggestions.value, ...(data?.suggestions || {}) };
} catch (e) {
console.error('Failed to load suggestions', e);
}
}
const fieldOptions = {
person: [
'first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'
],
person_addresses: [ 'address', 'country', 'type_id', 'description' ],
person_phones: [ 'nu', 'country_code', 'type_id', 'description' ],
emails: [ 'email', 'is_primary' ],
accounts: [ 'reference', 'balance_amount', 'contract_id', 'contract_reference' ],
contracts: [ 'reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id' ],
};
// entityOptions and fieldOptions now come from API (see computed above)
// ENTITY_ALIASES computed above from API definitions
// UI_PREFERRED no longer needed; UI keys are already the desired keys
function toggle(entity) {
const el = document.getElementById(`acc-${entity}`);
@ -116,7 +154,11 @@ function deleteMapping(m) {
function reorder(entity, direction, m) {
// Build new order across all mappings, swapping positions for this entity scope
const all = [...props.template.mappings];
const entityMaps = all.filter(x => x.target_field?.startsWith(entity + '.'));
const aliases = (ENTITY_ALIASES.value[entity] || [entity]).map(a => a + '.');
const entityMaps = all.filter(x => {
const tf = x.target_field || '';
return aliases.some(prefix => tf.startsWith(prefix));
});
const idx = entityMaps.findIndex(x => x.id === m.id);
if (idx < 0) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
@ -158,6 +200,18 @@ function performDelete() {
onFinish: () => { deleteConfirmOpen.value = false; },
});
}
// Load entity definitions and initial suggestions
onMounted(async () => {
await loadEntityDefs();
await refreshSuggestions();
});
// Refresh suggestions when unassigned list changes
watch(() => unassignedSourceColumns.value.join('|'), async () => {
await refreshSuggestions();
});
</script>
<template>
@ -223,6 +277,28 @@ function performDelete() {
</select>
<p class="text-xs text-gray-500 mt-1">Pusti prazno za samodejno zaznavo. Uporabi, ko zaznavanje ne deluje pravilno.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti Segment</label>
<select v-model="form.meta.segment_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(brez)</option>
<option v-for="s in (props.segments || [])" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeta Odločitev</label>
<select v-model="form.meta.decision_id" class="mt-1 block w-full border rounded p-2" :disabled="!form.meta.action_id">
<option :value="null">(brez)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">Najprej izberi dejanje, nato odločitev.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeto Dejanja (Activity)</label>
<select v-model="form.meta.action_id" class="mt-1 block w-full border rounded p-2">
<option :value="null">(brez)</option>
<option v-for="a in (props.actions || [])" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
@ -313,6 +389,13 @@ function performDelete() {
<div class="text-sm">
<div class="text-gray-500 text-xs">Source</div>
<div class="font-medium">{{ m.source_column }}</div>
<div class="mt-1 text-xs" v-if="suggestions[m.source_column]">
<span class="text-gray-500">Predlog:</span>
<button
class="ml-1 underline text-indigo-700 hover:text-indigo-900"
@click.prevent="(() => { const s = suggestions[m.source_column]; if (!s) return; (unassignedState[m.id] ||= {}).entity = s.entity; (unassignedState[m.id] ||= {}).field = s.field; saveUnassigned(m); })()"
>{{ suggestions[m.source_column].entity }}.{{ suggestions[m.source_column].field }}</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-600">Entity</label>
@ -364,7 +447,10 @@ function performDelete() {
<div class="mt-4 space-y-4">
<!-- Existing mappings for this entity -->
<div v-if="props.template.mappings && props.template.mappings.length" class="space-y-2">
<div v-for="m in props.template.mappings.filter(m=>m.target_field?.startsWith(entity + '.'))" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
<div v-for="m in props.template.mappings.filter(m => {
const aliases = ENTITY_ALIASES[entity] || [entity];
return aliases.some(a => m.target_field?.startsWith(a + '.'));
})" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center">
<input v-model="m.source_column" class="border rounded p-2 text-sm" />
<input v-model="m.target_field" class="border rounded p-2 text-sm" />
@ -470,9 +556,10 @@ function performDelete() {
@click.prevent="(() => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
const eKey = entity;
useForm({
sources: b.sources,
entity,
entity: eKey,
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
@ -483,6 +570,35 @@ function performDelete() {
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
<button
class="ml-2 px-3 py-2 bg-emerald-600 text-white rounded"
@click.prevent="(async () => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
const list = b.sources.split(/[\n,]+/).map(s=>s.trim()).filter(Boolean);
try {
const { data } = await axios.post('/api/import-entities/suggest', { columns: list });
const sugg = data?.suggestions || {};
for (const src of list) {
const s = sugg[src];
if (!s) continue;
const aliases = ENTITY_ALIASES.value[entity] || [entity];
if (!aliases.includes(s.entity)) continue; // only apply for this entity
const payload = {
source_column: src,
target_field: `${s.entity}.${s.field}`,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), { preserveScroll: true });
}
} catch (e) {
console.error('Failed to auto-add suggestions', e);
}
bulkRows[entity] = {};
})()"
>Auto iz predlog</button>
</div>
</div>
</div>

View File

@ -46,11 +46,7 @@ defineProps({
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
<template v-if="$page.props.jetstream.hasAccountDeletionFeatures">
<SectionBorder />
<DeleteUserForm class="mt-10 sm:mt-0" />
</template>
</div>
</div>
</AppLayout>

View File

@ -9,14 +9,18 @@
return $request->user();
})->middleware('auth:sanctum');
Route::get('/person', function(){
Route::get('/person', function () {
return new PersonCollection(Person::all());
})->middleware('auth:sanctum');
Route::get('/search', function(Request $request){
Route::get('/search', function (Request $request) {
$query = '41242523';
$persons = App\Models\Person\Person::search($query)->get();
return $persons;
})->middleware('auth:sanctum');
// Import entities and suggestions
Route::get('/import-entities', [\App\Http\Controllers\ImportEntityController::class, 'index'])->name('api.importEntities.index');
Route::post('/import-entities/suggest', [\App\Http\Controllers\ImportEntityController::class, 'suggest'])->name('api.importEntities.suggest');

View File

@ -89,6 +89,27 @@
})
->get();
// Also search by contract reference and include affiliated cases ("Primeri")
$limit = (int) ($request->input('limit') ?? 8);
$query = trim((string) $request->input('query'));
if ($query !== '') {
$contractCases = \App\Models\Contract::query()
->join('client_cases', 'contracts.client_case_id', '=', 'client_cases.id')
->join('person', 'client_cases.person_id', '=', 'person.id')
// portable case-insensitive match across drivers
->whereRaw('LOWER(contracts.reference) LIKE ?', ['%'.mb_strtolower($query).'%'])
->select('person.*', 'client_cases.uuid as case_uuid')
->limit($limit)
->get();
// Merge and de-duplicate by case uuid
$clientCases = $clientCases
->concat($contractCases)
->unique('case_uuid')
->values()
->take($limit);
}
return [
'clients' => $clients,
'client_cases' => $clientCases,

View File

@ -0,0 +1,39 @@
<?php
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns client cases when searching by contract reference', function () {
// Arrange: create a user and authenticate
$user = User::factory()->create();
$this->actingAs($user);
// Create a client case with a contract that has a known reference
/** @var ClientCase $clientCase */
$clientCase = ClientCase::factory()->create();
/** @var Contract $contract */
$contract = Contract::factory()->create([
'client_case_id' => $clientCase->id,
'reference' => 'REF-TEST-12345',
]);
// Act: hit the global search route with the contract reference
$response = $this->get(route('search', [
'query' => 'REF-TEST-12345',
'limit' => 8,
]));
// Assert: response contains the affiliated case in "client_cases"
$response->assertOk();
$data = $response->json();
expect($data)
->toHaveKey('client_cases')
->and(collect($data['client_cases'])->pluck('case_uuid')->all())
->toContain($clientCase->uuid);
});

View File

@ -0,0 +1,96 @@
<?php
use App\Models\Client;
use App\Models\Contract;
use App\Models\Import;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('imports account.initial_amount when mapped', function () {
\Illuminate\Support\Facades\Artisan::call('db:seed', ['--force' => true]);
$user = User::factory()->create();
Auth::login($user);
$client = Client::factory()->create();
Storage::fake('local');
// CSV with straightforward decimal to avoid delimiter/locale complications
$csv = "contract.reference,account.reference,account.initial_amount\nREF-IA-1,ACC-IA-1,1234.56\n";
Storage::disk('local')->put('imports/acc_init.csv', $csv);
$import = Import::create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'client_id' => $client->id,
'source_type' => 'csv',
'file_name' => 'acc_init.csv',
'original_name' => 'acc_init.csv',
'disk' => 'local',
'path' => 'imports/acc_init.csv',
'status' => 'queued',
'meta' => [
'has_header' => true,
'columns' => ['contract.reference','account.reference','account.initial_amount'],
],
'import_template_id' => null,
]);
// Mappings
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'contract.reference',
'target_field' => 'contract.reference',
'transform' => 'trim|upper|ref',
'apply_mode' => 'both',
'options' => null,
'position' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'contract.reference',
'target_field' => 'account.contract_reference',
'transform' => 'trim|upper|ref',
'apply_mode' => 'both',
'options' => null,
'position' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'account.reference',
'target_field' => 'account.reference',
'transform' => 'trim|upper|ref',
'apply_mode' => 'both',
'options' => null,
'position' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'account.initial_amount',
'target_field' => 'account.initial_amount',
'transform' => 'trim',
'apply_mode' => 'both',
'options' => null,
'position' => 3,
'created_at' => now(),
'updated_at' => now(),
]);
$service = app(\App\Services\ImportProcessor::class);
$service->process($import, $user);
$acc = \App\Models\Account::query()->first();
expect($acc)->not->toBeNull();
// normalized decimal should be 1234.56
expect((string) $acc->initial_amount)->toBe('1234.56');
});

View File

@ -0,0 +1,87 @@
<?php
use App\Models\Client;
use App\Models\Import;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('deduplicates client cases by client_ref when importing', function () {
// Ensure base seeds for reference tables
\Illuminate\Support\Facades\Artisan::call('db:seed', ['--force' => true]);
$user = User::factory()->create();
Auth::login($user);
// Prepare client and a small CSV with two rows sharing same client_ref but different contract.reference
$client = Client::factory()->create();
Storage::fake('local');
$csv = "client_ref,contract.reference,contract.description\nCASE-001,REF-1,First\nCASE-001,REF-2,Second\n";
Storage::disk('local')->put('imports/test.csv', $csv);
$import = Import::create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'client_id' => $client->id,
'source_type' => 'csv',
'file_name' => 'test.csv',
'original_name' => 'test.csv',
'disk' => 'local',
'path' => 'imports/test.csv',
'status' => 'queued',
'meta' => ['has_header' => true, 'columns' => ['client_ref', 'contract.reference', 'contract.description']],
'import_template_id' => null,
]);
// Mapping: map client_ref to client_case.client_ref and contract fields
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'client_ref',
'target_field' => 'client_case.client_ref',
'transform' => 'trim|upper',
'apply_mode' => 'both',
'options' => null,
'position' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'contract.reference',
'target_field' => 'contract.reference',
'transform' => 'trim|upper|ref',
'apply_mode' => 'both',
'options' => null,
'position' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'contract.description',
'target_field' => 'contract.description',
'transform' => 'trim',
'apply_mode' => 'both',
'options' => null,
'position' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
// Process
$service = app(\App\Services\ImportProcessor::class);
$service->process($import, $user);
// There should be exactly one client case for CASE-001 and two contracts under it
$ccs = \App\Models\ClientCase::where('client_id', $client->id)->where('client_ref', 'CASE-001')->get();
// Ensure at least one exists
expect($ccs->count())->toBeGreaterThanOrEqual(1);
// Dedup to 1
expect($ccs->count())->toBe(1);
$contracts = \App\Models\Contract::where('client_case_id', $ccs->first()->id)->get();
expect($contracts->count())->toBe(2);
});

View File

@ -0,0 +1,90 @@
<?php
use App\Models\Client;
use App\Models\Contract;
use App\Models\Import;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('reuses a single person per client_ref (client_case) across multiple rows', function () {
\Illuminate\Support\Facades\Artisan::call('db:seed', ['--force' => true]);
$user = User::factory()->create();
Auth::login($user);
$client = Client::factory()->create();
Storage::fake('local');
$csv = "client_ref,contract.reference,person.first_name\nCASE-PR-1,REF-100,Ana\nCASE-PR-1,REF-200,Borut\n";
Storage::disk('local')->put('imports/ppl.csv', $csv);
$import = Import::create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'client_id' => $client->id,
'source_type' => 'csv',
'file_name' => 'ppl.csv',
'original_name' => 'ppl.csv',
'disk' => 'local',
'path' => 'imports/ppl.csv',
'status' => 'queued',
'meta' => ['has_header' => true, 'columns' => ['client_ref','contract.reference','person.first_name']],
'import_template_id' => null,
]);
// Map client_ref, contract.reference, and a person field
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'client_ref',
'target_field' => 'client_case.client_ref',
'transform' => 'trim|upper',
'apply_mode' => 'both',
'options' => null,
'position' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'contract.reference',
'target_field' => 'contract.reference',
'transform' => 'trim|upper|ref',
'apply_mode' => 'both',
'options' => null,
'position' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'source_column' => 'person.first_name',
'target_field' => 'person.first_name',
'transform' => 'trim',
'apply_mode' => 'both',
'options' => null,
'position' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
$service = app(\App\Services\ImportProcessor::class);
$service->process($import, $user);
$case = \App\Models\ClientCase::where('client_id', $client->id)->where('client_ref', 'CASE-PR-1')->first();
expect($case)->not->toBeNull();
expect($case->person_id)->not->toBeNull();
// Both contracts should be under the same case
$countContracts = Contract::where('client_case_id', $case->id)->count();
expect($countContracts)->toBe(2);
// The person assigned to the case should be the only person linked for this case usage
$personId = $case->person_id;
// There should not be any other person created solely due to these rows; we assert that attaching contacts is done to the same person
// To be conservative, assert that person exists and is used; deeper global uniqueness is not enforced here.
expect($personId)->toBeInt();
});

View File

@ -0,0 +1,33 @@
<?php
use App\Services\CsvImportService;
it('detects semicolon-delimited headers after leading blanks', function () {
$tmpPath = storage_path('app/test_csv_detection_semicolon.csv');
$content = "\n\n\nCol A;Col B;Col C\n1;2;3\n";
// Prepend UTF-8 BOM to simulate common exports
$content = "\xEF\xBB\xBF".$content;
file_put_contents($tmpPath, $content);
$svc = new CsvImportService;
[$delim, $cols] = $svc->detectColumnsFromCsv($tmpPath, true);
expect($delim)->toBe(';');
expect($cols)->toBe(['Col A', 'Col B', 'Col C']);
@unlink($tmpPath);
});
it('returns positional indices when hasHeader is false', function () {
$tmpPath = storage_path('app/test_csv_detection_positional.csv');
$content = "\n\nA;B;C\n";
file_put_contents($tmpPath, $content);
$svc = new CsvImportService;
[$delim, $cols] = $svc->detectColumnsFromCsv($tmpPath, false);
expect($delim)->toBe(';');
expect($cols)->toBe(['0', '1', '2']);
@unlink($tmpPath);
});