openapi: 3.1.0
info:
  title: SLSP Solutions Partner API
  version: 1.0.0
  summary: API d'intégration B2B pour partenaires (Dynamic Flows et autres).
  description: |
    Cette API permet à un système partenaire (typiquement un workflow d'entreprise
    type Dynamic Flows) de :

    - **Pousser** des bons de commande (BC) vers Pie 1 — `POST /v1/work-orders`
    - **Lire** le statut et la progression des BC — `GET /v1/work-orders[/{ref}]`
    - **Lire** les inspections d'équipements Merle — `GET /v1/inspections[/{id}]`
    - **Recevoir** des webhooks signés HMAC quand un BC change de statut
      (cf. section *Webhooks* en bas de doc)

    Chaque clé API est scopée à une seule organisation (SLSP). Pour intégrer
    plusieurs SLSP, demander une clé par organisation.

    **Versionnement** : préfixe `/v1` dans le chemin. Les évolutions
    rétro-compatibles (ajout de champ optionnel) ne déclenchent pas de v2.

    **Quotas** : 1000 requêtes / minute / clé. Header `Retry-After` en cas de 429.

  contact:
    name: Mosa Data Engineering
    email: support@slspsolutions.be
    url: https://slspsolutions.be
  license:
    name: Propriétaire — usage strictement contractuel

servers:
  - url: https://api.slspsolutions.be/v1
    description: Production
  - url: https://europe-west1-slsp-46f0c.cloudfunctions.net/partnerApi/v1
    description: Production (URL alternative directe Cloud Functions)

security:
  - bearerAuth: []

tags:
  - name: WorkOrders
    description: Bons de commande Pie 1
  - name: Inspections
    description: Inspections Merle

