================================================================================ GUIDE COMPLET — INTÉGRATION OFFRES D'EMPLOI ODOO DANS UN PROJET NEXT.JS ================================================================================ Rédigé à partir du projet portail-sar (Next.js 16 + Laravel 10 + Odoo XML-RPC) Date : Avril 2026 ================================================================================ TABLE DES MATIÈRES ────────────────── 1. Vue d'ensemble de l'architecture 2. Prérequis 3. Variables d'environnement 4. BACKEND LARAVEL — Étape par étape 4.1 Config Odoo 4.2 Service OdooClient (XML-RPC) 4.3 Migration base de données (cache) 4.4 Modèle CachedJob 4.5 JobController (API) 4.6 Routes API 4.7 Commande artisan de synchronisation 4.8 Planification (Scheduler) 4.9 CORS 5. FRONTEND NEXT.JS — Étape par étape 5.1 Variable d'environnement frontend 5.2 Interface TypeScript OdooJob 5.3 Page /emploi (page.tsx) 5.4 Traductions (fr.json / en.json) 5.5 Lien dans la navbar 5.6 Internationalisation (next-intl) 6. Test de l'intégration 7. Synchronisation manuelle depuis l'admin 8. Récapitulatif des flux de données 9. Erreurs courantes et solutions 10. Checklist finale ================================================================================ 1. VUE D'ENSEMBLE DE L'ARCHITECTURE ================================================================================ Le système fonctionne en 3 couches : ┌─────────────────────────────────────────────────────────────────┐ │ FRONTEND (Next.js) │ │ - Page /emploi │ │ - Affiche les offres avec formulaire de candidature intégré │ └────────────────────────┬────────────────────────────────────────┘ │ HTTP (fetch) │ GET /api/jobs │ POST /api/jobs/{id}/apply ▼ ┌─────────────────────────────────────────────────────────────────┐ │ BACKEND (Laravel) │ │ - JobController : sert les offres depuis le cache MySQL │ │ - OdooClient : communique avec Odoo via XML-RPC │ │ - Sync quotidien : php artisan odoo:sync-jobs │ └────────────────────────┬────────────────────────────────────────┘ │ XML-RPC (HTTPS) │ /xmlrpc/2/common (auth) │ /xmlrpc/2/object (données) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ ODOO │ │ - Modèle hr.job : offres d'emploi │ │ - Modèle hr.candidate : candidats │ │ - Modèle hr.applicant : candidatures │ │ - Modèle ir.attachment: pièces jointes (CV) │ └─────────────────────────────────────────────────────────────────┘ Pourquoi un cache MySQL ? - Odoo peut être lent ou indisponible - La page /emploi doit charger rapidement - Les candidatures, elles, sont envoyées DIRECTEMENT à Odoo (pas de cache) ================================================================================ 2. PRÉREQUIS ================================================================================ Backend (Laravel) : - PHP >= 8.1 - Laravel >= 10 - MySQL ou MariaDB - Extension PHP : php-xml (pour XML-RPC), php-mbstring, php-curl Frontend (Next.js) : - Next.js >= 14 (App Router) - next-intl (si multilingue) - framer-motion (si animations) - TypeScript Odoo : - Instance Odoo accessible via HTTPS (cloud ou self-hosted) - Compte utilisateur Odoo avec accès au module Recrutement (hr) - Module "Recrutement" activé dans Odoo - Les offres doivent être marquées "Publiées sur le site web" (website_published = true) ================================================================================ 3. VARIABLES D'ENVIRONNEMENT ================================================================================ --- BACKEND (.env dans le dossier Laravel) --- Valeurs utilisées dans le projet SAR (à adapter pour votre nouveau projet) : APP_URL=http://10.113.245.26:8000 DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=portail_sar DB_USERNAME=portail_sar_user DB_PASSWORD=root CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://10.113.245.26:3000 # ── Credentials Odoo (instance de l'environnement SAR) ────────────────────── ODOO_URL=https://sar-sirh-feature-sar-recipe-28977020.dev.odoo.com ODOO_DB=sar-sirh-feature-sar-recipe-28977020 ODOO_USERNAME=abdoulayelah.external@sar.sn ODOO_PASSWORD=Lah # ──────────────────────────────────────────────────────────────────────────── FRONTEND_URL=http://10.113.245.26:3000 --- FRONTEND (.env.local dans le dossier Next.js) --- NEXT_PUBLIC_API_URL=http://10.113.245.26:8000 IMPORTANT : Pour un nouveau projet sur une machine différente, remplacez 10.113.245.26 par la vraie IP de votre serveur. En développement local sur la même machine, utilisez http://localhost:8000. ================================================================================ 4. BACKEND LARAVEL — ÉTAPE PAR ÉTAPE ================================================================================ ──────────────────────────────────────────────────────────── 4.1 CONFIG ODOO ──────────────────────────────────────────────────────────── Créer le fichier : config/odoo.php env('ODOO_URL'), 'db' => env('ODOO_DB'), 'username' => env('ODOO_USERNAME'), 'password' => env('ODOO_PASSWORD'), ]; ──────────────────────────────────────────────────────────── 4.2 SERVICE ODOO CLIENT (XML-RPC) ──────────────────────────────────────────────────────────── Créer le fichier : app/Services/OdooClient.php Ce service gère TOUTE la communication avec Odoo via le protocole XML-RPC. XML-RPC est le seul protocole supporté nativement par Odoo pour l'API externe. url = config('odoo.url'); $this->db = config('odoo.db'); $this->username = config('odoo.username'); $this->password = config('odoo.password'); $this->uid = $this->authenticate(); } // ── Authentification ────────────────────────────────────────────────── protected function authenticate(): int { $response = $this->xmlrpcCall( $this->url . '/xmlrpc/2/common', 'authenticate', [$this->db, $this->username, $this->password, []] ); if (!$response || !is_int($response)) { throw new \Exception('Odoo auth failed. Check ODOO_USERNAME / ODOO_PASSWORD.'); } return $response; } // ── Récupérer les offres publiées ───────────────────────────────────── public function getPublishedJobs(): array { return $this->xmlrpcCall( $this->url . '/xmlrpc/2/object', 'execute_kw', [ $this->db, $this->uid, $this->password, 'hr.job', 'search_read', [[['website_published', '=', true]]], [ 'fields' => [ 'id', 'name', 'department_id', 'job_description', 'description', 'no_of_recruitment', 'address_id', 'publication_start_date', 'publication_end_date', ], 'limit' => 100, ] ] ) ?? []; } // ── Récupérer une offre par ID ───────────────────────────────────────── public function getJob(int $jobId): ?array { $results = $this->xmlrpcCall( $this->url . '/xmlrpc/2/object', 'execute_kw', [ $this->db, $this->uid, $this->password, 'hr.job', 'search_read', [[['website_published', '=', true], ['id', '=', $jobId]]], [ 'fields' => [ 'id', 'name', 'department_id', 'job_description', 'description', 'no_of_recruitment', 'address_id', 'publication_start_date', 'publication_end_date', ], 'limit' => 1, ] ] ) ?? []; return count($results) > 0 ? $results[0] : null; } // ── Soumettre une candidature ───────────────────────────────────────── // Processus en 3 étapes : // 1. Créer le candidat (hr.candidate) // 2. Attacher le CV (ir.attachment) // 3. Créer la candidature (hr.applicant) public function submitApplication( int $jobId, string $name, string $email, ?string $phone = null, ?string $coverLetter = null, ?string $cvBase64 = null, ?string $cvFilename = null, ?string $workType = null ): int { // Étape 1 : Créer le candidat $candidateData = ['partner_name' => $name, 'email' => $email]; if ($phone) $candidateData['partner_phone'] = $phone; $candidateId = $this->xmlrpcCall( $this->url . '/xmlrpc/2/object', 'execute_kw', [$this->db, $this->uid, $this->password, 'hr.candidate', 'create', [$candidateData]] ); // Étape 2 : Attacher le CV si fourni $attachmentId = null; if ($cvBase64 && $cvFilename && $candidateId) { $attachmentId = $this->xmlrpcCall( $this->url . '/xmlrpc/2/object', 'execute_kw', [ $this->db, $this->uid, $this->password, 'ir.attachment', 'create', [[ 'name' => $cvFilename, 'datas' => $cvBase64, 'res_model' => 'hr.candidate', 'res_id' => $candidateId, 'type' => 'binary', ]] ] ); } // Étape 3 : Créer la candidature $applicantData = [ 'job_id' => $jobId, 'partner_name' => $name, 'email_from' => $email, 'candidate_id' => $candidateId, 'description' => $coverLetter ? '

