Achats

Référence technique du domaine Achats fournisseurs — devis multi-fournisseurs, inbox OCR IA, comparateur, commandes, réception→asset, rapprochement 3 voies et boucle achats↔leasing.

Achats fournisseurs

Périmètre : devis fournisseurs (avec lignes de détail), inbox d'ingestion IA (OCR PDF → relecture humaine → commit), comparateur multi-devis, commandes fournisseurs (PO), réception → création d'assets, factures fournisseurs et rapprochement 3 voies, plus la boucle achats↔leasing (capital financé depuis devis retenu, commande auto à la signature de contrat, indicateur coût réel vs financé).

Chantier 13 — fonctionnellement complet (4 lots mergés). Routes UI sous /workspace/purchasing/*, /workspace/supplier-* et /workspace/purchasing-overview. Routes API sous /api/workspace/purchasing/*, /api/workspace/supplier-quotes/*, /api/workspace/supplier-orders/*, /api/workspace/supplier-invoices/*, /api/workspace/lease-affairs/* et /api/workspace/lease-financings/*. Source de cette page : docs/specs/13-achats-fournisseurs-ia-leasing.md.

Vocabulaire & entités

TermeEntitéDéfinition
Devis fournisseurSupplierQuoteOffre tarifaire d'un fournisseur avec lignes de détail. Peut être rattaché à une affaire de leasing (N:N).
Ligne de devisSupplierQuoteLineDésignation, quantité, prix unitaire HT, TVA, remise, éco-participation, total calculé, lien catalogue optionnel.
ComparateurLeaseAffairSupplierQuoteTable de jointure N:N affaire↔devis avec flag isRetained (au plus 1 par affaire).
Commande fournisseur (PO)SupplierOrderCommande d'achat avec lignes (SupplierOrderLine). Peut être générée automatiquement à la signature d'un contrat LLD.
RéceptionAction sur SupplierOrder : saisie des quantités reçues par ligne → création d'Asset.
Facture fournisseurSupplierInvoiceFacture reçue avec lignes (SupplierInvoiceLine). Rapprochement 3 voies : facture↔commande↔bon de réception.
Allocation contratSupplierInvoiceAllocationImputation d'une facture fournisseur vers un LeaseContract.
Import IASupplierDocumentImportPDF déposé dans l'inbox ; passe par EXTRACTING → NEEDS_REVIEW → COMMITTED (ou ERROR/DISCARDED).
Capital depuis devisLeaseFinancing.capitalHtCapital LLD alimenté via Σ lignes HT du devis retenu (source tracée : capitalSource=SUPPLIER_QUOTE).

Les totaux (totalHt, totalVat, totalTtc) sont toujours recalculés serveur à partir des lignes (RG-13-01). Les champs stockés sont dénormalisés pour le tri/perf, jamais source de vérité.

Écrans

ÉcranRouteContenu
Vue d'ensemble achats/workspace/purchasing-overviewKPIs globaux achats/commandes/factures.
Inbox OCR/workspace/purchasing/importDropzone multi-PDF + liste des imports avec statut et actions.
Relecture import/workspace/purchasing/import/[id]Form pré-rempli par l'IA (en-tête + grille de lignes éditable) ; commit ou rejet.
Liste devis fournisseurs/workspace/supplier-quotesDataTable avec filtres statut, fournisseur, montant.
Fiche devis/workspace/supplier-quotes/[id]Détail + grille SupplierLineGrid éditable.
Liste commandes/workspace/supplier-ordersDataTable + filtres statut/contrat lié.
Fiche commande/workspace/supplier-orders/[id]Lignes + réception par ligne + lien contrat LLD.
Liste factures fournisseurs/workspace/supplier-invoicesDataTable avec rapprochement et statut 3 voies.
Fiche facture/workspace/supplier-invoices/[id]Lignes + vue rapprochement 3 voies + allocation contrat.

Modèle de données (points clés)

  • Lignes de détail : 3 modèles iso-convention côté client — SupplierQuoteLine, SupplierOrderLine, SupplierInvoiceLine. Chaque ligne porte position, designation, quantity, unitPriceHt, vatRatePct, discountPct, ecoTaxHt, lineTotalHt (calculé), assetModelId?.
  • Traçabilité : SupplierOrderLine.sourceQuoteLineId → ligne de devis source ; SupplierInvoiceLine.sourceOrderLineId → ligne de commande source (base du rapprochement 3 voies RG-13-12).
  • Inbox IA : SupplierDocumentImport avec fileId (PDF), detectedType (enum QUOTE|INVOICE|CREDIT_NOTE|ORDER|UNKNOWN), status (enum EXTRACTING|NEEDS_REVIEW|COMMITTED|DISCARDED|ERROR), rawExtraction: Json?, matchedSupplierId?. Unicité par [organizationId, fileId] (idempotence RG-13-04).
  • Boucle leasing : LeaseAffairSupplierQuote (N:N, isRetained). LeaseFinancing étendu avec capitalSource: CapitalSource (MANUAL|SUPPLIER_QUOTE) et sourceSupplierQuoteId?. SupplierOrder étendu avec leaseContractId? et leaseAffairId?.
  • Migration zero-downtime : colonnes nullables ajoutées en migration additive ; backfill des anciens documents sans lignes via prisma/backfill/13-supplier-lines-reprise.ts (ligne unique designation="Reprise — montant global").

API

Toutes les routes exigent les permissions purchasing.* ou les permissions existantes du domaine concerné.

MéthodeRoutePermission
POST/api/workspace/purchasing/importspurchasing.import:create
GET/api/workspace/purchasing/importspurchasing.import:read
GET/api/workspace/purchasing/imports/[id]purchasing.import:read
POST/api/workspace/purchasing/imports/[id]/reextractpurchasing.import:create
POST/api/workspace/purchasing/imports/[id]/commitpurchasing.import:commit
DELETE/api/workspace/purchasing/imports/[id]purchasing.import:delete
POST/api/workspace/purchasing/imports/[id]/create-supplier-papperssupplier:create
PUT/api/workspace/supplier-quotes/[id]/linessupplier-quote:update
PUT/api/workspace/supplier-orders/[id]/linessupplier-order:update
PUT/api/workspace/supplier-invoices/[id]/linessupplier-invoice:update
GET/POST/api/workspace/lease-affairs/[id]/quoteslease.affair:read / :update
POST/api/workspace/lease-affairs/[id]/quotes/[quoteId]/retainlease.affair:update
GET/api/workspace/lease-affairs/[id]/quote-comparisonlease.affair:read
POST/api/workspace/lease-financings/[id]/capital-from-quotelease.financing:update
POST/api/workspace/supplier-orders/[id]/receivesupplier-order:update
GET/api/workspace/supplier-invoices/[id]/match-linessupplier-invoice:read
POST/api/workspace/supplier-invoices/[id]/allocate-contractsupplier-invoice:update

Les lignes utilisent un PUT « set complet » (remplacement transactionnel de toutes les lignes + recalcul des totaux) plutôt qu'un CRUD ligne à ligne — cohérent avec une grille éditable UI, évite les désynchros de totaux.

Exemple de référence d'endpoint :

POST/api/workspace/lease-financings/[id]/capital-from-quoteAuth

Prévisualise ou applique le remplissage du capital LLD depuis le devis retenu.

Corps (JSON)

supplierQuoteId
string required
Identifiant du devis fournisseur source (doit être retenu sur l'affaire liée).

::field{name="mode" type=""preview" | "apply"" required} preview calcule sans persister ; apply persiste, pose capitalSource=SUPPLIER_QUOTE et audit-logge. ::

Requête

curl -s -X POST "$API/api/workspace/lease-financings/$FIN_ID/capital-from-quote" \
  -H "Content-Type: application/json" -H "Cookie: $SESSION" \
  -d '{"supplierQuoteId":"sqt_...","mode":"preview"}'

Réponse

{
  "capitalHt": 18500,
  "previousCapitalHt": 15000,
  "periodicPmtHt": 412.50,
  "previousPeriodicPmtHt": 338.20,
  "totalInterestHt": 1450,
  "deltaPmtPct": 22.0
}

Workflows

Inbox OCR IA (ingestion Scaleway pixtral-12b)

Dépôt PDF (1..N)
  → upload S3 + hash SHA-256 (idempotence RG-13-04)
  → SupplierDocumentImport { status: EXTRACTING }
  → job IA (purchase-document-extract) :
      détection type + extraction en-tête + lignes
      match SIRET → Supplier existant ou "Créer via Pappers"
  → status: NEEDS_REVIEW (ou ERROR si budget IA épuisé RG-13-05)
  → relecture humaine (form pré-rempli)
  → POST /commit { type, supplierId, documentNumber, lines[] }
  → SupplierQuote | SupplierInvoice créé + lignes
  → status: COMMITTED

L'IA propose, l'humain valide — aucune création automatique sans passage par NEEDS_REVIEW (RG-13-03). Si le budget mensuel est épuisé, l'import passe ERROR et la saisie manuelle reste disponible.

Comparateur multi-devis et capital LLD

LeaseAffair
  → POST /quotes  (rattacher ≥ 1 devis)
  → GET  /quote-comparison  (tableau lignes comparées, moins-disant surligné)
  → POST /quotes/[id]/retain  (isRetained=true, dé-retient l'ancien RG-13-07)
  → POST /lease-financings/[id]/capital-from-quote { mode:"preview" }
      ↓ aperçu PMT recalculé
  → POST ...capital-from-quote { mode:"apply" }
      ↓ capitalHt = Σ lignes HT, capitalSource=SUPPLIER_QUOTE

Commande automatique à la signature de contrat

LeaseContract → signature
  → onContractSigned() (hook service)
  → generateFromRetainedQuote(orgId, contractId)
      ↓ copie lignes du devis retenu
      ↓ SupplierOrder { status: DRAFT, leaseContractId, leaseAffairId }
  [idempotent : 1 seul PO auto/contrat, re-signature sans effet RG-13-09]
  → Confirmation manuelle + envoi fournisseur (action "validate-and-send")

Réception → création d'assets

SupplierOrder { status: CONFIRMED }
  → POST /receive { lines: [{ orderLineId, receivedQuantity, serialNumbers? }] }
  → pour chaque ligne reçue :
      Asset { acquisitionAmountHt=unitPriceHt, leaseContractId, modelId=assetModelId }
  → SupplierOrderLine.receivedQuantity cumulé (≤ quantity commandée RG-13-11)
  → SupplierOrder.status → RECEIVED | PARTIALLY_RECEIVED

Rapprochement 3 voies facture↔commande↔réception

SupplierInvoice + lignes (via inbox OCR ou saisie)
  → GET /match-lines
      ↓ écart par ligne via sourceOrderLineId → sourceQuoteLineId
      tolérance 5 % → OK | MISMATCH
  → MISMATCH → blocage validation (override ADMIN avec motif obligatoire, audit)
  → POST /allocate-contract { leaseContractId }
      ↓ SupplierInvoiceAllocation (≤ totalHt facture RG-13-13)
      ↓ indicateur "coût réel vs financé" mis à jour sur le contrat

Règles métier

  • RG-13-01 — Totaux totalHt/totalVat/totalTtc toujours recalculés serveur à partir des lignes. Jamais saisis.
  • RG-13-02lineTotalHt = round2(quantity × unitPriceHt × (1 − discountPct/100)) + ecoTaxHt. Arrondi ROUND_HALF_UP 2 décimales (cohérent server/lib/finance.ts:roundCents).
  • RG-13-03 — Aucune création d'entité réelle sans validation humaine explicite (passage NEEDS_REVIEWcommit). L'IA ne crée jamais en aveugle.
  • RG-13-04SupplierDocumentImport idempotent par hash fichier (File.checksumSha256) par organisation : redéposer le même PDF réutilise l'import existant non-DISCARDED.
  • RG-13-05 — Extraction IA bloquée si isAiBudgetExhausted() → import ERROR, message explicite, saisie manuelle disponible.
  • RG-13-06 — Un devis n'est rattachable qu'à une affaire de la même organisation.
  • RG-13-07 — Au plus 1 devis isRetained=true par LeaseAffair. Retenir un nouveau devis dé-retient l'ancien (transaction).
  • RG-13-08 — Capital depuis devis exige capitalSource=SUPPLIER_QUOTE. Modification manuelle ultérieure repasse capitalSource=MANUAL (avec sourceSupplierQuoteId conservé pour audit).
  • RG-13-09/10 — Génération de commande à la signature : idempotente (1 seul PO auto par contrat), statut DRAFT, envoi au fournisseur toujours manuel.
  • RG-13-11 — Assets créés à la réception (jamais à la signature). Réception partielle cumulable, jamais au-delà de la quantité commandée.
  • RG-13-12 — Rapprochement 3 voies au niveau ligne via sourceOrderLineId. Écart > 5 % → MISMATCH, bloquant sauf override ADMIN (motif + audit).
  • RG-13-13 — Allocation facture → contrat ne dépasse jamais le totalHt de la facture.
  • RG-13-14 — Type UNKNOWN détecté : l'écran de relecture s'ouvre mais l'utilisateur doit choisir le type avant le commit.

Codes d'erreur métier

CodeHTTPDescription
AI_BUDGET_EXHAUSTED402Budget IA mensuel épuisé — saisie manuelle disponible.
IMPORT_ALREADY_COMMITTED409L'import a déjà été validé ; ré-extraction impossible.
QUOTE_ALREADY_ATTACHED409Ce devis est déjà rattaché à l'affaire.
QUOTE_NO_LINES422Le devis retenu n'a aucune ligne (bloque capital-from-quote).
ORDER_ALREADY_GENERATED409Un PO a déjà été généré pour ce contrat.
RECEIVE_OVER_QUANTITY422Quantité reçue supérieure à la quantité commandée.