un seul “contrat logistique” que NestJS expose à Odoo, et derrière vous routez vers Uber / Central-Courses / Cocolis.

un seul “contrat logistique” que NestJS expose à Odoo, et derrière vous routez vers Uber / Central-Courses / Cocolis.

Je vous donne un schéma JSON unifié pour :

  1. DeliveryQuoteRequest / DeliveryQuoteResponse
  2. DeliveryCreateRequest / DeliveryCreateResponse
  3. Un événement DeliveryStatusWebhook (retour provider → votre backend)

Tout est pensé pour être provider-agnostic, avec un champ provider + un bloc provider_options extensible.

1️⃣ DeliveryQuoteRequest (demande de devis)

Schéma JSON (style JSON Schema, valide)

{ "$id": "https://synergia/logistics/DeliveryQuoteRequest.schema.json", "title": "DeliveryQuoteRequest", "type": "object", "properties": { "provider": { "type": "string", "enum": ["uber_direct", "central_courses", "cocolis"] }, "client_reference": { "type": "string", "description": "Référence interne côté Odoo (sale.order ou autre)." }, "pickup": { "type": "object", "properties": { "name": { "type": "string" }, "company": { "type": "string" }, "phone": { "type": "string" }, "email": { "type": "string" }, "address": { "type": "object", "properties": { "street": { "type": "string" }, "street2": { "type": "string" }, "postal_code": { "type": "string" }, "city": { "type": "string" }, "country_code": { "type": "string" } }, "required": ["street", "postal_code", "city", "country_code"], "additionalProperties": false }, "instructions": { "type": "string" } }, "required": ["name", "phone", "address"], "additionalProperties": false }, "dropoff": { "type": "object", "properties": { "name": { "type": "string" }, "company": { "type": "string" }, "phone": { "type": "string" }, "email": { "type": "string" }, "address": { "type": "object", "properties": { "street": { "type": "string" }, "street2": { "type": "string" }, "postal_code": { "type": "string" }, "city": { "type": "string" }, "country_code": { "type": "string" } }, "required": ["street", "postal_code", "city", "country_code"], "additionalProperties": false }, "instructions": { "type": "string" } }, "required": ["name", "phone", "address"], "additionalProperties": false }, "packages": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "integer", "minimum": 1 }, "weight_kg": { "type": "number", "minimum": 0 }, "length_cm": { "type": "number", "minimum": 0 }, "width_cm": { "type": "number", "minimum": 0 }, "height_cm": { "type": "number", "minimum": 0 }, "declared_value_eur": { "type": "number", "minimum": 0 }, "is_fragile": { "type": "boolean" }, "is_medical": { "type": "boolean" }, "needs_upright": { "type": "boolean" } }, "required": ["description", "quantity"], "additionalProperties": false }, "minItems": 1 }, "constraints": { "type": "object", "properties": { "service_level": { "type": "string", "enum": ["express", "same_day", "scheduled", "economy"] }, "pickup_time_iso": { "type": "string", "format": "date-time" }, "latest_dropoff_time_iso": { "type": "string", "format": "date-time" }, "vehicle_type": { "type": "string", "enum": ["bike", "scooter", "car", "van", "truck"] }, "max_price_eur": { "type": "number", "minimum": 0 } }, "additionalProperties": false }, "provider_options": { "type": "object", "description": "Options spécifiques au provider (Uber/Central/Cocolis).", "additionalProperties": true }, "metadata": { "type": "object", "description": "Données libres pour log/debug (ID Odoo, user, canal).", "additionalProperties": true } }, "required": ["provider", "client_reference", "pickup", "dropoff", "packages"], "additionalProperties": false }

2️⃣ DeliveryQuoteResponse (retour de devis de votre orchestrateur)

{ "$id": "https://synergia/logistics/DeliveryQuoteResponse.schema.json", "title": "DeliveryQuoteResponse", "type": "object", "properties": { "provider": { "type": "string", "enum": ["uber_direct", "central_courses", "cocolis"] }, "client_reference": { "type": "string", "description": "Référence envoyée dans la requête (sale.order...)." }, "internal_quote_id": { "type": "string", "description": "ID du devis dans VOTRE système (NestJS / DB)." }, "provider_quote_id": { "type": "string", "description": "ID de devis retourné par Uber/Central/Cocolis." }, "currency": { "type": "string", "default": "EUR" }, "price_estimate": { "type": "number" }, "price_min": { "type": "number" }, "price_max": { "type": "number" }, "eta_pickup_minutes": { "type": "integer" }, "eta_dropoff_minutes": { "type": "integer" }, "expires_at_iso": { "type": "string", "format": "date-time" }, "provider_raw": { "type": "object", "description": "Payload brut du provider (pour debug/audit).", "additionalProperties": true } }, "required": [ "provider", "client_reference", "internal_quote_id", "currency" ], "additionalProperties": false }

