Import CSV des relevés d'une source

- ImportController : create() (formulaire) + store() (traitement)
- Détection automatique du séparateur ; ou ,
- Suppression du BOM UTF-8
- Correspondance colonnes ↔ champs par libellé
- Parsing des types : date (avec calendrier), booléen, nombre, lieu (recherche par nom_long), texte
- Vue sources/import.blade.php : formulaire + liste des colonnes attendues
- Routes sources.import.create / sources.import.store
- Bouton "↑ Importer CSV" dans la fiche source (soumis aux droits create releve)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 20:05:03 +02:00
parent f5a7407be0
commit cdbf6d458c
4 changed files with 222 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FieldType;
use App\Models\Lieu;
use App\Models\Releve;
use App\Models\Source;
use App\Support\DbCompat;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ImportController extends Controller
{
public function create(Source $source): View
{
$this->authorize('create', [Releve::class, $source]);
$source->load('sourceType.fields');
return view('sources.import', compact('source'));
}
public function store(Request $request, Source $source): RedirectResponse
{
$this->authorize('create', [Releve::class, $source]);
$request->validate([
'fichier' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
]);
$source->load('sourceType.fields');
$fieldsByLabel = $source->sourceType->fields->keyBy('label');
$path = $request->file('fichier')->getRealPath();
$handle = fopen($path, 'r');
// Suppression du BOM UTF-8 éventuel
$bom = fread($handle, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($handle);
}
// Détection automatique du séparateur (; ou ,)
$firstLine = fgets($handle);
rewind($handle);
if ($bom === "\xEF\xBB\xBF") {
fread($handle, 3);
}
$sep = substr_count($firstLine, ';') >= substr_count($firstLine, ',') ? ';' : ',';
$header = fgetcsv($handle, 0, $sep);
if (! $header) {
fclose($handle);
return back()->with('error', 'Fichier CSV vide ou invalide.');
}
$header = array_map('trim', $header);
$imported = 0;
$userId = auth()->id();
while (($line = fgetcsv($handle, 0, $sep)) !== false) {
if (count(array_filter($line, fn ($v) => $v !== '')) === 0) {
continue;
}
$data = [];
foreach ($header as $i => $label) {
$field = $fieldsByLabel->get($label);
if (! $field) {
continue;
}
$data[$field->name] = $this->parseValue(trim($line[$i] ?? ''), $field->type);
}
$source->releves()->create([
'data' => $data,
'created_by' => $userId,
'updated_by' => $userId,
]);
$imported++;
}
fclose($handle);
if ($imported === 0) {
return back()->with('error', 'Aucun relevé importé — vérifiez que les en-têtes correspondent aux libellés des champs.');
}
return redirect()->route('sources.releves.index', $source)
->with('success', "{$imported} relevé(s) importé(s) avec succès.");
}
private function parseValue(string $raw, FieldType $type): mixed
{
return match ($type) {
FieldType::Boolean => in_array(mb_strtolower($raw), ['oui', 'yes', '1', 'true'], true),
FieldType::Number => $raw !== '' ? (float) str_replace(',', '.', $raw) : null,
FieldType::Date => $this->parseDate($raw),
FieldType::Place => $this->parsePlace($raw),
default => $raw !== '' ? $raw : null,
};
}
private function parseDate(string $raw): array
{
if ($raw === '') {
return ['valeur' => null, 'calendrier' => 'gregorien'];
}
// Format export : "YYYY-MM-DD" ou "YYYY-MM-DD (calendrier)"
if (preg_match('/^(.+?)\s*\((\w+)\)\s*$/', $raw, $m)) {
return ['valeur' => trim($m[1]), 'calendrier' => trim($m[2])];
}
return ['valeur' => $raw, 'calendrier' => 'gregorien'];
}
private function parsePlace(string $raw): ?array
{
if ($raw === '') {
return null;
}
$like = DbCompat::like();
$lieu = Lieu::where('nom_long', $raw)->first(['id', 'nom_long'])
?? Lieu::where('nom_long', $like, $raw . '%')->first(['id', 'nom_long']);
return $lieu
? ['id' => $lieu->id, 'nom_long' => $lieu->nom_long]
: ['id' => null, 'nom_long' => $raw];
}
}