' . $coverLetter . '

' : false, ]; if ($phone) $applicantData['partner_phone'] = $phone; if ($attachmentId) $applicantData['attachment_ids'] = [[4, $attachmentId]]; $applicantId = $this->xmlrpcCall( $this->url . '/xmlrpc/2/object', 'execute_kw', [$this->db, $this->uid, $this->password, 'hr.applicant', 'create', [$applicantData]] ); return $applicantId; } // ── Moteur XML-RPC (bas niveau) ─────────────────────────────────────── protected function xmlrpcCall(string $endpoint, string $method, array $params): mixed { $xml = $this->encodeXmlrpc($method, $params); $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $xml, CURLOPT_HTTPHEADER => ['Content-Type: text/xml; charset=utf-8'], CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, // mettre true en production avec bon certificat ]); $response = curl_exec($ch); $error = curl_error($ch); curl_close($ch); if ($error || !$response) { Log::error('Odoo XML-RPC curl error', ['endpoint' => $endpoint, 'error' => $error]); return null; } return $this->decodeXmlrpcResponse($response); } // ── Encodage XML-RPC ────────────────────────────────────────────────── protected function encodeXmlrpc(string $method, array $params): string { $paramsXml = implode('', array_map(fn($p) => '' . $this->encodeValue($p) . '', $params)); return '' . htmlspecialchars($method) . '' . $paramsXml . ''; } protected function encodeValue(mixed $value): string { if (is_null($value)) return '0'; if (is_bool($value)) return '' . ($value ? '1' : '0') . ''; if (is_int($value)) return '' . $value . ''; if (is_float($value)) return '' . $value . ''; if (is_array($value)) { // Vérifier si c'est un tableau associatif (struct) ou indexé (array) if (array_keys($value) !== range(0, count($value) - 1)) { // Struct (objet / dictionnaire) $members = ''; foreach ($value as $k => $v) { $members .= '' . htmlspecialchars((string)$k) . '' . $this->encodeValue($v) . ''; } return '' . $members . ''; } else { // Array $items = implode('', array_map(fn($v) => '' . $this->encodeValue($v) . '', $value)); return '' . implode('', array_map( fn($v) => $this->encodeValue($v), $value )) . ''; } } // String par défaut return '' . htmlspecialchars((string)$value) . ''; } // ── Décodage XML-RPC ────────────────────────────────────────────────── protected function decodeXmlrpcResponse(string $xml): mixed { libxml_use_internal_errors(true); $doc = new \DOMDocument(); $doc->loadXML($xml); $fault = $doc->getElementsByTagName('fault'); if ($fault->length > 0) { $faultData = $this->decodeNode($fault->item(0)->firstChild); Log::error('Odoo XML-RPC fault', $faultData ?? []); return null; } $params = $doc->getElementsByTagName('params'); if ($params->length === 0) return null; $param = $params->item(0)->firstChild; return $this->decodeNode($param->firstChild); // } protected function decodeNode(\DOMNode $node): mixed { if ($node->nodeName === 'value') { $child = $node->firstChild; if (!$child) return $node->textContent; return $this->decodeTypedNode($child); } return $this->decodeTypedNode($node); } protected function decodeTypedNode(\DOMNode $node): mixed { switch ($node->nodeName) { case 'int': case 'i4': return (int) $node->textContent; case 'double': return (float) $node->textContent; case 'boolean': return $node->textContent === '1'; case 'string': return $node->textContent; case 'nil': return null; case 'array': $result = []; foreach ($node->firstChild->childNodes as $dataNode) { // $result[] = $this->decodeNode($dataNode); } return $result; case 'struct': $result = []; foreach ($node->childNodes as $member) { $key = $member->firstChild->textContent; // $value = $this->decodeNode($member->lastChild); // $result[$key] = $value; } return $result; default: return $node->textContent; } } } NOTA : Le code ci-dessus est une réécriture propre de l'OdooClient original. L'encodage XML-RPC manuel est nécessaire car PHP ne dispose plus de l'extension xmlrpc par défaut depuis PHP 8.0. ──────────────────────────────────────────────────────────── 4.3 MIGRATION BASE DE DONNÉES (CACHE) ──────────────────────────────────────────────────────────── Créer le fichier : database/migrations/YYYY_MM_DD_000000_create_cached_jobs_table.php id(); $table->integer('odoo_id')->unique(); $table->string('name'); $table->json('department_id')->nullable(); $table->longText('job_description')->nullable(); $table->longText('description')->nullable(); $table->integer('no_of_recruitment')->default(1); $table->json('address_id')->nullable(); $table->string('publication_start_date')->nullable(); $table->string('publication_end_date')->nullable(); $table->timestamp('synced_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('cached_jobs'); } }; Puis exécuter : php artisan migrate ──────────────────────────────────────────────────────────── 4.4 MODÈLE CACHEDJOB ──────────────────────────────────────────────────────────── Créer le fichier : app/Models/CachedJob.php 'array', 'address_id' => 'array', 'synced_at' => 'datetime', ]; } ──────────────────────────────────────────────────────────── 4.5 JOBCONTROLLER (API) ──────────────────────────────────────────────────────────── Créer le fichier : app/Http/Controllers/Api/JobController.php get(); return response()->json($jobs); } // GET /api/jobs/{id} — une offre par son odoo_id public function show(int $id): JsonResponse { $job = CachedJob::where('odoo_id', $id)->firstOrFail(); return response()->json($job); } // POST /api/jobs/{id}/apply — soumettre une candidature public function apply(Request $request, int $id): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email|max:255', 'phone' => 'nullable|string|max:50', 'cover_letter' => 'nullable|string|max:5000', 'cv' => 'nullable|file|mimes:pdf,doc,docx|max:10240', // 10 Mo ]); $cvBase64 = null; $cvFilename = null; if ($request->hasFile('cv') && $request->file('cv')->isValid()) { $file = $request->file('cv'); $cvBase64 = base64_encode(file_get_contents($file->getRealPath())); $cvFilename = $file->getClientOriginalName(); } try { $odoo = new OdooClient(); $applicantId = $odoo->submitApplication( $id, $validated['name'], $validated['email'], $validated['phone'] ?? null, $validated['cover_letter'] ?? null, $cvBase64, $cvFilename ); return response()->json(['success' => true, 'applicant_id' => $applicantId]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => 'Erreur lors de l\'envoi de la candidature.', ], 500); } } // GET /api/jobs/debug — diagnostic (ne pas exposer en production) public function debug(): JsonResponse { $odoo = new OdooClient(); $jobs = $odoo->getPublishedJobs(); return response()->json(['count' => count($jobs), 'jobs' => $jobs]); } } ──────────────────────────────────────────────────────────── 4.6 ROUTES API ──────────────────────────────────────────────────────────── Dans routes/api.php, ajouter : use App\Http\Controllers\Api\JobController; Route::get('/jobs', [JobController::class, 'index']); Route::get('/jobs/debug', [JobController::class, 'debug']); // optionnel Route::get('/jobs/{id}', [JobController::class, 'show']); Route::post('/jobs/{id}/apply',[JobController::class, 'apply']); ATTENTION : L'ordre est important. La route /debug doit être AVANT /jobs/{id}, sinon Laravel interprétera "debug" comme un {id}. ──────────────────────────────────────────────────────────── 4.7 COMMANDE ARTISAN DE SYNCHRONISATION ──────────────────────────────────────────────────────────── Créer le fichier : app/Console/Commands/SyncOdooJobsCommand.php info('Connexion à Odoo...'); try { $odoo = new OdooClient(); $jobs = $odoo->getPublishedJobs(); } catch (\Exception $e) { $this->error('Connexion Odoo impossible : ' . $e->getMessage()); return Command::FAILURE; } $this->info(count($jobs) . ' offre(s) trouvée(s) sur Odoo.'); // Supprimer du cache les offres qui ne sont plus publiées sur Odoo $odooIds = array_column($jobs, 'id'); $deleted = CachedJob::whereNotIn('odoo_id', $odooIds)->delete(); if ($deleted) $this->line(" → {$deleted} offre(s) supprimée(s) du cache."); // Upsert (insérer ou mettre à jour) foreach ($jobs as $job) { CachedJob::updateOrCreate( ['odoo_id' => $job['id']], [ 'name' => $job['name'], 'department_id' => $job['department_id'] ?: null, 'job_description' => $job['job_description'] ?: null, 'description' => $job['description'] ?: null, 'no_of_recruitment' => $job['no_of_recruitment'] ?? 1, 'address_id' => $job['address_id'] ?: null, 'publication_start_date' => $job['publication_start_date'] ?: null, 'publication_end_date' => $job['publication_end_date'] ?: null, 'synced_at' => Carbon::now(), ] ); } $this->info('Synchronisation terminée.'); return Command::SUCCESS; } } Pour tester manuellement : php artisan odoo:sync-jobs ──────────────────────────────────────────────────────────── 4.8 PLANIFICATION (SCHEDULER) ──────────────────────────────────────────────────────────── Dans app/Console/Kernel.php, dans la méthode schedule() : use App\Console\Commands\SyncOdooJobsCommand; protected function schedule(Schedule $schedule): void { // Synchronisation tous les jours à minuit $schedule->command(SyncOdooJobsCommand::class)->dailyAt('00:00'); } Pour que le scheduler fonctionne en production (Linux/cron), ajouter : * * * * * cd /chemin/vers/laravel && php artisan schedule:run >> /dev/null 2>&1 Pour tester le scheduler en local : php artisan schedule:run ──────────────────────────────────────────────────────────── 4.9 CORS ──────────────────────────────────────────────────────────── Dans config/cors.php, vérifier : 'paths' => ['api/*', 'admin/*'], 'origins' => explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')), 'methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'headers' => ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With'], Ou directement dans .env : CORS_ALLOWED_ORIGINS=http://localhost:3000,http://VOTRE_IP:3000 Après chaque modification de config : php artisan config:clear php artisan cache:clear ================================================================================ 5. FRONTEND NEXT.JS — ÉTAPE PAR ÉTAPE ================================================================================ ──────────────────────────────────────────────────────────── 5.1 VARIABLE D'ENVIRONNEMENT FRONTEND ──────────────────────────────────────────────────────────── Dans .env.local : NEXT_PUBLIC_API_URL=http://VOTRE_IP:8000 Cette variable est utilisée dans la page emploi pour construire les URL d'API. ──────────────────────────────────────────────────────────── 5.2 INTERFACE TYPESCRIPT ODOO JOB ──────────────────────────────────────────────────────────── À placer en haut de la page emploi (ou dans un fichier types/odoo.ts) : interface OdooJob { id: number name: string department_id: [number, string] | false // Odoo retourne soit [id, label] soit false job_description: string | false description: string | false no_of_recruitment: number address_id: [number, string] | false publication_start_date: string | false publication_end_date: string | false } IMPORTANT : Dans Odoo, les champs Many2one (ex : department_id) retournent soit - Un tableau [id, "Nom du département"] - false (si non renseigné) Jamais null — c'est un comportement Odoo spécifique. ──────────────────────────────────────────────────────────── 5.3 PAGE /emploi ──────────────────────────────────────────────────────────── Créer le fichier : app/[locale]/emploi/page.tsx "use client" import { useState, useEffect, useRef } from "react" import { useTranslations, useLocale } from "next-intl" import { motion, AnimatePresence } from "framer-motion" import { ChevronDown, MapPin, Users, Calendar, Upload, Send, CheckCircle } from "lucide-react" const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000" interface OdooJob { id: number name: string department_id: [number, string] | false job_description: string | false description: string | false no_of_recruitment: number address_id: [number, string] | false publication_start_date: string | false publication_end_date: string | false } // ── Panneau formulaire de candidature ───────────────────────────────────── function ApplyFormPanel({ jobId, jobName, onClose, }: { jobId: number jobName: string onClose: () => void }) { const [form, setForm] = useState({ name: "", email: "", phone: "", coverLetter: "" }) const [cv, setCv] = useState(null) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) const [success, setSuccess] = useState(false) const [serverError, setServerError] = useState("") const fileRef = useRef(null) const validate = () => { const e: Record = {} if (!form.name.trim()) e.name = "Le nom est requis" if (!form.email.trim()) e.email = "L'email est requis" else if (!/\S+@\S+\.\S+/.test(form.email)) e.email = "Email invalide" if (cv && cv.size > 10 * 1024 * 1024) e.cv = "Le CV ne doit pas dépasser 10 Mo" setErrors(e) return Object.keys(e).length === 0 } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!validate()) return setSubmitting(true) setServerError("") const data = new FormData() data.append("name", form.name) data.append("email", form.email) if (form.phone) data.append("phone", form.phone) if (form.coverLetter) data.append("cover_letter", form.coverLetter) if (cv) data.append("cv", cv) try { const res = await fetch(`${API_URL}/api/jobs/${jobId}/apply`, { method: "POST", headers: { Accept: "application/json" }, body: data, }) const json = await res.json() if (json.success) { setSuccess(true) setTimeout(() => { setSuccess(false); onClose() }, 5000) } else { setServerError(json.message ?? "Erreur inconnue") } } catch { setServerError("Impossible de joindre le serveur.") } finally { setSubmitting(false) } } if (success) return (
Candidature envoyée avec succès ! Nous vous contacterons prochainement.
) return (

Postuler — {jobName}

{serverError && (
{serverError}
)}
setForm(f => ({ ...f, name: e.target.value }))} className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500" /> {errors.name &&

{errors.name}

}
setForm(f => ({ ...f, email: e.target.value }))} className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500" /> {errors.email &&

{errors.email}

}
setForm(f => ({ ...f, phone: e.target.value }))} className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500" />
setCv(e.target.files?.[0] ?? null)} className="hidden" /> {errors.cv &&

{errors.cv}

}