paths:
  /work-orders:
    post:
      tags: [WorkOrders]
      summary: Créer un bon de commande
      description: |
        Créé un BC dans Pie 1. La référence interne `BC-YYYY-NNNN` est générée
        côté SLSP Solutions et retournée dans la réponse.

        **Idempotence** : si un BC existe déjà avec la même `externalRef` pour
        cette organisation, il est retourné sans modification (HTTP 200 au lieu
        de 201). Cela permet de retry sans risque de doublon.

        **Résolution de l'entrepreneur** : passer `contractorEmail`. L'utilisateur
        doit déjà exister côté SLSP Solutions avec un lien `entrepreneur` actif
        sur l'organisation. Si non, retour 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkOrderCreate'
      responses:
        '201':
          description: BC créé
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrder' }
        '200':
          description: BC déjà existant pour cette `externalRef` (idempotence)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrder' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }

    get:
      tags: [WorkOrders]
      summary: Lister les bons de commande
      description: |
        Liste paginée des BC de l'organisation. Tri par `updatedAt` décroissant
        par défaut. Combinable avec filtres `since` (delta sync) et `status`.
      parameters:
        - $ref: '#/components/parameters/Since'
        - in: query
          name: status
          schema: { $ref: '#/components/schemas/WorkOrderStatus' }
          description: Filtre sur statut (un seul à la fois).
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Liste paginée
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrderList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /work-orders/{ref}:
    get:
      tags: [WorkOrders]
      summary: Lire un bon de commande
      description: |
        Récupère un BC par sa référence interne (`BC-2026-0042`) ou par
        `external:<externalRef>` pour interroger par l'identifiant côté partenaire.
      parameters:
        - in: path
          name: ref
          required: true
          schema: { type: string }
          example: BC-2026-0042
          description: Référence interne ou `external:<externalRef>`
      responses:
        '200':
          description: BC
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /work-orders/{ref}/receive:
    post:
      tags: [WorkOrders]
      summary: Réceptionner un BC (transition termine → receptionne)
      description: |
        La SLSP valide l'intervention et clôture le BC. Disponible uniquement
        depuis le statut `termine`. Notes optionnelles (commentaire de réception
        positif).

        Renvoie le BC complet après transition. Le `receivedBy` côté Pie 1 est
        positionné à `partner:<keyId>` pour traçabilité.
      parameters:
        - in: path
          name: ref
          required: true
          schema: { type: string }
          example: BC-2026-0042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                receptionNotes:
                  type: string
                  nullable: true
                  description: Commentaire de réception (optionnel).
                  example: "Travail propre, RAS."
      responses:
        '200':
          description: BC réceptionné
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Transition invalide (statut courant ≠ `termine`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /work-orders/{ref}/request-correction:
    post:
      tags: [WorkOrders]
      summary: Renvoyer un BC pour correction (transition termine → a_corriger)
      description: |
        La SLSP refuse l'intervention et demande des corrections. Disponible
        uniquement depuis le statut `termine`. Le motif (`receptionNotes`) est
        **obligatoire** — il sera affiché à l'entrepreneur dans Pie 1 mobile.

        L'entrepreneur reprendra le BC depuis Pie 1 mobile, ce qui transitionnera
        le statut vers `en_cours` automatiquement.
      parameters:
        - in: path
          name: ref
          required: true
          schema: { type: string }
          example: BC-2026-0042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receptionNotes]
              properties:
                receptionNotes:
                  type: string
                  minLength: 3
                  description: Motif du renvoi pour correction. Obligatoire.
                  example: "Photos après manquantes, joint robinet pas étanche."
      responses:
        '200':
          description: BC renvoyé pour correction
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkOrder' }
        '400':
          description: receptionNotes manquantes ou vides
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Transition invalide (statut courant ≠ `termine`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /inspections:
    get:
      tags: [Inspections]
      summary: Lister les inspections
      description: |
        Liste paginée des inspections Merle de l'organisation. Tri par
        `timestamp` décroissant. Filtres optionnels.
      parameters:
        - $ref: '#/components/parameters/Since'
        - in: query
          name: equipmentId
          schema: { type: string }
          description: Filtre sur un équipement précis.
        - in: query
          name: type
          schema: { $ref: '#/components/schemas/InspectionType' }
          description: Filtre sur le type d'inspection.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Liste paginée
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /inspections/{id}:
    get:
      tags: [Inspections]
      summary: Lire une inspection
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Inspection
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Inspection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

webhooks:
  workOrderStatusChanged:
    post:
      summary: Notification temps réel d'un changement de statut de BC
      description: |
        SLSP Solutions appelle votre endpoint webhook chaque fois que le
        statut d'un BC change (ex: l'entrepreneur passe `en_cours → termine`,
        ou la SLSP renvoie pour correction).

        **Pré-requis** : transmettre votre URL HTTPS à Mosa Data, qui en
        retour vous communique un `webhookSecret` (32 caractères, à conserver
        de manière confidentielle). Sans webhook configuré, comportement
        par défaut = polling sur `GET /v1/work-orders?since=...`.

        **Vérification de signature** (obligatoire côté partenaire) :

        ```
        signature_attendue = HMAC-SHA256(rawBody, webhookSecret)
        # comparer en hex avec le header X-SLSP-Signature
        ```

        Pseudocode Node.js :

        ```js
        const crypto = require("crypto");
        const expected = "sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET)
          .update(rawBody, "utf8").digest("hex");
        if (!crypto.timingSafeEqual(
          Buffer.from(req.headers["x-slsp-signature"]),
          Buffer.from(expected)
        )) {
          return res.status(401).end();
        }
        ```

        **Idempotence côté partenaire** : utiliser `X-SLSP-Event-Id` (ou
        `eventId` du body) comme clé d'idempotence. SLSP Solutions peut
        retenter une livraison en cas d'échec — votre endpoint doit gérer
        les doublons (no-op si event déjà traité).

        **Retry** : 4 tentatives au total avec backoff (1s, 2s, 4s entre les
        tentatives). Si votre endpoint répond `2xx`, succès. Si `4xx` (sauf
        429), abandon (bug de votre côté à corriger). Si `5xx` ou timeout,
        retry. Au-delà des 4 tentatives, l'événement est considéré perdu et
        loggué côté MDE — récupération via polling possible.

        **Timeout** : votre endpoint a 10 secondes pour répondre. Au-delà,
        retry (donc traitement asynchrone recommandé : ack rapide en 200,
        traitement métier en background).

      parameters:
        - in: header
          name: X-SLSP-Event-Id
          required: true
          schema: { type: string }
          description: Identifiant unique d'événement (clé d'idempotence)
          example: "evt_abc123_termine_1714305600000"
        - in: header
          name: X-SLSP-Event-Type
          required: true
          schema: { type: string, enum: [work_order.status_changed] }
        - in: header
          name: X-SLSP-Signature
          required: true
          schema: { type: string }
          description: "`sha256=<hex>` — HMAC-SHA256 du body avec le webhookSecret"
          example: "sha256=4f3c8a1b9d2e..."
        - in: header
          name: User-Agent
          schema: { type: string, example: "slsp-solutions-webhooks/1.0" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200':
          description: Événement reçu et accepté (ack rapide recommandé)
        '401':
          description: Signature invalide — l'événement sera retenté

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: SLSP-API-Key
      description: |
        Clé API au format `slsp_live_xxxxxxxxxxxxxxxx` (production) ou
        `slsp_test_xxxxxxxxxxxxxxxx` (staging). Header :
        `Authorization: Bearer slsp_live_...`

  parameters:
    Since:
      in: query
      name: since
      schema: { type: string, format: date-time }
      example: "2026-04-01T00:00:00Z"
      description: Ne retourne que les éléments modifiés après cette date (ISO 8601 UTC).

    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      description: Nombre max d'éléments par page.

    Cursor:
      in: query
      name: cursor
      schema: { type: string }
      description: Token opaque retourné par la requête précédente (`nextCursor`).

  responses:
    BadRequest:
      description: Requête invalide
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Clé API manquante, invalide ou révoquée
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Ressource introuvable dans le périmètre de la clé
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: Validation métier (entrepreneur introuvable, etc.)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Quota dépassé. Voir header `Retry-After` (secondes).
      headers:
        Retry-After:
          schema: { type: integer }
          description: Secondes à attendre avant retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Code d'erreur stable (snake_case).
              example: contractor_not_found
            message:
              type: string
              description: Message lisible (français, ne pas parser).
              example: "Aucun entrepreneur actif avec cet email pour cette organisation."
            details:
              type: object
              additionalProperties: true

    WorkOrderStatus:
      type: string
      description: |
        Cycle : `emis → en_cours → termine → receptionne ✓`. Boucle possible :
        `termine → a_corriger → en_cours`.
      enum: [emis, en_cours, termine, a_corriger, receptionne]

    InspectionType:
      type: string
      enum: [pose, ronde_interne, controle_agree]

    Geopoint:
      type: object
      required: [latitude, longitude]
      properties:
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }

    Location:
      type: object
      description: |
        Localisation libre. Si le partenaire connaît nos identifiants internes
        (`siteId`, `buildingId`), il peut les passer ; sinon `address` suffit
        et l'opérateur SLSP affinera côté Pie 1.
      properties:
        siteId: { type: string, nullable: true }
        siteName: { type: string, nullable: true, readOnly: true, description: "Dénormalisé en réponse." }
        buildingId: { type: string, nullable: true }
        buildingName: { type: string, nullable: true, readOnly: true }
        zoneId: { type: string, nullable: true }
        floor: { type: integer, nullable: true }
        apartment: { type: string, nullable: true, description: "Numéro/identifiant logement." }
        address: { type: string, nullable: true }

    Contact:
      type: object
      description: Personne sur place (locataire, gardien, syndic).
      properties:
        name: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        email: { type: string, nullable: true, format: email }

    StatusHistoryEntry:
      type: object
      properties:
        status: { $ref: '#/components/schemas/WorkOrderStatus' }
        at: { type: string, format: date-time }
        by: { type: string, description: "User ID interne (opaque côté partenaire)." }

    VisitOutcome:
      type: string
      enum: [no_answer, no_access, appointment_set, refused, completed]
      description: |
        Issue d'un passage sur place : sonné/personne (`no_answer`), pas
        d'accès (`no_access`), RDV pris (`appointment_set`), refusé (`refused`),
        intervention faite (`completed`).

    Visit:
      type: object
      properties:
        at: { type: string, format: date-time }
        outcome: { $ref: '#/components/schemas/VisitOutcome' }
        notes: { type: string, nullable: true }
        geopoint: { $ref: '#/components/schemas/Geopoint' }
        photoUrls:
          type: array
          items: { type: string, format: uri }

    WorkOrderCreate:
      type: object
      required: [externalRef, description, contractorEmail]
      properties:
        externalRef:
          type: string
          maxLength: 128
          description: |
            Identifiant unique côté partenaire. Utilisé comme clé d'idempotence.
            Renvoyer la même valeur sur retry retourne le BC existant.
          example: "DF-BC-12345"
        description:
          type: string
          minLength: 3
          maxLength: 4000
        contractorEmail:
          type: string
          format: email
          description: |
            Email de l'entrepreneur (patron) à qui le BC est attribué. Doit
            correspondre à un compte avec lien `entrepreneur` actif sur
            l'organisation de la clé.
        deadline:
          type: string
          format: date-time
          nullable: true
        plannedStart:
          type: string
          format: date-time
          nullable: true
          description: Date/heure planifiée (créneaux).
        plannedDurationMinutes:
          type: integer
          nullable: true
          minimum: 15
          maximum: 1440
        estimatedAmount:
          type: number
          nullable: true
          description: Montant estimé HTVA en EUR.
        location: { $ref: '#/components/schemas/Location' }
        contact: { $ref: '#/components/schemas/Contact' }

    WorkOrder:
      type: object
      required: [id, reference, status, organizationId, description, createdAt]
      properties:
        id: { type: string, description: "ID interne." }
        reference:
          type: string
          pattern: '^BC-\d{4}-\d{4}$'
          example: BC-2026-0042
        externalRef:
          type: string
          nullable: true
          description: Référence côté partenaire si fournie à la création.
        organizationId: { type: string }
        organizationName: { type: string, nullable: true }
        status: { $ref: '#/components/schemas/WorkOrderStatus' }
        description: { type: string }
        deadline: { type: string, format: date-time, nullable: true }
        plannedStart: { type: string, format: date-time, nullable: true }
        plannedDurationMinutes: { type: integer, nullable: true }
        estimatedAmount: { type: number, nullable: true }
        location: { $ref: '#/components/schemas/Location' }
        contact: { $ref: '#/components/schemas/Contact' }
        contractorEmail: { type: string, format: email }
        assignedCollaboratorEmail: { type: string, format: email, nullable: true }
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        receivedAt: { type: string, format: date-time, nullable: true }
        interventionNotes: { type: string, nullable: true }
        receptionNotes: { type: string, nullable: true }
        startedGeopoint: { $ref: '#/components/schemas/Geopoint' }
        completedGeopoint: { $ref: '#/components/schemas/Geopoint' }
        photosBefore:
          type: array
          items: { type: string, format: uri }
          description: URLs Firebase Storage signées (1h de validité).
        photosAfter:
          type: array
          items: { type: string, format: uri }
        visits:
          type: array
          items: { $ref: '#/components/schemas/Visit' }
        statusHistory:
          type: array
          items: { $ref: '#/components/schemas/StatusHistoryEntry' }
        updatedAt: { type: string, format: date-time }

    WorkOrderList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/WorkOrder' }
        nextCursor:
          type: string
          nullable: true
          description: Présent s'il reste des éléments. Repasser tel quel sur la requête suivante.

    Inspector:
      type: object
      properties:
        userId: { type: string, nullable: true }
        name: { type: string }
        role: { type: string }
        providerId: { type: string, nullable: true }
        providerName: { type: string, nullable: true }

    Inspection:
      type: object
      required: [id, equipmentId, type, timestamp, inspector]
      properties:
        id: { type: string }
        equipmentId: { type: string }
        equipmentLabel: { type: string, description: "Type lisible (ex: 'Extincteur')." }
        equipmentLocation: { $ref: '#/components/schemas/Location' }
        type: { $ref: '#/components/schemas/InspectionType' }
        timestamp: { type: string, format: date-time }
        inspector: { $ref: '#/components/schemas/Inspector' }
        geopoint: { $ref: '#/components/schemas/Geopoint' }
        checklist:
          type: object
          additionalProperties: true
          description: Snapshot des items contrôlés (clé = item, valeur = bool/string).
        anomalies:
          type: array
          nullable: true
          items: { type: string }
        photoUrls:
          type: array
          items: { type: string, format: uri }

    InspectionList:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Inspection' }
        nextCursor:
          type: string
          nullable: true

    WebhookEvent:
      type: object
      required: [event, eventId, occurredAt, workOrderId, reference, toStatus, workOrder]
      properties:
        event:
          type: string
          enum: [work_order.status_changed]
          description: Type d'événement.
        eventId:
          type: string
          description: |
            Identifiant unique stable pour cet événement. À utiliser comme
            clé d'idempotence côté partenaire.
          example: "evt_abc123_termine_1714305600000"
        occurredAt:
          type: string
          format: date-time
          description: Horodatage côté MDE au moment du dispatch.
        workOrderId:
          type: string
          description: ID interne du BC.
        reference:
          type: string
          example: BC-2026-0042
        fromStatus:
          $ref: '#/components/schemas/WorkOrderStatus'
        toStatus:
          $ref: '#/components/schemas/WorkOrderStatus'
        workOrder:
          $ref: '#/components/schemas/WorkOrder'
          description: Snapshot complet du BC après transition.
