changes 0129092025 laptop

This commit is contained in:
Simon 2025-09-29 17:35:54 +02:00
parent 30eee6c5b0
commit 1fddf959f0
7 changed files with 277 additions and 4 deletions

View File

@ -171,12 +171,27 @@ public function columns(Request $request, Import $import, CsvImportService $csv)
{
$validated = $request->validate([
'has_header' => 'nullable|boolean',
'delimiter' => 'nullable|string|max:4',
]);
$hasHeader = array_key_exists('has_header', $validated)
? (bool) $validated['has_header']
: (bool) ($import->meta['has_header'] ?? true);
// Resolve delimiter preference: explicit param > template meta > existing meta > auto-detect
$explicitDelimiter = null;
if (array_key_exists('delimiter', $validated) && $validated['delimiter'] !== null && $validated['delimiter'] !== '') {
$explicitDelimiter = (string) $validated['delimiter'];
} elseif ($import->import_template_id) {
// Try reading template meta for a default delimiter
$tplDelimiter = optional(ImportTemplate::find($import->import_template_id))->meta['delimiter'] ?? null;
if ($tplDelimiter) {
$explicitDelimiter = (string) $tplDelimiter;
}
} 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([
@ -186,13 +201,21 @@ public function columns(Request $request, Import $import, CsvImportService $csv)
}
$fullPath = Storage::disk($import->disk)->path($import->path);
[$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader);
if ($explicitDelimiter !== null && $explicitDelimiter !== '') {
$columns = $csv->parseColumnsFromCsv($fullPath, $explicitDelimiter, $hasHeader);
$delimiter = $explicitDelimiter;
} else {
[$delimiter, $columns] = $csv->detectColumnsFromCsv($fullPath, $hasHeader);
}
// Save meta
$meta = $import->meta ?? [];
$meta['has_header'] = $hasHeader;
$meta['detected_delimiter'] = $delimiter;
$meta['columns'] = $columns;
if ($explicitDelimiter) {
$meta['forced_delimiter'] = $explicitDelimiter;
}
$import->update([
'meta' => $meta,
'status' => $import->status === 'uploaded' ? 'parsed' : $import->status,
@ -336,6 +359,7 @@ public function show(Import $import)
'import_templates.source_type',
'import_templates.default_record_type',
'import_templates.client_id',
'import_templates.meta',
'clients.uuid as client_uuid',
]);

View File

@ -227,8 +227,20 @@ public function update(Request $request, ImportTemplate $template)
'client_id' => 'nullable|integer|exists:clients,id',
'is_active' => 'boolean',
'sample_headers' => 'nullable|array',
'meta' => 'nullable|array',
'meta.delimiter' => 'nullable|string|max:4',
])->validate();
// 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']) === '')) {
unset($newMeta['delimiter']);
}
}
$template->update([
'name' => $data['name'],
'description' => $data['description'] ?? null,
@ -237,6 +249,7 @@ public function update(Request $request, ImportTemplate $template)
'client_id' => $data['client_id'] ?? null,
'is_active' => $data['is_active'] ?? $template->is_active,
'sample_headers' => $data['sample_headers'] ?? $template->sample_headers,
'meta' => $newMeta,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])

View File

@ -62,4 +62,30 @@ public function detectColumnsFromCsv(string $path, bool $hasHeader): array
return [$bestDelim, $clean];
}
/**
* Parse columns from CSV using a specific delimiter. If $hasHeader is false,
* returns positional indices instead of header names.
*/
public function parseColumnsFromCsv(string $path, string $delimiter, bool $hasHeader): array
{
$firstLine = $this->readFirstLine($path);
if ($firstLine === null) {
return [];
}
$row = str_getcsv($firstLine, $delimiter);
$count = is_array($row) ? count($row) : 0;
if ($hasHeader) {
return array_map(function ($v) {
$v = trim((string) $v);
$v = preg_replace('/\s+/', ' ', $v);
return $v;
}, $row ?: []);
}
$cols = [];
for ($i = 0; $i < $count; $i++) {
$cols[] = (string) $i;
}
return $cols;
}
}

View File

@ -58,7 +58,11 @@ public function process(Import $import, ?Authenticatable $user = null): array
->get(['source_column', 'target_field', 'transform', 'apply_mode', 'options']);
$header = $import->meta['columns'] ?? null;
$delimiter = $import->meta['detected_delimiter'] ?? ',';
// Prefer explicitly chosen delimiter, then template meta, else detected
$delimiter = $import->meta['forced_delimiter']
?? optional($import->template)->meta['delimiter']
?? $import->meta['detected_delimiter']
?? ',';
$hasHeader = (bool) ($import->meta['has_header'] ?? true);
$path = Storage::disk($import->disk)->path($import->path);

View File

