changes 0129092025 laptop
This commit is contained in:
parent
30eee6c5b0
commit
1fddf959f0
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
112
tests/Feature/ImportDelimiterTest.php
Normal file
112
tests/Feature/ImportDelimiterTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user