3️⃣ DeliveryCreateRequest (confirmation de livraison à partir d’un devis)

{ "$id": "https://synergia/logistics/DeliveryCreateRequest.schema.json", "title": "DeliveryCreateRequest", "type": "object", "properties": { "provider": { "type": "string", "enum": ["uber_direct", "central_courses", "cocolis"] }, "client_reference": { "type": "string", "description": "Référence Odoo (commande, picking, etc.)." }, "internal_quote_id": { "type": "string", "description": "L’ID de devis que votre backend vous a renvoyé." }, "provider_quote_id": { "type": "string", "description": "Optionnel si non nécessaire pour certains providers." }, "confirm_price_eur": { "type": "number", "description": "Prix accepté côté Odoo (pour log/anti-surprise)." }, "webhook_url": { "type": "string", "format": "uri", "description": "URL que VOTRE orchestrateur donnera au provider pour les statuts." }, "provider_options": { "type": "object", "description": "Options spécifiques au provider pour la création.", "additionalProperties": true } }, "required": ["provider", "client_reference", "internal_quote_id"], "additionalProperties": false }

4️⃣ DeliveryCreateResponse (résultat création livraison)

{ "$id": "https://synergia/logistics/DeliveryCreateResponse.schema.json", "title": "DeliveryCreateResponse", "type": "object", "properties": { "provider": { "type": "string", "enum": ["uber_direct", "central_courses", "cocolis"] }, "client_reference": { "type": "string" }, "internal_delivery_id": { "type": "string", "description": "ID de livraison dans VOTRE base (clé primaire)." }, "provider_delivery_id": { "type": "string", "description": "ID retourné par Uber/Central/Cocolis." }, "status": { "type": "string", "enum": [ "requested", "accepted", "pickup_in_progress", "picked_up", "in_transit", "delivered", "failed", "cancelled" ] }, "tracking_url": { "type": "string", "format": "uri" }, "final_price_eur": { "type": "number" }, "currency": { "type": "string", "default": "EUR" }, "provider_raw": { "type": "object", "additionalProperties": true } }, "required": [ "provider", "client_reference", "internal_delivery_id", "status" ], "additionalProperties": false }

5️⃣ DeliveryStatusWebhook (event unifié pour vos webhooks providers → NestJS → Odoo)

C’est le format interne que votre orchestrateur réutilise pour parler à Odoo.

Vous pouvez transformer les webhooks Uber / Central / Cocolis dans cette forme.

{ "$id": "https://synergia/logistics/DeliveryStatusWebhook.schema.json", "title": "DeliveryStatusWebhook", "type": "object", "properties": { "provider": { "type": "string", "enum": ["uber_direct", "central_courses", "cocolis"] }, "provider_delivery_id": { "type": "string" }, "internal_delivery_id": { "type": "string" }, "client_reference": { "type": "string" }, "status": { "type": "string", "enum": [ "requested", "accepted", "pickup_in_progress", "picked_up", "in_transit", "delivered", "failed", "cancelled" ] }, "status_reason": { "type": "string" }, "occurred_at_iso": { "type": "string", "format": "date-time" }, "location": { "type": "object", "properties": { "lat": { "type": "number" }, "lng": { "type": "number" } }, "additionalProperties": false }, "provider_raw": { "type": "object", "additionalProperties": true } }, "required": ["provider", "provider_delivery_id", "status", "occurred_at_iso"], "additionalProperties": false }

6️⃣ Comment l’utiliser côté Odoo / NestJS

  • Odoo ne voit que :
    • DeliveryQuoteRequest → DeliveryQuoteResponse
    • DeliveryCreateRequest → DeliveryCreateResponse
    • et éventuellement un endpoint “update status” en interne.
  • NestJS :
    • mappe Uber / Central / Cocolis vers ce contrat unique ;
    • choisit le provider avec une simple switch(provider) ;
    • garde provider_raw pour pouvoir débugger rapidement si un provider change sa payload.

la version TypeScript / DTO NestJS de ces schémas,

Parfait, on passe en mode code 🤓