@ -25,6 +25,28 @@ const savingMappings = ref(false);
// Persisted mappings from backend (raw view regardless of detected columns)
const persistedMappings = ref([]);
const detectedNote = ref('');
// Delimiter selection (auto by default, can be overridden by template or user)
const delimiterState = ref({ mode: 'auto', custom: '' });
const effectiveDelimiter = computed(() => {
switch (delimiterState.value.mode) {
case 'auto': return null; // let backend detect
case 'comma': return ',';
case 'semicolon': return ';';
case 'tab': return '\t';
case 'pipe': return '|';
case 'space': return ' ';
case 'custom': return delimiterState.value.custom || null;
default: return null;
}
});
// Initialize delimiter from import meta if previously chosen
const initForced = props.import?.meta?.forced_delimiter || null;
if (initForced) {
const map = { ',': 'comma', ';': 'semicolon', '\t': 'tab', '|': 'pipe', ' ': 'space' };
const mode = map[initForced] || 'custom';
delimiterState.value.mode = mode;
if (mode === 'custom') delimiterState.value.custom = initForced;
}
// Logs
const events = ref([]);
const eventsLimit = ref(200);
@ -276,7 +298,11 @@ const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.sk
async function fetchColumns() {
if (!importId.value) return;
const url = route('imports.columns', { import: importId.value });
const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } });
const params = { has_header: hasHeader.value ? 1 : 0 };
if (effectiveDelimiter.value) {
params.delimiter = effectiveDelimiter.value;
}
const { data } = await axios.get(url, { params });
// Normalize columns to strings for consistent rendering
const colsRaw = Array.isArray(data.columns) ? data.columns : [];
const normCols = colsRaw.map((c) => {
@ -334,6 +360,17 @@ async function applyTemplateToImport() {
withCredentials: true,
});
templateApplied.value = true;
// If template has a default delimiter, adopt it and refetch columns
const tpl = selectedTemplateOption.value;
const tplDelim = tpl?.delimiter || tpl?.meta?.delimiter || null;
if (tplDelim) {
// map to known mode if possible, else set custom
const map = { ',': 'comma', ';': 'semicolon', '\t': 'tab', '|': 'pipe', ' ': 'space' };
const mode = map[tplDelim] || 'custom';
delimiterState.value.mode = mode;
if (mode === 'custom') delimiterState.value.custom = tplDelim;
await fetchColumns();
}
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
@ -520,6 +557,13 @@ watch(() => detected.value.columns, (cols) => {
}
});
// If user changes delimiter selection, refresh detected columns
watch(() => delimiterState.value, async () => {
if (importId.value) {
await fetchColumns();
}
}, { deep: true });
async function fetchEvents() {
if (!importId.value) return;
loadingEvents.value = true;
@ -616,6 +660,34 @@ async function fetchEvents() {
</div>
</div>
<!-- Parsing options -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end" v-if="!isCompleted">
<div>
<label class="block text-sm font-medium text-gray-700">Header row</label>
<select v-model="hasHeader" class="mt-1 block w-full border rounded p-2" @change="fetchColumns">
<option :value="true">Has header</option>
<option :value="false">No header (positional)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Delimiter</label>
<select v-model="delimiterState.mode" class="mt-1 block w-full border rounded p-2">
<option value="auto">Auto-detect</option>
<option value="comma">Comma ,</option>
<option value="semicolon">Semicolon ;</option>
<option value="tab">Tab \t</option>
<option value="pipe">Pipe |</option>
<option value="space">Space </option>
<option value="custom">Custom</option>
</select>
<p class="text-xs text-gray-500 mt-1">Template default: {{ selectedTemplateOption?.meta?.delimiter || 'auto' }}</p>
</div>
<div v-if="delimiterState.mode === 'custom'">
<label class="block text-sm font-medium text-gray-700">Custom delimiter</label>
<input v-model="delimiterState.custom" maxlength="4" placeholder="," class="mt-1 block w-full border rounded p-2" />
</div>
</div>
<div class="flex gap-3" v-if="!isCompleted">
<button
@click.prevent="applyTemplateToImport"
@ -676,7 +748,7 @@ async function fetchEvents() {
<div v-if="!isCompleted && displayRows.length" class="pt-4">
<h3 class="font-semibold mb-2">
<template v-if="!isCompleted">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})
<span class="ml-2 text-xs text-gray-500">detected: {{ detected.columns.length }}, rows: {{ displayRows.length }}</span>
<span class="ml-2 text-xs text-gray-500">detected: {{ detected.columns.length }}, rows: {{ displayRows.length }}, delimiter: {{ detected.delimiter || 'auto' }}</span>
</template>
<template v-else>Detected Columns</template>
</h3>

View File

@ -16,6 +16,12 @@ const form = useForm({
default_record_type: props.template.default_record_type || '',
is_active: props.template.is_active,
client_uuid: props.template.client_uuid || null,
sample_headers: props.template.sample_headers || [],
// Add meta with default delimiter support
meta: {
...(props.template.meta || {}),
delimiter: (props.template.meta && props.template.meta.delimiter) || '',
},
});
const entities = computed(() => (props.template.meta?.entities || []));
@ -136,6 +142,10 @@ const save = () => {
// drop client change when blocked
delete payload.client_uuid;
}
// Normalize empty delimiter: remove from meta to allow auto-detect
if (payload.meta && typeof payload.meta.delimiter === 'string' && payload.meta.delimiter.trim() === '') {
delete payload.meta.delimiter;
}
useForm(payload).put(route('importTemplates.update', { template: props.template.uuid }), { preserveScroll: true });
};
// Non-blocking confirm modal state for delete
@ -201,6 +211,18 @@ function performDelete() {
/>
<p v-if="!canChangeClient" class="text-xs text-amber-600 mt-1">Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti ločilni znak (CSV)</label>
<select v-model="form.meta.delimiter" class="mt-1 block w-full border rounded p-2">
<option value="">(Auto-detect)</option>
<option value=",">Comma ,</option>
<option value=";">Semicolon ;</option>
<option value="\t">Tab \t</option>
<option value="|">Pipe |</option>
<option value=" ">Space </option>
</select>
<p class="text-xs text-gray-500 mt-1">Pusti prazno za samodejno zaznavo. Uporabi, ko zaznavanje ne deluje pravilno.</p>
</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>

View File

@ -0,0 +1,112 @@
<?php
use App\Models\Email;
use App\Models\Import;
use App\Models\ImportTemplate;
use App\Models\Person\PersonType;
use App\Models\User;
use App\Services\ImportProcessor;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
it('detects columns using an explicit delimiter', function () {
// Prepare file with semicolon delimiter
$uuid = (string) Str::uuid();
$disk = 'local';
$path = "imports/{$uuid}.csv";
$csv = "email;reference\nalpha@example.com;REF-1\n";
Storage::disk($disk)->put($path, $csv);
$import = Import::create([
'uuid' => $uuid,
'user_id' => null,
'import_template_id' => null,
'client_id' => null,
'source_type' => 'csv',
'file_name' => basename($path),
'original_name' => 'semicolon.csv',
'disk' => $disk,
'path' => $path,
'size' => strlen($csv),
'status' => 'uploaded',
'meta' => [ 'has_header' => true ],
]);
$response = test()->getJson(route('imports.columns', ['import' => $import->id, 'has_header' => 1, 'delimiter' => ';']));
$response->assertSuccessful();
$data = $response->json();
expect($data['detected_delimiter'])->toBe(';');
expect($data['columns'])->toBe(['email','reference']);
});
it('processes using template default delimiter when provided', function () {
// Authenticate a user so Person::creating can set user_id
$user = User::factory()->create();
Auth::login($user);
// Minimal records for defaults used by ImportProcessor
DB::table('person_groups')->insert([
'name' => 'default',
'description' => '',
'color_tag' => null,
'created_at' => now(),
'updated_at' => now(),
]);
PersonType::firstOrCreate(['name' => 'default'], ['description' => '']);
// Template with semicolon as default delimiter
$template = ImportTemplate::create([
'uuid' => (string) Str::uuid(),
'name' => 'Semicolon CSV',
'source_type' => 'csv',
'default_record_type' => null,
'user_id' => $user->id,
'client_id' => null,
'is_active' => true,
'sample_headers' => ['email','reference'],
'meta' => [ 'delimiter' => ';' ],
]);
// Put a semicolon CSV file
$uuid = (string) Str::uuid();
$disk = 'local';
$path = "imports/{$uuid}.csv";
$csv = "email;reference\njohn.delim@example.com;R-100\n";
Storage::disk($disk)->put($path, $csv);
$import = Import::create([
'uuid' => $uuid,
'user_id' => $user->id,
'import_template_id' => $template->id,
'client_id' => null,
'source_type' => 'csv',
'file_name' => basename($path),
'original_name' => 'semicol.csv',
'disk' => $disk,
'path' => $path,
'size' => strlen($csv),
'status' => 'parsed',
// columns present to allow mapping by header name
'meta' => [ 'has_header' => true, 'columns' => ['email','reference'] ],
]);
// Map email -> email.value
DB::table('import_mappings')->insert([
'import_id' => $import->id,
'entity' => 'emails',
'source_column' => 'email',
'target_field' => 'email.value',
'transform' => 'trim',
'apply_mode' => 'both',
'options' => null,
'position' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$result = (new ImportProcessor)->process($import, $user);
expect($result['ok'])->toBeTrue();
expect(Email::where('value', 'john.delim@example.com')->exists())->toBeTrue();
});