route Express d’exemple qui reçoit le JSON et crée un Devis Odoo (REST) à partir de ces champs.
Voici une route Express clé-en-main qui reçoit le JSON mission de votre bot et crée un Devis Odoo (sale.order), avec création/recyclage du contact si besoin, puis une ligne “Prestation de transport” au tarif proposé.
1) Pré-requis (variables d’environnement)
# .env ODOO_URL="https://votre-instance.odoo.com" ODOO_DB="votre_db" ODOO_LOGIN="admin@exemple.com" # utilisateur Odoo ODOO_API_KEY="xxxxxxxx" # clé API Odoo (ou mot de passe) ODOO_PRODUCT_ID="42" # ID d'un produit Service "Transport"
⚠️ Conseillé : créez dans Odoo un produit service “Transport” (type: Service, prix = 0) et notez son ID pour ODOO_PRODUCT_ID.
Sinon, la route ci-dessous peut créer un produit si l’ID n’est pas fourni (optionnel dans le code).
2) Route Express (Node.js)
odooQuoteRoute.js
import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, ODOO_PRODUCT_ID } = process.env; // --- Helpers JSON-RPC Odoo --- async function odooAuthenticate() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const data = await r.json(); if (!data?.result?.session_id) { throw new Error("Échec d'authentification Odoo"); } return data.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const data = await r.json(); if (data.error) { throw new Error(`Odoo error: ${JSON.stringify(data.error)}`); } return data.result; } // Parse ultra-simple d’adresse (optionnel, on garde la chaîne complète dans note) function splitAddress(addr = "") { // Vous pouvez remplacer par un vrai parseur si nécessaire return { street: addr, city: "", zip: "", country_id: null }; } // --- La route qui reçoit le JSON mission et crée un Devis --- router.post("/odoo/quote", async (req, res) => { try { // JSON attendu (ex. tool_call du bot) // { // nom, tel, adresse_A, adresse_B, objet, // dimensions: { longueur, largeur, hauteur }, // poids, contrainte, option, tarif // } const mission = req.body || {}; const { nom, tel, adresse_A, adresse_B, objet, dimensions, poids, contrainte, option, tarif } = mission; if (!nom || !tel || !adresse_A || !adresse_B || !objet) { return res.status(400).json({ error: "Champs requis manquants (nom, tel, adresse_A, adresse_B, objet)" }); } const sessionId = await odooAuthenticate(); // 1) Trouver ou créer le contact (res.partner) // Recherche prioritaire par téléphone const partners = await odooCall(sessionId, { model: "res.partner", method: "search_read", args: [[["phone", "ilike", tel]], ["id", "name", "phone"], 0, 1] }); let partnerId; if (partners?.length) { partnerId = partners[0].id; } else { const aA = splitAddress(adresse_A); // On crée le contact (avec le tel; on utilisera l’adresse_A comme “street” par défaut) partnerId = await odooCall(sessionId, { model: "res.partner", method: "create", args: [{ name: nom, phone: tel, street: aA.street, city: aA.city, zip: aA.zip }] }); } // 2) Créer le Devis (sale.order) // On stocke toutes les infos logistiques dans le champ "note" const note = [ `OBJET: ${objet}`, dimensions ? `DIMENSIONS: ${dimensions.longueur ?? "?"} x ${dimensions.largeur ?? "?"} x ${dimensions.hauteur ?? "?"} cm` : "", poids ? `POIDS: ${poids} kg` : "", `ADRESSE A (Départ): ${adresse_A}`, `ADRESSE B (Arrivée): ${adresse_B}`, contrainte ? `CONTRAINTES: ${contrainte}` : "", option ? `OPTION: ${option}` : "" ].filter(Boolean).join("\n"); const orderId = await odooCall(sessionId, { model: "sale.order", method: "create", args: [{ partner_id: partnerId, client_order_ref: objet?.toString().slice(0, 64), // référence courte note }] }); // 3) Ajouter la ligne de devis (sale.order.line) let productId = ODOO_PRODUCT_ID ? parseInt(ODOO_PRODUCT_ID, 10) : null; if (!productId) { // (Optionnel) créer un produit service "Transport" si non fourni productId = await odooCall(sessionId, { model: "product.product", method: "create", args: [{ name: "Prestation de transport", type: "service", sale_ok: true, list_price: 0.0 }] }); } const lineName = `Transport ${objet} (${option ?? "standard"})`; const price = Number.isFinite(tarif) ? Number(tarif) : 0; await odooCall(sessionId, { model: "sale.order.line", method: "create", args: [{ order_id: orderId, product_id: productId, name: lineName, product_uom_qty: 1, price_unit: price }] }); // 4) Construire un lien direct vers le Devis const quoteUrl = `${ODOO_URL}/web#id=${orderId}&model=sale.order&view_type=form`; return res.status(201).json({ ok: true, order_id: orderId, partner_id: partnerId, quote_url: quoteUrl }); } catch (err) { console.error(err); return res.status(500).json({ error: err.message || "Erreur interne" }); } }); export default router;
3) Intégration serveur
server.js
import express from "express"; import dotenv from "dotenv"; import odooQuoteRoute from "./odooQuoteRoute.js"; dotenv.config(); const app = express(); app.use(express.json()); // pour lire le JSON // Route qui reçoit le JSON mission depuis votre front/bot app.use("/api", odooQuoteRoute); app.listen(4000, () => { console.log("API prête sur http://localhost:4000"); });
4) Exemple d’appel (depuis votre front/bot)
curl -X POST http://localhost:4000/api/odoo/quote \ -H "Content-Type: application/json" \ -d '{ "nom": "Jean Martin", "tel": "0612345678", "adresse_A": "23 rue Lafayette, 75009 Paris", "adresse_B": "12 bd de la République, 93100 Montreuil", "objet": "Canapé 3 places", "dimensions": { "longueur": 200, "largeur": 90, "hauteur": 80 }, "poids": 40, "contrainte": "3e sans ascenseur", "option": "express", "tarif": 140 }'
Réponse attendue :
{ "ok": true, "order_id": 1234, "partner_id": 567, "quote_url": "https://votre-instance.odoo.com/web#id=1234&model=sale.order&view_type=form" }
Bonnes pratiques & variantes
- Validation stricte : ajoutez un schéma (zod/yup) côté route pour verrouiller types/format (tel, tarif numérique, etc.).
- Adresses : si vous avez besoin de champs d’adresse détaillés, créez des champs personnalisés (Studio) ou parsez via un service (ou conservez l’adresse complète en note).
- Tarification auto : calculez tarif côté serveur (distance, volume, étage, urgence) plutôt que de faire confiance au client.
- Statuts / workflow : vous pouvez passer le devis en “Envoyé” (action_quotation_send) ou même confirmer la SO si paiement OK.
- WhatsApp : après création du devis, déclenchez l’envoi d’un lien de paiement et/ou la diffusion mission → freelances via votre connecteur WhatsApp Business (Cloud API).
Si vous voulez, je vous fournis une variante qui :
- calcule le tarif automatiquement (distance + volumétrie),
- ajoute une URL publique du devis (si vous utilisez les portails Odoo),
- pousse une notification WhatsApp au client et aux freelances dans la foulée.
> Variante qui calcule le tarif automatiquement (distance + volumétrie),>
Parfait. Voici la variante “tout-en-un” qui :
- Calcule le tarif automatiquement (distance + volume + étage + express),
- Génère l’URL publique du devis (portail Odoo),
- Envoie des messages WhatsApp au client (récap + lien de paiement/portail) et diffuse la mission aux freelances (bouton “Accepter”).
⚙️ Hypothèses :
– Vous avez le module Portal activé dans Odoo (permet action_get_portal_url).
– Vous disposez d’un numéro WhatsApp Business et d’un token Meta Cloud API.
– Vous avez déjà branché la route précédente (création de devis).
– Ici, on recalcule le tarif côté serveur (fiable). Si un tarif client existe, on l’ignore (sécurité).
0) Variables d’environnement
# .env (complétez avec vos valeurs) WHATSAPP_TOKEN="EAAG...votre_token_meta..." WHATSAPP_PHONE_ID="123456789012345" # phone_number_id WHATSAPP_CLIENT_TEMPLATE="devismod_client" # nom du template client (option A) WHATSAPP_BROADCAST_TEMPLATE="devismod_freelance" # nom du template freelance (option A) FREELANCER_NUMBERS="+33600000001,+33600000002" # liste CSV
Vous pouvez aussi envoyer des messages interactifs sans template (option B ci-dessous).
1) Helpers : distance, volume & tarification
pricing.js
// Haversine (distance à vol d'oiseau) en km export function haversineKm(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = (lat2 - lat1) * Math.PI/180; const dLon = (lon2 - lon1) * Math.PI/180; const a = Math.sin(dLat/2) ** 2 + Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) * Math.sin(dLon/2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } // Volume en m3 à partir de cm export function volumeM3(dim = {}) { const L = (Number(dim.longueur) || 0) / 100; const W = (Number(dim.largeur) || 0) / 100; const H = (Number(dim.hauteur) || 0) / 100; const v = L * W * H; return Number.isFinite(v) ? v : 0; } /** * Tarification simple et lisible : * - Base fixe : 25 € * - Distance (km) : 0,85 €/km * - Volume (m3) : 45 €/m3 * - Poids (>50 kg) : +0,25 €/kg au-delà de 50 * - Étages (sans ascenseur) : +4 €/étage (départ + arrivée si précisé) * - Express : +40 % */ export function computePrice({ distanceKm, volM3, poidsKg = 0, etages = 0, express = false }) { const base = 25; const pDist = (Number(distanceKm) || 0) * 0.85; const pVol = (Number(volM3) || 0) * 45; const extraPoids = Math.max(0, (Number(poidsKg)||0) - 50) * 0.25; const extraEtage = (Number(etages)||0) * 4; let total = base + pDist + pVol + extraPoids + extraEtage; if (express) total *= 1.4; return Math.round(total); // arrondi à l'euro }
🗺️ Géocodage / distance réelle : en prod, remplacez le Haversine par une distance routière (OpenRouteService, Google, etc.) et un géocodage d’adresses → lat/lon. Ici, on propose une version autonome (sans appel externe), avec lat/lon fournis ou un fallback au Haversine si vous stockez des coordonnées dans votre base.
2) Route Express : calcule tarif, crée devis Odoo, récupère portal_url, envoie WhatsApp
odooQuoteFullRoute.js
import express from "express"; import fetch from "node-fetch"; import { haversineKm, volumeM3, computePrice } from "./pricing.js"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, ODOO_PRODUCT_ID, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, WHATSAPP_CLIENT_TEMPLATE, WHATSAPP_BROADCAST_TEMPLATE, FREELANCER_NUMBERS } = process.env; // ------- Odoo JSON-RPC helpers ------- async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } // ------- WhatsApp helpers ------- async function sendWhatsAppTemplate({ to, template, lang = "fr", components = [] }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "template", template: { name: template, language: { code: lang }, components } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WhatsApp template error: ${JSON.stringify(j)}`); return j; } async function sendWhatsAppInteractive({ to, bodyText, footerText, acceptId, declineId, urlLabel, url }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "interactive", interactive: { type: "button", body: { text: bodyText }, footer: footerText ? { text: footerText } : undefined, action: { buttons: [ { type: "reply", reply: { id: acceptId, title: "Accepter" } }, { type: "reply", reply: { id: declineId, title: "Refuser" } }, url ? { type: "url", url: { url, title: urlLabel || "Voir" } } : null ].filter(Boolean) } } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WhatsApp interactive error: ${JSON.stringify(j)}`); return j; } // ------- Route principale ------- // Attend un JSON mission (tool_call) + éventuellement lat/lon pour A et B router.post("/odoo/quote/full", async (req, res) => { try { const mission = req.body || {}; const { nom, tel, adresse_A, adresse_B, objet, dimensions, poids, contrainte, option, // option client (ignorée pour pricing) // facultatif si vous avez déjà géocodé : coords_A, // { lat, lon } coords_B, // { lat, lon } etages_depart = 0, etages_arrivee = 0, express = false // bool force l'option } = mission; if (!nom || !tel || !adresse_A || !adresse_B || !objet) { return res.status(400).json({ error: "Requis: nom, tel, adresse_A, adresse_B, objet" }); } // 1) Calcul distance (fallback Haversine si coords fournies) let distanceKm = 8; // distance par défaut si on ne sait pas if (coords_A?.lat && coords_A?.lon && coords_B?.lat && coords_B?.lon) { distanceKm = haversineKm(coords_A.lat, coords_A.lon, coords_B.lat, coords_B.lon); } const vol = volumeM3(dimensions); const etages = Number(etages_depart||0) + Number(etages_arrivee||0); const isExpress = Boolean(express || (option === "express")); const price = computePrice({ distanceKm, volM3: vol, poidsKg: Number(poids) || 0, etages, express: isExpress }); const sessionId = await odooAuth(); // 2) Contact (res.partner) const partners = await odooCall(sessionId, { model: "res.partner", method: "search_read", args: [[["phone", "ilike", tel]], ["id","name","phone"], 0, 1] }); const partner_id = partners?.length ? partners[0].id : await odooCall(sessionId, { model: "res.partner", method: "create", args: [{ name: nom, phone: tel, street: adresse_A }] }); // 3) Devis (sale.order) const note = [ `OBJET: ${objet}`, dimensions ? `DIMENSIONS: ${dimensions.longueur ?? "?"} x ${dimensions.largeur ?? "?"} x ${dimensions.hauteur ?? "?"} cm` : "", (poids ? `POIDS: ${poids} kg` : ""), `DÉPART: ${adresse_A}`, `ARRIVÉE: ${adresse_B}`, contrainte ? `CONTRAINTES: ${contrainte}` : "", `DISTANCE_ESTIMÉE: ${distanceKm.toFixed(1)} km`, `VOLUME_ESTIMÉ: ${vol.toFixed(2)} m3`, etages ? `ÉTAGES (total): ${etages}` : "", isExpress ? `OPTION: EXPRESS` : `OPTION: STANDARD` ].filter(Boolean).join("\n"); const order_id = await odooCall(sessionId, { model: "sale.order", method: "create", args: [{ partner_id, note, client_order_ref: objet.toString().slice(0,64) }] }); // 4) Ligne de devis let product_id = ODOO_PRODUCT_ID ? parseInt(ODOO_PRODUCT_ID, 10) : null; if (!product_id) { product_id = await odooCall(sessionId, { model: "product.product", method: "create", args: [{ name: "Prestation de transport", type: "service", sale_ok: true, list_price: 0 }] }); } const lineName = `Transport ${objet} (${isExpress ? "express" : "standard"})`; await odooCall(sessionId, { model: "sale.order.line", method: "create", args: [{ order_id, product_id, name: lineName, product_uom_qty: 1, price_unit: price }] }); // 5) URL publique (portal) du devis // Nécessite Portal: action_get_portal_url const portal_url = await odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[order_id]] }); const backoffice_url = `${ODOO_URL}/web#id=${order_id}&model=sale.order&view_type=form`; // 6) WhatsApp → Client (2 options) // A) Avec un **template** pré-validé (idéal prod) if (WHATSAPP_CLIENT_TEMPLATE) { await sendWhatsAppTemplate({ to: tel, template: WHATSAPP_CLIENT_TEMPLATE, lang: "fr", components: [ { type: "body", parameters: [ { type: "text", text: nom }, { type: "text", text: objet }, { type: "text", text: `${Math.round(distanceKm)} km` }, { type: "text", text: `${price} €` }, { type: "text", text: portal_url } ] } ] }); } else { // B) Message interactif (sans template, plus souple pour tests) await sendWhatsAppInteractive({ to: tel, bodyText: `Bonjour ${nom} 👋\nVotre devis est prêt (${objet}).\nEstimation: ${price} € (${isExpress ? "Express" : "Standard"}).\nVoir le devis : ${portal_url}`, footerText: "Merci de votre confiance.", acceptId: `CLIENT_ACCEPT_${order_id}`, declineId: `CLIENT_DECLINE_${order_id}`, urlLabel: "Voir le devis", url: portal_url }); } // 7) Diffusion WhatsApp → Freelances (broadcast en parallèle) const numbers = (FREELANCER_NUMBERS || "").split(",").map(s => s.trim()).filter(Boolean); await Promise.all(numbers.map(num => { // Option A : template if (WHATSAPP_BROADCAST_TEMPLATE) { return sendWhatsAppTemplate({ to: num, template: WHATSAPP_BROADCAST_TEMPLATE, lang: "fr", components: [ { type: "body", parameters: [ { type: "text", text: objet }, { type: "text", text: `${Math.round(distanceKm)} km` }, { type: "text", text: `${(dimensions?.longueur || "?")}x${(dimensions?.largeur || "?")}x${(dimensions?.hauteur || "?")}cm / ${poids || "?"}kg` }, { type: "text", text: `${price} €` }, { type: "text", text: backoffice_url } // pour l’opérateur interne ] } ] }); } // Option B : message interactif const body = `Mission dispo: ${objet}\nTrajet ~${Math.round(distanceKm)} km\nEstimation ${price} €\nDépart: ${adresse_A}\nArrivée: ${adresse_B}`; return sendWhatsAppInteractive({ to: num, bodyText: body, footerText: "Appuyez pour accepter/refuser", acceptId: `FREELANCE_ACCEPT_${order_id}`, declineId: `FREELANCE_DECLINE_${order_id}`, urlLabel: "Détail backoffice", url: backoffice_url }); })); return res.status(201).json({ ok: true, order_id, partner_id, price, option: isExpress ? "express" : "standard", backoffice_url, portal_url }); } catch (e) { console.error(e); return res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
3) Points d’attention & conseils
- Portail Odoo : action_get_portal_url renvoie l’URL sécurisée (avec token). Le client pourra consulter/valider/payer (si paiement en ligne configuré).
- WhatsApp : en prod, privilégiez des templates validés (latence plus faible, meilleure délivrabilité). Les interactives sont parfaites pour du prototypage et les boutons “Accepter/Refuser”.
- Acceptation par un freelance : lorsque vous recevez le webhook WhatsApp (réponse bouton), attribuez la mission au premier qui accepte (créez un assignment dans Odoo ou dans votre app) et notifiez les autres qu’elle n’est plus disponible.
- Tarification : adaptez les coefficients (€/km, €/m3, surcharge express) à votre marché ; vous pouvez faire 2 paliers (urbain vs inter-urbain), zones, péages, etc.
- Distance routière : remplacez le Haversine par OpenRouteService/Google Distance Matrix pour un chiffrage réaliste.
- Qualité des adresses : stockez l’adresse “brute” en note comme ci-dessus et, en parallèle, votre version normalisée (ou champs Studio) si vous avez un parseur d’adresse.
- Sécurité/prix : ignorez tout tarif fourni par le client et recalculez côté serveur (fait ici).
- RGPD : informez l’utilisateur de l’usage de ses données (coordonnées, adresses), durée de rétention, base légale, etc.
Si vous le souhaitez, je peux vous fournir un webhook WhatsApp prêt à l’emploi qui :
- reçoit la réponse bouton du freelance → verrouille la mission pour le premier,
- met à jour le statut dans Odoo,
- notifie le client automatiquement.
ChatGPT peut commettre des erreurs. Il est recommandé de vérifier les informations importantes. Voir les préférences en matière de cookies.
Si vous le souhaitez, je peux vous fournir un webhook WhatsApp prêt à l’emploi qui :
- reçoit la réponse bouton du freelance → verrouille la mission pour le premier,
- met à jour le statut dans Odoo,
- notifie le client automatiquement. > Voir under
Voici un webhook WhatsApp clé-en-main (Node/Express) qui :
- gère la vérification Meta (GET),
- capte les réponses bouton (POST),
- attribue la mission au premier freelance qui accepte,
- met à jour le devis Odoo (note + message),
- notifie : le freelance gagnant, les autres freelances (mission plus dispo) et le client (contact + lien).
Hypothèses
– Les boutons envoyés aux freelances utilisent des IDs FREELANCE_ACCEPT_<order_id> / FREELANCE_DECLINE_<order_id>
– Les boutons côté client utilisent CLIENT_ACCEPT_<order_id> / CLIENT_DECLINE_<order_id>
– Vous avez déjà la route “full” qui a diffusé la mission (cf. échange précédent)
1) Variables d’environnement
# .env VERIFY_TOKEN="un_token_au_choix" # pour la vérification webhook Meta WHATSAPP_TOKEN="EAAG...votre_token_meta..." WHATSAPP_PHONE_ID="123456789012345" # phone_number_id WhatsApp FREELANCER_NUMBERS="+33600000001,+33600000002" # mêmes numéros que pour la diffusion ODOO_URL="https://votre-instance.odoo.com" ODOO_DB="votre_db" ODOO_LOGIN="admin@exemple.com" ODOO_API_KEY="xxxxxxxx"
2) Webhook WhatsApp + Attribution (Express)
whatsappWebhook.js
import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { VERIFY_TOKEN, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, FREELANCER_NUMBERS, ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY } = process.env; // ---------------- ODOO helpers ---------------- async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } async function appendNoteToOrder(sessionId, orderId, text) { const order = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], ["note"]] }); const prev = order?.[0]?.note || ""; const note = (prev ? prev + "\n" : "") + text; await odooCall(sessionId, { model: "sale.order", method: "write", args: [[orderId], { note }] }); } async function messageChatter(sessionId, orderId, body) { // poste un message dans le chatter (mail.message) lié au sale.order return odooCall(sessionId, { model: "mail.message", method: "create", args: [{ model: "sale.order", res_id: orderId, body // HTML OK }] }); } async function getOrderPortalUrl(sessionId, orderId) { return odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[orderId]] }); } async function getOrderInfo(sessionId, orderId) { const [o] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], ["id","name","partner_id","note","client_order_ref"]] }); return o; } async function getPartnerPhone(sessionId, partnerId) { const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["phone","name"]] }); return p; // { phone, name } } // ---------------- WhatsApp helpers ---------------- async function sendWhatsAppText({ to, text }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "text", text: { preview_url: false, body: text } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WhatsApp send error: ${JSON.stringify(j)}`); return j; } // ---------------- Attribution (mémoire process) ---------------- // Map<order_id, { phone: "+33...", at: timestamp }> const assignments = new Map(); // Liste freelances (pour notifier les perdants) const FREELANCERS = (FREELANCER_NUMBERS || "").split(",").map(s => s.trim()).filter(Boolean); // ---------------- Verif GET (Meta) ---------------- router.get("/webhooks/whatsapp", (req, res) => { const mode = req.query["hub.mode"]; const token = req.query["hub.verify_token"]; const challenge = req.query["hub.challenge"]; if (mode === "subscribe" && token === VERIFY_TOKEN) { return res.status(200).send(challenge); } return res.sendStatus(403); }); // ---------------- Réception POST ---------------- router.post("/webhooks/whatsapp", async (req, res) => { try { const entry = req.body?.entry?.[0]; const change = entry?.changes?.[0]; const msg = change?.value?.messages?.[0]; // On gère uniquement les réponses "button" / "interactive" const from = msg?.from; // ex: "33612345678" (sans +) const type = msg?.type; if (!msg || !from) { return res.sendStatus(200); // No-op } // Récupère l'ID du bouton (reply.id) let replyId = null; if (type === "interactive" && msg.interactive?.type === "button_reply") { replyId = msg.interactive.button_reply?.id; // ex: FREELANCE_ACCEPT_1234 } else if (type === "button") { replyId = msg.button?.payload; } if (!replyId) { // Pas un bouton → on ignore gentiment return res.sendStatus(200); } // Parse l'action et l'order_id // Patterns attendus : // FREELANCE_ACCEPT_<order_id> // FREELANCE_DECLINE_<order_id> // CLIENT_ACCEPT_<order_id> // CLIENT_DECLINE_<order_id> const m = replyId.match(/^(FREELANCE|CLIENT)_(ACCEPT|DECLINE)_(\d+)$/i); if (!m) return res.sendStatus(200); const role = m[1].toUpperCase(); // FREELANCE | CLIENT const action = m[2].toUpperCase(); // ACCEPT | DECLINE const orderId = parseInt(m[3], 10); const fromPhone = from.startsWith("+") ? from : ("+" + from); const sessionId = await odooAuth(); const order = await getOrderInfo(sessionId, orderId); // ——— CLIENT ——— if (role === "CLIENT") { if (action === "ACCEPT") { await messageChatter(sessionId, orderId, `<p>Client a accepté le devis via WhatsApp.</p>`); const portal = await getOrderPortalUrl(sessionId, orderId); await sendWhatsAppText({ to: fromPhone, text: `Merci 🙏 Votre devis est confirmé.\nLien: ${portal}\nNous vous informons dès qu'un livreur est attribué.` }); } else { await messageChatter(sessionId, orderId, `<p>Client a refusé le devis via WhatsApp.</p>`); await sendWhatsAppText({ to: fromPhone, text: `Nous avons bien noté votre refus. Dites-moi si vous souhaitez une autre option (standard/express) ou modifier les infos.` }); } return res.sendStatus(200); } // ——— FREELANCE ——— if (role === "FREELANCE") { if (action === "ACCEPT") { // First-come-first-served : si déjà attribué → on informe que c'est trop tard if (assignments.has(orderId)) { await sendWhatsAppText({ to: fromPhone, text: `Désolé, la mission #${orderId} est déjà attribuée. À bientôt pour la prochaine.` }); return res.sendStatus(200); } // Attribuer assignments.set(orderId, { phone: fromPhone, at: Date.now() }); // Noter dans Odoo await appendNoteToOrder(sessionId, orderId, `ASSIGNATION: freelance ${fromPhone} (premier acceptant).`); await messageChatter(sessionId, orderId, `<p>Mission attribuée au freelance <b>${fromPhone}</b>.</p>`); // Récupérer téléphone + nom client pour le message const partnerId = Array.isArray(order.partner_id) ? order.partner_id[0] : order.partner_id; const partner = await getPartnerPhone(sessionId, partnerId); const clientPhone = partner?.phone; const clientName = partner?.name || "Client"; // WhatsApp → freelance gagnant await sendWhatsAppText({ to: fromPhone, text: `✅ Mission #${orderId} acceptée. Coordonnées client: ${clientName} (${clientPhone || "N/A"}).\nMerci de prendre contact et de confirmer l'heure de prise en charge.` }); // WhatsApp → autres freelances const losers = FREELANCERS.filter(n => n !== fromPhone); await Promise.all(losers.map(num => sendWhatsAppText({ to: num, text: `Mission #${orderId} n'est plus disponible (déjà attribuée). Merci !` }))); // WhatsApp → client (assignation) const portal = await getOrderPortalUrl(sessionId, orderId); await sendWhatsAppText({ to: clientPhone, text: `Bonne nouvelle 🎉 Un livreur a été attribué pour votre mission #${orderId}.\nIl vous contactera au plus vite.\nSuivi / devis : ${portal}` }); return res.sendStatus(200); } else { // Refus freelance await sendWhatsAppText({ to: fromPhone, text: `Merci, nous notons votre indisponibilité pour la mission #${orderId}.` }); await messageChatter(sessionId, orderId, `<p>Un freelance a décliné la mission (#${fromPhone}).</p>`); return res.sendStatus(200); } } return res.sendStatus(200); } catch (e) { console.error("Webhook error:", e); return res.sendStatus(200); // éviter les retries en boucle } }); export default router;
3) Montage serveur
// server.js import express from "express"; import dotenv from "dotenv"; import whatsappWebhook from "./whatsappWebhook.js"; dotenv.config(); const app = express(); app.use(express.json({ limit: "2mb" })); // Webhook public pour Meta app.use("/", whatsappWebhook); app.listen(3001, () => { console.log("Webhook WhatsApp sur http://localhost:3001"); });
4) Ce que fait le webhook
- GET /webhooks/whatsapp : répond au challenge Meta (configurer l’URL publique + VERIFY_TOKEN).
-
POST /webhooks/whatsapp :
- lit interactive.button_reply.id, ex. FREELANCE_ACCEPT_1234,
-
si freelance ACCEPT :
- verrou first-come en mémoire (assignments),
- met à jour le devis Odoo (note + message),
- notifie le freelance gagnant (coordonnées client), préviens les autres,
- notifie le client (livreur attribué + lien portail).
- si freelance DECLINE : message + trace dans Odoo.
- si client ACCEPT/DECLINE : trace dans Odoo + message au client.
5) Conseils prod (rapides)
- Remplacez la Map assignments par un verrou distribué (Redis) pour éviter les courses si vous avez plusieurs instances.
- Sécurisez les IDs de boutons (UUID + order_id) pour éviter l’usurpation.
- Ajoutez un TTL d’assignation (ex. 10–15 min) si le livreur ne se manifeste pas → réouverture mission.
- Côté Odoo, vous pouvez créer un champ Studio (ex. x_freelancer_phone) pour stocker l’assignation proprement.
- Gérez le webhook WhatsApp de réception de messages texte (le freelance peut envoyer une ETA, que vous poussez au client).
Souhaitez-vous que je vous fournisse aussi la route de “réouverture de mission” (si le freelance se désiste) + re-broadcast WhatsApp automatique ?
a route de “réouverture de mission” (si le freelance se désiste) + re-broadcast WhatsApp automatique ? > Voir under
Parfait. Voici une route Express “Réouverture de mission” qui :
- annule l’assignation courante,
- journalise dans Odoo (note + message),
- re-broadcast automatique WhatsApp aux freelances (avec option d’exclure le freelance désisté),
- gère l’idempotence et un cooldown anti-spam.
Elle s’appuie sur les helpers déjà fournis (Odoo JSON-RPC + WhatsApp) et la Map assignments vue dans le webhook.
1) Route /missions/reopen (Express)
reopenRoute.js
import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, FREELANCER_NUMBERS } = process.env; // ------- Odoo helpers (copiez/importeZ ceux déjà écrits) ------- async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } async function appendNoteToOrder(sessionId, orderId, text) { const [order] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], ["note"]] }); const prev = order?.note || ""; const note = (prev ? prev + "\n" : "") + text; await odooCall(sessionId, { model: "sale.order", method: "write", args: [[orderId], { note }] }); } async function messageChatter(sessionId, orderId, bodyHtml) { return odooCall(sessionId, { model: "mail.message", method: "create", args: [{ model: "sale.order", res_id: orderId, body: bodyHtml }] }); } async function getOrder(sessionId, orderId) { const [o] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], ["id","name","partner_id","note","client_order_ref"]] }); return o; } async function getPartner(sessionId, partnerId) { const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["name","phone","mobile","email"]] }); return p; } async function getBackofficeUrl(orderId) { return `${ODOO_URL}/web#id=${orderId}&model=sale.order&view_type=form`; } async function getPortalUrl(sessionId, orderId) { return odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[orderId]] }); } // ------- WhatsApp helpers (text & interactif) ------- async function sendWhatsAppText({ to, text }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "text", text: { body: text, preview_url: false } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WA text error: ${JSON.stringify(j)}`); return j; } async function sendWhatsAppInteractive({ to, bodyText, footerText, acceptId, declineId, url, urlLabel }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "interactive", interactive: { type: "button", body: { text: bodyText }, footer: footerText ? { text: footerText } : undefined, action: { buttons: [ { type: "reply", reply: { id: acceptId, title: "Accepter" } }, { type: "reply", reply: { id: declineId, title: "Refuser" } }, url ? { type: "url", url: { url, title: urlLabel || "Voir" } } : null ].filter(Boolean) } } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WA interactive error: ${JSON.stringify(j)}`); return j; } // ------- Mémoire process : assignations et cooldown ------- /** * assignments: Map<order_id, { phone:"+33...", at:number }> * reopenCooldown: Map<order_id, timestamp>, pour éviter re-broadcast spam */ export const assignments = new Map(); const reopenCooldown = new Map(); const COOLDOWN_MS = 60_000; // 60s anti-spam (ajustez selon besoin) const ALL_FREELANCERS = (FREELANCER_NUMBERS || "").split(",").map(s => s.trim()).filter(Boolean); /** * POST /missions/reopen * body: { * order_id: number (requis), * reason?: string, * exclude?: string[] // ex: ["+336123..."] * rebroadcast_numbers?: string[] // override liste freelances * } */ router.post("/missions/reopen", async (req, res) => { try { const { order_id, reason, exclude = [], rebroadcast_numbers } = req.body || {}; if (!order_id) return res.status(400).json({ error: "order_id requis" }); // Cooldown anti-spam const last = reopenCooldown.get(order_id) || 0; const now = Date.now(); if (now - last < COOLDOWN_MS) { return res.status(429).json({ error: "Trop de réouvertures rapprochées. Réessayez plus tard." }); } reopenCooldown.set(order_id, now); const sessionId = await odooAuth(); // 1) Récup infos Odoo (client, etc.) const order = await getOrder(sessionId, order_id); if (!order) return res.status(404).json({ error: "Devis introuvable" }); const partnerId = Array.isArray(order.partner_id) ? order.partner_id[0] : order.partner_id; const partner = await getPartner(sessionId, partnerId); const clientPhone = partner?.phone || partner?.mobile; // 2) Annuler assignation si existante const prev = assignments.get(order_id); if (prev) { assignments.delete(order_id); await appendNoteToOrder(sessionId, order_id, `RÉOUVERTURE: ancienne assignation ${prev.phone} libérée. Motif: ${reason || "non précisé"}.`); await messageChatter(sessionId, order_id, `<p>Mission réouverte. Ancien livreur: <b>${prev.phone}</b>. Motif: ${reason || "non précisé"}.</p>`); // notifier l'ancien freelance try { await sendWhatsAppText({ to: prev.phone, text: `Info: la mission #${order_id} vous a été retirée / réouverte (motif: ${reason || "non précisé"}).` }); } catch {} } else { await appendNoteToOrder(sessionId, order_id, `RÉOUVERTURE: pas d'assignation active. Motif: ${reason || "non précisé"}.`); await messageChatter(sessionId, order_id, `<p>Mission réouverte (aucune assignation active). Motif: ${reason || "non précisé"}.</p>`); } // 3) Notifier le client (optionnel mais recommandé) if (clientPhone) { try { const portal = await getPortalUrl(sessionId, order_id); await sendWhatsAppText({ to: clientPhone, text: `Info: votre mission #${order_id} est réouverte. Nous recontactons nos livreurs.\nSuivi: ${portal}` }); } catch {} } // 4) Re-broadcast aux freelances const backofficeUrl = await getBackofficeUrl(order_id); const baseList = Array.isArray(rebroadcast_numbers) && rebroadcast_numbers.length ? rebroadcast_numbers : ALL_FREELANCERS; // Exclure ancien et liste "exclude" const excl = new Set([...(exclude || []), prev?.phone].filter(Boolean)); const targets = baseList.filter(n => !excl.has(n)); if (targets.length === 0) { return res.status(200).json({ ok: true, message: "Mission réouverte, mais aucun freelance cible après exclusion." }); } // Construire message (vous pouvez améliorer avec + d'infos) const body = `Mission #${order_id} réouverte 🚚\n` + `Détails: voir backoffice.\n` + `Appuyez pour "Accepter" ou "Refuser".`; await Promise.allSettled(targets.map(to => sendWhatsAppInteractive({ to, bodyText: body, footerText: "Merci !", acceptId: `FREELANCE_ACCEPT_${order_id}`, declineId: `FREELANCE_DECLINE_${order_id}`, url: backofficeUrl, urlLabel: "Backoffice" }) )); return res.status(200).json({ ok: true, rebroadcasted_to: targets, excluded: Array.from(excl) }); } catch (e) { console.error("reopen error:", e); return res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
2) Montage serveur
// server.js import express from "express"; import dotenv from "dotenv"; import reopenRoute from "./reopenRoute.js"; dotenv.config(); const app = express(); app.use(express.json({ limit: "2mb" })); app.use("/api", reopenRoute); app.listen(4002, () => { console.log("Service Réouverture sur http://localhost:4002"); });
3) Exemple d’appel
curl -X POST http://localhost:4002/api/missions/reopen \ -H "Content-Type: application/json" \ -d '{ "order_id": 1234, "reason": "Freelance indisponible / panne", "exclude": ["+33600000001"] // optionnel : lister les numéros à exclure }'
Réponse :
{ "ok": true, "rebroadcasted_to": ["+33600000002", "+33600000003"], "excluded": ["+33600000001","+33600000004"] // par ex. l’ancien assigné + exclu manuel }
Bonnes pratiques (prod)
- Verrou distribué (Redis) pour assignments et reopenCooldown si vous avez plusieurs pods.
- Politique d’éligibilité : filtrez par zone géo, gabarit, historique qualité, etc., au lieu d’un broadcast “aveugle”.
- Retry & DLQ : journalisez les envois WhatsApp (réussites/erreurs), prévoyez une file de retry.
- Horaires : évitez les envois la nuit → mettez des fenêtres horaires.
- Règle d’or : tout prix et état restent source-de-vérité côté serveur/Odoo, jamais côté client.
Vous voulez que je vous ajoute aussi la route d’annulation (fermeture définitive, notification client + freelances) ?
ChatGPT peut commettre des erreurs. Il est recommandé de vérifier les informations importantes. Voir les préférences en matière de cookies.
la route d’annulation (fermeture définitive, notification client + freelances) ? > Voir udner
Voici la route d’annulation définitive de mission qui :
- annule le devis/commande côté Odoo (action native action_cancel),
- journalise (note + message chatter),
- notifie le client et tous les freelances (avec message spécifique au freelance éventuellement déjà assigné),
- nettoie l’assignation en mémoire,
- gère l’idempotence (si déjà annulé → OK silencieux).
Elle réutilise les mêmes helpers que vos routes précédentes (Odoo JSON-RPC, WhatsApp, map assignments).
1) Route /missions/cancel (Express)
cancelRoute.js
import express from "express"; import fetch from "node-fetch"; // 👉 Si vous avez déjà ces helpers dans des fichiers séparés, importez-les. // Ici on les redéclare pour que le fichier soit autonome. const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, FREELANCER_NUMBERS } = process.env; // ---- Mémoire process partagée (importeZ la Map si déjà déclarée ailleurs) export const assignments = new Map(); // Map<order_id, { phone:"+33...", at:number }> // ---------- Odoo helpers ---------- async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } async function readOrder(sessionId, orderId, fields = ["id", "name", "state", "partner_id", "note"]) { const [o] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], fields] }); return o; } async function appendNote(sessionId, orderId, text) { const order = await readOrder(sessionId, orderId, ["note"]); const prev = order?.note || ""; const note = (prev ? prev + "\n" : "") + text; await odooCall(sessionId, { model: "sale.order", method: "write", args: [[orderId], { note }] }); } async function chatter(sessionId, orderId, html) { return odooCall(sessionId, { model: "mail.message", method: "create", args: [{ model: "sale.order", res_id: orderId, body: html }] }); } async function getPartner(sessionId, partnerId) { const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["name","phone","mobile","email"]] }); return p; } async function getPortalUrl(sessionId, orderId) { return odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[orderId]] }); } // ---------- WhatsApp helpers ---------- async function sendWhatsAppText({ to, text }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "text", text: { body: text, preview_url: false } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WA send error: ${JSON.stringify(j)}`); return j; } // ---------- Route d’annulation ---------- // POST /missions/cancel // body: { order_id: number (requis), reason?: string, notify_freelancers?: boolean (def true), notify_client?: boolean (def true) } router.post("/missions/cancel", async (req, res) => { try { const { order_id, reason, notify_freelancers = true, notify_client = true } = req.body || {}; if (!order_id) return res.status(400).json({ error: "order_id requis" }); const sessionId = await odooAuth(); const order = await readOrder(sessionId, order_id, ["id","name","state","partner_id","note"]); if (!order) return res.status(404).json({ error: "Devis introuvable" }); const partnerId = Array.isArray(order.partner_id) ? order.partner_id[0] : order.partner_id; const partner = await getPartner(sessionId, partnerId); const clientPhone = partner?.phone || partner?.mobile; const reasonTxt = reason || "non précisé"; // Idempotence: si déjà annulé → on trace et on continue notifications const alreadyCancelled = (order.state === "cancel"); if (!alreadyCancelled) { // action_cancel est l’action native d’annulation await odooCall(sessionId, { model: "sale.order", method: "action_cancel", args: [[order_id]] }); await appendNote(sessionId, order_id, `ANNULATION DÉFINITIVE: motif = ${reasonTxt}.`); await chatter(sessionId, order_id, `<p>Commande/Devis <b>annulé</b>. Motif: ${reasonTxt}.</p>`); } else { await chatter(sessionId, order_id, `<p>Re-ANNULATION (idempotent): l'ordre est déjà à l'état <i>cancel</i>. Motif: ${reasonTxt}.</p>`); } // Nettoyage de l’assignation en mémoire const prevAssign = assignments.get(order_id); if (prevAssign) assignments.delete(order_id); // Notifications // 1) Client if (notify_client && clientPhone) { const portal = await getPortalUrl(sessionId, order_id); // le lien restera consultable (selon droits/état) try { await sendWhatsAppText({ to: clientPhone, text: `Information: votre mission #${order_id} a été annulée (motif: ${reasonTxt}).\nLien: ${portal}` }); } catch {} } // 2) Freelances if (notify_freelancers) { const all = (FREELANCER_NUMBERS || "").split(",").map(s => s.trim()).filter(Boolean); await Promise.allSettled(all.map(num => sendWhatsAppText({ to: num, text: `Info: la mission #${order_id} a été annulée. Merci de votre disponibilité !` }))); // Message spécifique à l'assigné (si existait) if (prevAssign?.phone) { try { await sendWhatsAppText({ to: prevAssign.phone, text: `Info: la mission #${order_id} qui vous était attribuée a été annulée (motif: ${reasonTxt}).` }); } catch {} } } return res.status(200).json({ ok: true, order_id, cancelled: !alreadyCancelled, message: alreadyCancelled ? "Déjà annulé (idempotent), notifications envoyées" : "Annulé et notifications envoyées" }); } catch (e) { console.error("cancel error:", e); return res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
2) Montage serveur
// server.js import express from "express"; import dotenv from "dotenv"; import cancelRoute from "./cancelRoute.js"; dotenv.config(); const app = express(); app.use(express.json({ limit: "2mb" })); app.use("/api", cancelRoute); app.listen(4003, () => { console.log("Service Annulation sur http://localhost:4003"); });
3) Exemple d’appel
curl -X POST http://localhost:4003/api/missions/cancel \ -H "Content-Type: application/json" \ -d '{ "order_id": 1234, "reason": "Client a changé de date", "notify_freelancers": true, "notify_client": true }'
Réponse :
{ "ok": true, "order_id": 1234, "cancelled": true, "message": "Annulé et notifications envoyées" }
Bonnes pratiques (prod)
- Statut côté Odoo : si vous avez un flux avancé, pensez à créer un état métier (champ Studio, ex. x_mission_status = 'cancelled') pour vos dashboards logistiques, distinct de state.
- Remboursement / paiement : si vous avez des paiements en ligne, ajoutez le workflow de remboursement (capture/void/refund) et tracez l’ID de paiement dans note.
- Journalisation : logguez les envois WhatsApp (succès/erreurs) et stockez un raison d’annulation codifiée (enum).
- Sécurité : restreignez cette route (JWT/API key), et mettez des rôles (seuls opérateurs autorisés).
- Idempotence stricte : vous pouvez ajouter un cache Redis pour marquer un order_id comme “cancel_processed” et ignorer les doubles POST courts.
Souhaitez-vous que j’ajoute une route “reporter la mission” (replanning avec nouvelle date/heure + ping WhatsApp coordonné) ?
ChatGPT peut commettre des erreurs. Il est recommandé de vérifier les informations importantes. Voir les préférences en matière de cookies.
une route “reporter la mission” (replanning avec nouvelle date/heure + ping WhatsApp coordonné) ?
Voici la route “reporter la mission” (replanning) qui :
- met à jour la date/heure prévue côté Odoo (champ natif commitment_date + journalisation dans note),
- prévient le client (WhatsApp) avec le nouveau créneau,
- libère l’assignation en cours (optionnel) et re-broadcast aux freelances pour reconfirmation.
Hypothèses
– Vous avez déjà les helpers Odoo et WhatsApp vus plus haut.
– Les champs Studio optionnels (ex. x_pickup_datetime, x_delivery_datetime) peuvent exister : on tente d’écrire, et si ça échoue on tombe en gracieux (on loggue dans la note).
– Le re-broadcast réutilise le pattern de boutons FREELANCE_ACCEPT_<order_id> / FREELANCE_DECLINE_<order_id>.
1) Route /missions/reschedule (Express)
rescheduleRoute.js
import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID, FREELANCER_NUMBERS } = process.env; // ---- Mémoire d'assignations (partagée avec le webhook) ---- export const assignments = new Map(); // Map<order_id, { phone:"+33...", at:number }> // ---------------- Odoo helpers ---------------- async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } async function readOrder(sessionId, orderId, fields=["id","name","partner_id","note"]) { const [o] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[orderId], fields] }); return o; } async function getPartner(sessionId, partnerId) { const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["name","phone","mobile","email"]] }); return p; } async function appendNote(sessionId, orderId, text) { const order = await readOrder(sessionId, orderId, ["note"]); const prev = order?.note || ""; const note = (prev ? prev + "\n" : "") + text; await odooCall(sessionId, { model: "sale.order", method: "write", args: [[orderId], { note }] }); } async function chatter(sessionId, orderId, html) { return odooCall(sessionId, { model: "mail.message", method: "create", args: [{ model: "sale.order", res_id: orderId, body: html }] }); } async function getPortalUrl(sessionId, orderId) { return odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[orderId]] }); } async function backofficeUrl(orderId) { return `${ODOO_URL}/web#id=${orderId}&model=sale.order&view_type=form`; } // ---------------- WhatsApp helpers ---------------- async function sendWhatsAppText({ to, text }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "text", text: { body: text, preview_url: false } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WA send error: ${JSON.stringify(j)}`); return j; } async function sendWhatsAppInteractive({ to, bodyText, footerText, acceptId, declineId, url, urlLabel }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "interactive", interactive: { type: "button", body: { text: bodyText }, footer: footerText ? { text: footerText } : undefined, action: { buttons: [ { type: "reply", reply: { id: acceptId, title: "Accepter" } }, { type: "reply", reply: { id: declineId, title: "Refuser" } }, url ? { type: "url", url: { url, title: urlLabel || "Voir" } } : null ].filter(Boolean) } } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WA interactive error: ${JSON.stringify(j)}`); return j; } // ---------------- Route de replanification ---------------- /** * POST /missions/reschedule * body: { * order_id: number (requis), * pickup_datetime?: string ISO, // ex: "2025-10-01T14:00:00" * delivery_datetime?: string ISO, // ex: "2025-10-01T18:00:00" * window?: string, // ex: "14:00–16:00" * reason?: string, * release_assignment?: boolean, // def: true → libère le livreur actuel * rebroadcast?: boolean, // def: true → rebroadcast aux freelances * rebroadcast_numbers?: string[] // override liste par défaut * } */ router.post("/missions/reschedule", async (req, res) => { try { const { order_id, pickup_datetime, delivery_datetime, window, reason, release_assignment = true, rebroadcast = true, rebroadcast_numbers } = req.body || {}; if (!order_id) return res.status(400).json({ error: "order_id requis" }); const sessionId = await odooAuth(); const order = await readOrder(sessionId, order_id, ["id","name","partner_id","note"]); if (!order) return res.status(404).json({ error: "Devis introuvable" }); const partnerId = Array.isArray(order.partner_id) ? order.partner_id[0] : order.partner_id; const partner = await getPartner(sessionId, partnerId); const clientPhone = partner?.phone || partner?.mobile; // 1) Écriture Odoo : engagement (commitment_date) + champs Studio si dispos const writeVals = {}; if (pickup_datetime) writeVals.commitment_date = pickup_datetime; // natif sale.order // Champs Studio (si existent) — on tente sans casser if (pickup_datetime) writeVals["x_pickup_datetime"] = pickup_datetime; if (delivery_datetime) writeVals["x_delivery_datetime"] = delivery_datetime; try { if (Object.keys(writeVals).length) { await odooCall(sessionId, { model: "sale.order", method: "write", args: [[order_id], writeVals] }); } } catch { // Gracieux: si les champs Studio n'existent pas, on ignore if (pickup_datetime) { await odooCall(sessionId, { model: "sale.order", method: "write", args: [[order_id], { commitment_date: pickup_datetime }] }); } } // 2) Journalisation const humanPickup = pickup_datetime ? new Date(pickup_datetime).toLocaleString() : null; const humanDelivery = delivery_datetime ? new Date(delivery_datetime).toLocaleString() : null; const line = [ "REPLANIFICATION:", humanPickup ? `- Enlèvement prévu: ${humanPickup}` : null, humanDelivery ? `- Livraison prévue: ${humanDelivery}` : null, window ? `- Créneau: ${window}` : null, reason ? `- Motif: ${reason}` : null ].filter(Boolean).join("\n"); await appendNote(sessionId, order_id, line); await chatter(sessionId, order_id, `<p><b>Replanification</b><br/>${(line || "").replace(/\n/g,"<br/>")}</p>`); // 3) Option : libérer l’assignation actuelle pour forcer une reconfirmation let prevAssign = null; if (release_assignment && assignments.has(order_id)) { prevAssign = assignments.get(order_id); assignments.delete(order_id); try { await sendWhatsAppText({ to: prevAssign.phone, text: `Info: la mission #${order_id} a été replanifiée. Votre assignation est libérée, merci de reconfirmer si vous êtes dispo.` }); } catch {} } // 4) Notifications WhatsApp const portal = await getPortalUrl(sessionId, order_id); if (clientPhone) { try { await sendWhatsAppText({ to: clientPhone, text: `Votre mission #${order_id} est replanifiée.\n` + (humanPickup ? `Enlèvement: ${humanPickup}\n` : "") + (humanDelivery ? `Livraison: ${humanDelivery}\n` : "") + (window ? `Créneau: ${window}\n` : "") + `Suivi: ${portal}` }); } catch {} } // 5) Re-broadcast aux freelances (ou ping au seul assigné si conservé) const BO = await backofficeUrl(order_id); if (rebroadcast) { const base = Array.isArray(rebroadcast_numbers) && rebroadcast_numbers.length ? rebroadcast_numbers : (FREELANCER_NUMBERS || "").split(",").map(s=>s.trim()).filter(Boolean); // Si on vient de libérer le précédent, on peut l’exclure du broadcast pour 5–10 min (simplifié ici : exclusion immédiate) const excl = new Set([prevAssign?.phone].filter(Boolean)); const targets = base.filter(n => !excl.has(n)); const body = `Mission #${order_id} replanifiée 🔁\n` + (humanPickup ? `Enlèvement: ${humanPickup}\n` : "") + (window ? `Créneau: ${window}\n` : "") + `Merci d'Accepter/Refuser.`; await Promise.allSettled(targets.map(to => sendWhatsAppInteractive({ to, bodyText: body, footerText: "Merci de votre réactivité", acceptId: `FREELANCE_ACCEPT_${order_id}`, declineId: `FREELANCE_DECLINE_${order_id}`, url: BO, urlLabel: "Backoffice" }) )); } else if (!release_assignment && assignments.has(order_id)) { // pas de rebroadcast : ping le livreur déjà assigné pour reconfirmation douce const current = assignments.get(order_id); try { await sendWhatsAppText({ to: current.phone, text: `Rappel: la mission #${order_id} est replanifiée. Merci de confirmer votre disponibilité.` }); } catch {} } return res.status(200).json({ ok: true, order_id, updated_fields: Object.keys(writeVals), notification_client: Boolean(clientPhone), rebroadcasted: Boolean(rebroadcast) }); } catch (e) { console.error("reschedule error:", e); return res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
2) Montage serveur
// server.js import express from "express"; import dotenv from "dotenv"; import rescheduleRoute from "./rescheduleRoute.js"; dotenv.config(); const app = express(); app.use(express.json({ limit: "2mb" })); app.use("/api", rescheduleRoute); app.listen(4004, () => { console.log("Service Replanification sur http://localhost:4004"); });
3) Exemple d’appel
curl -X POST http://localhost:4004/api/missions/reschedule \ -H "Content-Type: application/json" \ -d '{ "order_id": 1234, "pickup_datetime": "2025-10-02T14:00:00", "delivery_datetime": "2025-10-02T18:00:00", "window": "14:00–16:00", "reason": "Demande client", "release_assignment": true, "rebroadcast": true }'
Réponse
{ "ok": true, "order_id": 1234, "updated_fields": ["commitment_date","x_pickup_datetime","x_delivery_datetime"], "notification_client": true, "rebroadcasted": true }
Conseils pratiques
- Fuseaux horaires : envoyez des datetimes en ISO UTC et rendez-les lisibles au client (timezone Europe/Paris) côté message.
- Champs Odoo : si vous avez des champs Studio (x_pickup_datetime / x_delivery_datetime), créez-les en Datetime et ajoutez-les aux vues pour le suivi.
- Process métier : vous pouvez exiger une reconfirmation du client (boutons WhatsApp CLIENT_ACCEPT_<id> / CLIENT_DECLINE_<id>) lors d’un changement de créneau.
- SLA interne : logguez le nombre de replanifications par mission et alertez l’équipe si > N.
Souhaitez-vous que je vous fasse un mini dashboard Odoo (OWL) ou un panel React qui liste les missions en attente d’assignation / replanification, avec actions rapides (reopen, reschedule, cancel) ?
Mini dashboard Odoo (OWL) ou un panel React qui liste les missions en attente d’assignation / replanification, avec actions rapides (reopen, reschedule, cancel) ? > voir ci-dessous
import React, { useEffect, useMemo, useState } from "react";
/**
* LogisticsMissionsDashboard
* ------------------------------------------------------------
* Tableau de bord React (single-file) pour piloter vos missions :
* - Liste (live via /api/missions/list)
* - Actions: Réouverture, Replanification, Annulation, Envoi devis
* - Sélection multiple + Export CSV
* - UI Tailwind minimaliste, sans dépendances externes
* - Appels REST vers vos routes Node exposées précédemment :
* GET /api/missions/list
* POST /api/missions/reopen
* POST /api/missions/reschedule
* POST /api/missions/cancel
* POST /api/missions/send-quote
* ------------------------------------------------------------
* Intégration rapide :
* <LogisticsMissionsDashboard apiBase="http://localhost:4000/api" />
*/
const SAMPLE_MISSIONS = [
{
id: 1240,
objet: "Canapé 3 places",
client: { name: "Jean Martin", phone: "+33612345678" },
depart: "23 rue Lafayette, 75009 Paris",
arrivee: "12 bd de la République, 93100 Montreuil",
poidsKg: 40,
dims: { L: 200, l: 90, h: 80 },
status: "en_attente", // en_attente | assignee | replanning | annulee
option: "express",
price: 140,
pickup_iso: null,
delivery_iso: null,
created_at: "2025-10-01T08:30:00Z",
portal_url: "https://exemple/portal/1240"
},
{
id: 1241,
objet: "Frigo 180x70",
client: { name: "Sophie Durand", phone: "+33600000001" },
depart: "Marseille",
arrivee: "Lyon",
poidsKg: 60,
dims: { L: 180, l: 70, h: 70 },
status: "assignee",
option: "standard",
price: 250,
pickup_iso: "2025-10-02T08:00:00Z",
delivery_iso: "2025-10-02T16:00:00Z",
created_at: "2025-09-30T14:10:00Z",
portal_url: "https://exemple/portal/1241"
},
{
id: 1242,
objet: "Palette 120x80",
client: { name: "SARL BatiPro", phone: "+33600000002" },
depart: "Vitrolles",
arrivee: "Aix-en-Provence",
poidsKg: 120,
dims: { L: 120, l: 80, h: 100 },
status: "replanning",
option: "standard",
price: 120,
pickup_iso: null,
delivery_iso: null,
created_at: "2025-09-29T09:00:00Z",
portal_url: "https://exemple/portal/1242"
},
];
function StatusBadge({ status }) {
const map = {
en_attente: { label: "En attente", cls: "bg-yellow-100 text-yellow-800" },
assignee: { label: "Assignée", cls: "bg-emerald-100 text-emerald-800" },
replanning: { label: "À replanifier", cls: "bg-blue-100 text-blue-800" },
annulee: { label: "Annulée", cls: "bg-rose-100 text-rose-800" },
};
const m = map[status] || { label: status, cls: "bg-gray-100 text-gray-800" };
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${m.cls}`}>
{m.label}
</span>
);
}
function ActionButton({ onClick, disabled, title, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`px-3 py-1.5 rounded-xl text-sm shadow-sm border transition active:scale-[.98] disabled:opacity-50 disabled:cursor-not-allowed
bg-white hover:bg-gray-50 border-gray-200`}
>
{children}
</button>
);
}
function PrimaryButton({ onClick, disabled, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="px-3 py-1.5 rounded-xl text-sm shadow-sm bg-indigo-600 hover:bg-indigo-700 text-white active:scale-[.98] disabled:opacity-50"
>
{children}
</button>
);
}
function DangerButton({ onClick, disabled, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="px-3 py-1.5 rounded-xl text-sm shadow-sm border border-rose-200 bg-rose-50 hover:bg-rose-100 text-rose-700 active:scale-[.98] disabled:opacity-50"
>
{children}
</button>
);
}
function useMissions(apiBase) {
const [missions, setMissions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const r = await fetch(`${apiBase}/missions/list`);
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
const items = Array.isArray(data) ? data : (data.items || []);
setMissions(items);
} catch (e) {
// Fallback mock si l'API n'est pas encore prête
setMissions(SAMPLE_MISSIONS);
setError("API list indisponible — mock chargé");
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [apiBase]);
return { missions, setMissions, loading, error, reload: load };
}
function formatDateTimeLocalISO(iso) {
if (!iso) return "";
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mi = pad(d.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
// Export CSV côté front (UTF-8, ; séparateur)
function exportCsv(rows) {
const headers = [
"id","objet","client_name","client_phone","depart","arrivee","poidsKg","dim_L","dim_l","dim_h","status","option","price","pickup_iso","delivery_iso","portal_url"
];
const lines = [headers.join(";")];
rows.forEach(m => {
const vals = [
m.id, m.objet, m.client?.name || "", m.client?.phone || "", m.depart || "", m.arrivee || "",
m.poidsKg ?? "", m?.dims?.L ?? "", m?.dims?.l ?? "", m?.dims?.h ?? "",
m.status || "", m.option || "", m.price ?? "", m.pickup_iso || "", m.delivery_iso || "", m.portal_url || ""
].map(v => String(v).replaceAll(";", ","));
lines.push(vals.join(";"));
});
const blob = new Blob(["" + lines.join("
")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = `missions_${Date.now()}.csv`; a.click();
URL.revokeObjectURL(url);
}
export default function LogisticsMissionsDashboard({ apiBase = "http://localhost:4000/api" }) {
const { missions, setMissions, loading, error, reload } = useMissions(apiBase);
const [q, setQ] = useState("");
const [filter, setFilter] = useState("all");
const [selected, setSelected] = useState(new Set());
const filtered = useMemo(() => {
return missions.filter((m) => {
const okFilter = filter === "all" || m.status === filter;
const text = `${m.id} ${m.objet} ${m.client?.name} ${m.depart} ${m.arrivee}`.toLowerCase();
const okQ = text.includes(q.toLowerCase());
return okFilter && okQ;
});
}, [missions, q, filter]);
const toggleAll = () => {
if (selected.size === filtered.length) setSelected(new Set());
else setSelected(new Set(filtered.map(m => m.id)));
};
const toggleOne = (id) => {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
};
const doReopen = async (mission) => {
const payload = { order_id: mission.id, reason: "Réouverture depuis le panel" };
const r = await fetch(`${apiBase}/missions/reopen`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) alert("Réouverture: erreur API");
else {
setMissions((prev) => prev.map((x) => x.id === mission.id ? { ...x, status: "en_attente" } : x));
}
};
const doCancel = async (mission) => {
if (!confirm(`Annuler définitivement la mission #${mission.id} ?`)) return;
const payload = { order_id: mission.id, reason: "Annulation depuis le panel", notify_client: true, notify_freelancers: true };
const r = await fetch(`${apiBase}/missions/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) alert("Annulation: erreur API");
else {
setMissions((prev) => prev.map((x) => x.id === mission.id ? { ...x, status: "annulee" } : x));
}
};
const doReschedule = async (mission) => {
const pickup = prompt("Nouvelle date/heure enlèvement (ISO, ex. 2025-10-02T14:00:00)", formatDateTimeLocalISO(mission.pickup_iso));
if (pickup === null) return; // annulé
const delivery = prompt("Nouvelle date/heure livraison (ISO, optionnel)", formatDateTimeLocalISO(mission.delivery_iso));
const window = prompt("Créneau (texte, optionnel)", "14:00–16:00");
const payload = {
order_id: mission.id,
pickup_datetime: pickup || undefined,
delivery_datetime: delivery || undefined,
window: window || undefined,
reason: "Reschedule depuis le panel",
release_assignment: true,
rebroadcast: true,
};
const r = await fetch(`${apiBase}/missions/reschedule`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) alert("Replanification: erreur API");
else {
setMissions((prev) => prev.map((x) => x.id === mission.id ? { ...x, status: "replanning", pickup_iso: pickup || x.pickup_iso, delivery_iso: delivery || x.delivery_iso } : x));
}
};
const doSendQuote = async (mission) => {
const r = await fetch(`${apiBase}/missions/send-quote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: mission.id, channel: "whatsapp_and_email" })
});
if (!r.ok) {
alert("Envoi devis: erreur API");
} else {
const data = await r.json();
alert(`Devis #${mission.id} envoyé. Lien: ${data.portal_url || "(portail)"}`);
}
};
const bulkExport = () => {
const rows = missions.filter(m => selected.has(m.id));
if (rows.length === 0) return alert("Sélectionnez au moins une mission");
exportCsv(rows);
};
return (
<div className="max-w-6xl mx-auto p-4">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<h1 className="text-2xl font-semibold tracking-tight">Missions logistiques</h1>
<div className="flex items-center gap-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Rechercher…"
className="px-3 py-2 rounded-xl border border-gray-200 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-200"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-3 py-2 rounded-xl border border-gray-200 text-sm shadow-sm bg-white"
>
<option value="all">Tous</option>
<option value="en_attente">En attente</option>
<option value="assignee">Assignée</option>
<option value="replanning">À replanifier</option>
<option value="annulee">Annulée</option>
</select>
<PrimaryButton onClick={bulkExport}>Exporter CSV</PrimaryButton>
<button onClick={reload} className="px-3 py-2 rounded-xl text-sm bg-indigo-600 text-white shadow hover:bg-indigo-700 active:scale-[.98]">Rafraîchir</button>
</div>
</div>
{/* Notices */}
{loading && (
<div className="mb-3 text-sm text-gray-600">Chargement…</div>
)}
{error && (
<div className="mb-3 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl p-3">{error}</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-2xl border border-gray-200 shadow-sm">
<table className="min-w-full bg-white text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-4 py-3">
<input type="checkbox" checked={selected.size === filtered.length && filtered.length>0} onChange={toggleAll} />
</th>
<th className="text-left px-4 py-3">#</th>
<th className="text-left px-4 py-3">Objet</th>
<th className="text-left px-4 py-3">Client</th>
<th className="text-left px-4 py-3">Trajet</th>
<th className="text-left px-4 py-3">Poids / Dim</th>
<th className="text-left px-4 py-3">Option / Prix</th>
<th className="text-left px-4 py-3">Statut</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => (
<tr key={m.id} className="border-t border-gray-100">
<td className="px-4 py-3">
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleOne(m.id)} />
</td>
<td className="px-4 py-3 font-medium text-gray-900">{m.id}</td>
<td className="px-4 py-3">{m.objet}</td>
<td className="px-4 py-3">
<div className="font-medium">{m.client?.name}</div>
<div className="text-gray-500 text-xs">{m.client?.phone}</div>
</td>
<td className="px-4 py-3">
<div className="text-gray-800">{m.depart}</div>
<div className="text-gray-500 text-xs">→ {m.arrivee}</div>
</td>
<td className="px-4 py-3">
<div>{m.poidsKg ?? "?"} kg</div>
<div className="text-gray-500 text-xs">{m?.dims?.L ?? "?"}×{m?.dims?.l ?? "?"}×{m?.dims?.h ?? "?"} cm</div>
</td>
<td className="px-4 py-3">
<div className="capitalize">{m.option}</div>
<div className="text-gray-900 font-medium">{m.price} €</div>
</td>
<td className="px-4 py-3"><StatusBadge status={m.status} /></td>
<td className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<ActionButton onClick={() => doReopen(m)} title="Réouvrir la mission" disabled={m.status === "annulee"}>
Réouvrir
</ActionButton>
<ActionButton onClick={() => doReschedule(m)} title="Replanifier" disabled={m.status === "annulee"}>
Replanifier
</ActionButton>
<ActionButton onClick={() => doSendQuote(m)} title="Envoyer le devis au client">
Envoyer devis
</ActionButton>
<DangerButton onClick={() => doCancel(m)} disabled={m.status === "annulee"}>
Annuler
</DangerButton>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-10 text-center text-gray-500">Aucune mission</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Légende / aide */}
<div className="mt-4 text-xs text-gray-500 space-y-1">
<p>
Le tableau tente d'abord <code className="px-1 mx-1 rounded bg-gray-100">GET {"${apiBase}/missions/list"}</code> ; en cas d'échec, des données de démonstration sont affichées.
</p>
<p>
Les actions déclenchent : <code className="px-1 rounded bg-gray-100">/reopen</code>, <code className="px-1 rounded bg-gray-100">/reschedule</code>, <code className="px-1 rounded bg-gray-100">/cancel</code>, <code className="px-1 rounded bg-gray-100">/send-quote</code>.
</p>
</div>
</div>
);
}
Top, j’ai ajouté dans le canvas :
- la sélection multiple + Export CSV,
- le bouton Envoyer devis (par ligne) qui appelle POST /api/missions/send-quote,
- la liste lit désormais /api/missions/list (avec fallback mock).
Voici maintenant les routes Node à brancher côté serveur.
1) GET /api/missions/list (lecture Odoo → format UI)
Objectif : lister des sale.order récents, en ramenant les champs utiles + l’URL portail.
// routes/missionsList.js import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY } = process.env; async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } router.get("/missions/list", async (req, res) => { try { const sessionId = await odooAuth(); // Ex : derniers 100 devis/commandes « à faire » const domain = [["state", "in", ["draft", "sent", "sale"]]]; const fields = ["id","name","partner_id","note","state","commitment_date","create_date"]; const orders = await odooCall(sessionId, { model: "sale.order", method: "search_read", args: [domain, fields, 0, 100, "id desc"] }); // Mappage vers format UI const items = await Promise.all(orders.map(async (o) => { const order_id = o.id; const partnerId = Array.isArray(o.partner_id) ? o.partner_id[0] : o.partner_id; // Lire contact const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["name","phone","mobile"]] }); // URL portail const portal_url = await odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[order_id]] }); // Heuristique : extraire infos depuis la note (si vous avez structuré autrement, adaptez) const note = o.note || ""; const objet = (note.match(/OBJET:\s*(.+)/i) || [])[1] || o.name; const depart = (note.match(/D[ÉE]PART:\s*(.+)/i) || [])[1] || ""; const arrivee = (note.match(/ARRIV[ÉE]E:\s*(.+)/i) || [])[1] || ""; const dimsMatch = (note.match(/DIMENSIONS:\s*([0-9?]+)\s*x\s*([0-9?]+)\s*x\s*([0-9?]+)/i) || []); const poidsMatch = (note.match(/POIDS:\s*([0-9.]+)/i) || []); const optionMatch = (note.match(/OPTION:\s*(STANDARD|EXPRESS)/i) || []); const dims = { L: dimsMatch[1]||"", l: dimsMatch[2]||"", h: dimsMatch[3]||"" }; const poidsKg = poidsMatch[1] ? Number(poidsMatch[1]) : null; const option = (optionMatch[1] || "").toLowerCase() || "standard"; // Statut UI (map sur state Odoo) let status = "en_attente"; if (o.state === "sale") status = "assignee"; if (o.state === "cancel") status = "annulee"; return { id: order_id, objet, client: { name: p?.name || "", phone: p?.phone || p?.mobile || "" }, depart, arrivee, poidsKg, dims, status, option, price: null, // si vous stockez price ailleurs, remplissez-le pickup_iso: o.commitment_date || null, delivery_iso: null, // si vous avez x_delivery_datetime, lisez-le created_at: o.create_date, portal_url }; })); res.json({ items }); } catch (e) { console.error(e); res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
Montez-la dans votre serveur :
// server.js import express from "express"; import dotenv from "dotenv"; import missionsList from "./routes/missionsList.js"; dotenv.config(); const app = express(); app.use(express.json()); app.use("/api", missionsList); app.listen(4000, () => console.log("API sur http://localhost:4000"));
2) POST /api/missions/send-quote (Odoo action + WhatsApp)
Objectif : déclencher action_quotation_send (ou au minimum récupérer portal_url) et notifier le client par WhatsApp + éventuellement email (si vous envoyez l’email côté Odoo).
// routes/sendQuote.js import express from "express"; import fetch from "node-fetch"; const router = express.Router(); const { ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, WHATSAPP_TOKEN, WHATSAPP_PHONE_ID } = process.env; async function odooAuth() { const r = await fetch(`${ODOO_URL}/web/session/authenticate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", params: { db: ODOO_DB, login: ODOO_LOGIN, password: ODOO_API_KEY } }) }); const j = await r.json(); if (!j?.result?.session_id) throw new Error("Auth Odoo échouée"); return j.result.session_id; } async function odooCall(sessionId, { model, method, args = [], kwargs = {} }) { const r = await fetch(`${ODOO_URL}/web/dataset/call_kw`, { method: "POST", headers: { "Content-Type": "application/json", "Cookie": `session_id=${sessionId}` }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { model, method, args, kwargs } }) }); const j = await r.json(); if (j.error) throw new Error(`Odoo error: ${JSON.stringify(j.error)}`); return j.result; } async function sendWhatsAppText({ to, text }) { const r = await fetch(`https://graph.facebook.com/v19.0/${WHATSAPP_PHONE_ID}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${WHATSAPP_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", to, type: "text", text: { body: text, preview_url: true } }) }); const j = await r.json(); if (!r.ok) throw new Error(`WhatsApp send error: ${JSON.stringify(j)}`); return j; } router.post("/missions/send-quote", async (req, res) => { try { const { order_id, channel = "whatsapp_and_email" } = req.body || {}; if (!order_id) return res.status(400).json({ error: "order_id requis" }); const sessionId = await odooAuth(); // Optionnel : déclencher l'assistant d'envoi de devis (peut ouvrir un wizard mail) // On peut également juste récupérer l'URL portail si vous envoyez par WhatsApp. let portal_url = await odooCall(sessionId, { model: "sale.order", method: "action_get_portal_url", args: [[order_id]] }); // Récupérer téléphone & nom client const [order] = await odooCall(sessionId, { model: "sale.order", method: "read", args: [[order_id], ["partner_id","name"]] }); const partnerId = Array.isArray(order.partner_id) ? order.partner_id[0] : order.partner_id; const [p] = await odooCall(sessionId, { model: "res.partner", method: "read", args: [[partnerId], ["name","phone","mobile","email"]] }); const phone = p?.phone || p?.mobile; const clientName = p?.name || "Client"; // Si souhaité, on peut aussi appeler action_quotation_send (ouvre un wizard côté UI), // mais en JSON-RPC pur, on enverra surtout via WhatsApp + l'email via un template custom. // await odooCall(sessionId, { model: "sale.order", method: "action_quotation_send", args: [[order_id]] }); // WhatsApp au client if (channel.includes("whatsapp") && phone) { await sendWhatsAppText({ to: phone, text: `Bonjour ${clientName} 👋\nVotre devis #${order_id} est prêt.\nConsultez & validez ici : ${portal_url}` }); } // Email (optionnel) : à câbler via un template mail côté Odoo, // ex. message_post avec subtype_mail + template_id. return res.status(200).json({ ok: true, order_id, portal_url }); } catch (e) { console.error(e); return res.status(500).json({ error: e.message || "Erreur interne" }); } }); export default router;
Montez-la :
// server.js import express from "express"; import dotenv from "dotenv"; import missionsList from "./routes/missionsList.js"; import sendQuote from "./routes/sendQuote.js"; dotenv.config(); const app = express(); app.use(express.json()); app.use("/api", missionsList); app.use("/api", sendQuote); app.listen(4000, () => console.log("API sur http://localhost:4000"));
Notes & conseils
- Performance : la route list lit res.partner et portal_url pour chaque ordre ; si ça devient lourd, faites un batch (cache ou champs dénormalisés sur sale.order via Studio).
- Portail & paiement : si le paiement en ligne est configuré, le lien portail permettra au client de payer directement.
- Export CSV : fait côté front pour la sélection ; si vous voulez un export serveur, je peux ajouter une route /api/missions/export.csv.
- Sécurité : protégez ces routes par API key/JWT et limitez IP si nécessaire.
Vous voulez que j’ajoute aussi l’export serveur en CSV et un filtre par date (ex. ?from=2025-09-01&to=2025-10-01 sur /missions/list) ?