Je te propose une petite arborescence + les DTO NestJS en TypeScript, prêts à coller dans ton projet.

📁 Structure proposée

src/logistics/ delivery-provider.enum.ts delivery-status.enum.ts dto/ address.dto.ts contact.dto.ts package.dto.ts constraints.dto.ts delivery-quote-request.dto.ts delivery-quote-response.dto.ts delivery-create-request.dto.ts delivery-create-response.dto.ts delivery-status-webhook.dto.ts

1️⃣ Enums – providers & status

delivery-provider.enum.ts

export enum DeliveryProvider { UBER_DIRECT = 'uber_direct', CENTRAL_COURSES = 'central_courses', COCOLIS = 'cocolis', }

delivery-status.enum.ts

export enum DeliveryStatus { REQUESTED = 'requested', ACCEPTED = 'accepted', PICKUP_IN_PROGRESS = 'pickup_in_progress', PICKED_UP = 'picked_up', IN_TRANSIT = 'in_transit', DELIVERED = 'delivered', FAILED = 'failed', CANCELLED = 'cancelled', }

2️⃣ DTO “atomiques” : adresse, contact, colis, contraintes

dto/address.dto.ts

import { IsOptional, IsString } from 'class-validator'; export class AddressDto { @IsString() street: string; @IsOptional() @IsString() street2?: string; @IsString() postal_code: string; @IsString() city: string; @IsString() country_code: string; }

dto/contact.dto.ts

import { Type } from 'class-transformer'; import { IsEmail, IsOptional, IsString } from 'class-validator'; import { AddressDto } from './address.dto'; export class ContactDto { @IsString() name: string; @IsOptional() @IsString() company?: string; @IsString() phone: string; @IsOptional() @IsEmail() email?: string; @Type(() => AddressDto) address: AddressDto; @IsOptional() @IsString() instructions?: string; }

dto/package.dto.ts

import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator'; export class PackageDto { @IsString() description: string; @IsInt() @Min(1) quantity: number; @IsOptional() @IsNumber() @Min(0) weight_kg?: number; @IsOptional() @IsNumber() @Min(0) length_cm?: number; @IsOptional() @IsNumber() @Min(0) width_cm?: number; @IsOptional() @IsNumber() @Min(0) height_cm?: number; @IsOptional() @IsNumber() @Min(0) declared_value_eur?: number; @IsOptional() @IsBoolean() is_fragile?: boolean; @IsOptional() @IsBoolean() is_medical?: boolean; @IsOptional() @IsBoolean() needs_upright?: boolean; }

dto/constraints.dto.ts

import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; export enum ServiceLevel { EXPRESS = 'express', SAME_DAY = 'same_day', SCHEDULED = 'scheduled', ECONOMY = 'economy', } export enum VehicleType { BIKE = 'bike', SCOOTER = 'scooter', CAR = 'car', VAN = 'van', TRUCK = 'truck', } export class ConstraintsDto { @IsOptional() @IsEnum(ServiceLevel) service_level?: ServiceLevel; @IsOptional() @IsString() // ISO 8601 pickup_time_iso?: string; @IsOptional() @IsString() // ISO 8601 latest_dropoff_time_iso?: string; @IsOptional() @IsEnum(VehicleType) vehicle_type?: VehicleType; @IsOptional() @IsNumber() max_price_eur?: number; }

3️⃣ DeliveryQuoteRequest / Response DTO

dto/delivery-quote-request.dto.ts

import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested, } from 'class-validator'; import { DeliveryProvider } from '../delivery-provider.enum'; import { ContactDto } from './contact.dto'; import { PackageDto } from './package.dto'; import { ConstraintsDto } from './constraints.dto'; export class DeliveryQuoteRequestDto { @IsEnum(DeliveryProvider) provider: DeliveryProvider; @IsString() client_reference: string; // ex: SO2025/0001 @Type(() => ContactDto) @ValidateNested() pickup: ContactDto; @Type(() => ContactDto) @ValidateNested() dropoff: ContactDto; @IsArray() @ValidateNested({ each: true }) @Type(() => PackageDto) packages: PackageDto[]; @IsOptional() @Type(() => ConstraintsDto) @ValidateNested() constraints?: ConstraintsDto; @IsOptional() @IsObject() provider_options?: Record<string, any>; @IsOptional() @IsObject() metadata?: Record<string, any>; }

dto/delivery-quote-response.dto.ts

import { IsEnum, IsNumber, IsObject, IsOptional, IsString } from 'class-validator'; import { DeliveryProvider } from '../delivery-provider.enum'; export class DeliveryQuoteResponseDto { @IsEnum(DeliveryProvider) provider: DeliveryProvider; @IsString() client_reference: string; @IsString() internal_quote_id: string; @IsOptional() @IsString() provider_quote_id?: string; @IsString() currency: string; @IsOptional() @IsNumber() price_estimate?: number; @IsOptional() @IsNumber() price_min?: number; @IsOptional() @IsNumber() price_max?: number; @IsOptional() @IsNumber() eta_pickup_minutes?: number; @IsOptional() @IsNumber() eta_dropoff_minutes?: number; @IsOptional() @IsString() expires_at_iso?: string; @IsOptional() @IsObject() provider_raw?: Record<string, any>; }

4️⃣ DeliveryCreateRequest / Response DTO

dto/delivery-create-request.dto.ts

import { IsEnum, IsNumber, IsObject, IsOptional, IsString, IsUrl } from 'class-validator'; import { DeliveryProvider } from '../delivery-provider.enum'; export class DeliveryCreateRequestDto { @IsEnum(DeliveryProvider) provider: DeliveryProvider; @IsString() client_reference: string; @IsString() internal_quote_id: string; @IsOptional() @IsString() provider_quote_id?: string; @IsOptional() @IsNumber() confirm_price_eur?: number; @IsOptional() @IsUrl() webhook_url?: string; @IsOptional() @IsObject() provider_options?: Record<string, any>; }

dto/delivery-create-response.dto.ts

import { IsEnum, IsNumber, IsObject, IsOptional, IsString, IsUrl } from 'class-validator'; import { DeliveryProvider } from '../delivery-provider.enum'; import { DeliveryStatus } from '../delivery-status.enum'; export class DeliveryCreateResponseDto { @IsEnum(DeliveryProvider) provider: DeliveryProvider; @IsString() client_reference: string; @IsString() internal_delivery_id: string; @IsOptional() @IsString() provider_delivery_id?: string; @IsEnum(DeliveryStatus) status: DeliveryStatus; @IsOptional() @IsUrl() tracking_url?: string; @IsOptional() @IsNumber() final_price_eur?: number; @IsOptional() @IsString() currency?: string; @IsOptional() @IsObject() provider_raw?: Record<string, any>; }

5️⃣ DeliveryStatusWebhook DTO (format unifié interne)

dto/delivery-status-webhook.dto.ts

import { IsEnum, IsNumber, IsObject, IsOptional, IsString } from 'class-validator'; import { DeliveryProvider } from '../delivery-provider.enum'; import { DeliveryStatus } from '../delivery-status.enum'; export class DeliveryStatusWebhookDto { @IsEnum(DeliveryProvider) provider: DeliveryProvider; @IsString() provider_delivery_id: string; @IsOptional() @IsString() internal_delivery_id?: string; @IsOptional() @IsString() client_reference?: string; @IsEnum(DeliveryStatus) status: DeliveryStatus; @IsOptional() @IsString() status_reason?: string; @IsString() occurred_at_iso: string; // ISO 8601 @IsOptional() @IsObject() location?: { lat?: number; lng?: number; }; @IsOptional() @IsObject() provider_raw?: Record<string, any>; }

6️⃣ Comment tu plugges ça dans NestJS

Exemple minimal :

// logistics.controller.ts @Post('quote') createQuote(@Body() dto: DeliveryQuoteRequestDto): Promise<DeliveryQuoteResponseDto> { return this.logisticsService.createQuote(dto); } @Post('create-delivery') createDelivery(@Body() dto: DeliveryCreateRequestDto): Promise<DeliveryCreateResponseDto> { return this.logisticsService.createDelivery(dto); } @Post('webhook/status') handleStatus(@Body() dto: DeliveryStatusWebhookDto) { return this.logisticsService.handleStatus(dto); }

Ensuite dans logistics.service.ts, tu fais ton switch(dto.provider) pour router vers :

  • UberDirectAdapter
  • CentralCoursesAdapter
  • CocolisAdapter

tout en gardant le contrat DTO unifié côté Odoo.

Si tu veux, je peux te faire au prochain message :

👉 un exemple complet d’UberDirectAdapter (service NestJS) qui mappe ton DeliveryQuoteRequestDto vers l’API Uber (quote + create), avec les noms de champs cohérents et des TODO pour la vraie doc Uber.