Aller au contenu

Ingénierie

Versions de devis : négocier en plusieurs tours sans réécrire l'historique

Par Jahangir Alam · 15 octobre 2026 · 17 min de lecture

Dernière vérification
API Shopify
2026-10
Public
Développeurs et agences qui construisent ou auditent la négociation de devis sur Shopify ou sur toute autre plateforme e-commerce
Périmètre
Le versionnage des devis B2B en API Shopify 2026-10 : pourquoi modifier sur place un devis envoyé casse les documents, les validations et l'acceptation ; ce que donnent les commandes provisoires et les modifications de commande de Shopify ; les couches ensemble de travail, brouillon et versions ; ce qu'une version doit contenir ; la création par copy-on-write ; les contre-offres comme versions ; le verrou d'envoi ; la numérotation des versions face aux accès concurrents ; le verrouillage optimiste face au verrouillage pessimiste ; la garde contre l'acceptation périmée ; le remplacement des validations ; les changements de catalogue ; l'acceptation partielle ; les diffs ; et la comparaison avec les tables temporelles, les dimensions de type 2 et l'event sourcing

Un devis envoyé est un document commercial : l'acheteur a un PDF, un approbateur a validé ses chiffres, et une acceptation est peut-être déjà en route. Si l'enregistrement qui le porte peut encore être modifié, ces trois éléments peuvent cesser de correspondre sans que personne ne s'en aperçoive. La solution est un modèle de données où une offre envoyée ne change jamais - chaque modification devient une nouvelle version numérotée - plus les règles de concurrence qui empêchent deux personnes, ou une personne et un onglet de navigateur périmé, d'agir en même temps sur des versions différentes.

Cette page présente cette conception, avec du code simplifié : ce qu'une version doit contenir, comment la créer par copy-on-write, pourquoi une contre-offre est une version et non un correctif, comment numéroter les versions face aux accès concurrents, le verrouillage optimiste face au verrouillage pessimiste pour une négociation qui dure des jours, et la garde qui empêche un acheteur d'accepter une offre déjà remplacée. Quand QuotWay sert d'exemple, il est décrit tel qu'il fonctionne aujourd'hui. Le journal d'audit des validations traite de l'enregistrement que laisse une décision ; cette page traite de ce sur quoi porte la décision.

Les faits Shopify et base de données ci-dessous ont été vérifiés dans leur propre documentation le 15 octobre 2026, en version d'API Shopify 2026-10 et avec PostgreSQL 18 ; les sources sont en fin de page.

Ce que casse la modification d'un devis sur place

Imaginez un seul enregistrement de devis avec ses lignes, modifié chaque fois que l'affaire avance. Quatre choses se passent mal :

  • Le document et les données divergent. Le PDF de l'acheteur indique un prix ; l'enregistrement en indique maintenant un autre. Quand quelqu'un demande ce qui a été proposé le 3 mars, la base de données ne peut pas répondre.
  • Les validations pointent vers des chiffres qui ont bougé. La validation d'une remise de 18 pour cent ne veut rien dire si les lignes qu'elle a approuvées peuvent être modifiées après coup.
  • L'acceptation fait la course avec la modification. Un acheteur qui regarde l'écran de la semaine dernière clique sur accepter une seconde après que le vendeur a réduit une quantité. Quelle offre a-t-il acceptée ?
  • Des tours disparaissent. Une contre-offre qui écrase les prix du vendeur ne laisse aucune trace de ce que chaque partie a proposé - or c'est toute l'histoire d'une négociation.

Chacun de ces problèmes est une question sur quelle offre, et un enregistrement modifié sur place ne peut jamais répondre que « l'offre actuelle ». C'est pourquoi l'architecture de devis confie les versions de proposition à la couche de devis, pas à Shopify.

Ce que Shopify vous donne

Shopify n'a pas d'objet devis : une couche de devis sur Shopify utilise donc soit des commandes provisoires comme devis, soit ses propres enregistrements. Les commandes provisoires sont modifiées sur place : draftOrderUpdate « updates a draft order » (met à jour une commande provisoire), et l'objet DraftOrder porte une liste events et un horodatage updatedAt, mais aucun historique de son contenu. Deux détails documentés comptent ici :

  • « If a checkout has been started for a draft order, any update to the draft will unlink the checkout. » (Si un checkout a été démarré pour une commande provisoire, toute mise à jour de celle-ci détache le checkout.)
  • « Draft orders created on or after April 1, 2025 will be automatically purged after one year of inactivity. » (Les commandes provisoires créées à partir du 1er avril 2025 sont automatiquement purgées après un an d'inactivité.)

Une commande provisoire est donc un bon résultat de négociation et un mauvais enregistrement de celle-ci. Commande provisoire ou devis détaille la machine à états dont un devis a besoin et qui manque à une commande provisoire.

La conception de Shopify pour modifier une commande passée est instructive : une modification de commande est préparée par étapes. orderEditBegin renvoie une commande calculée qui montre les modifications appliquées, et « the session tracks all staged changes until you commit or abandon them » (la session suit toutes les modifications préparées jusqu'à ce que vous les validiez ou les abandonniez). La commande validée n'est jamais à moitié modifiée. Une couche de devis veut la même propriété pour chaque offre qu'elle envoie.

Trois couches : ensemble de travail, brouillon, versions

Couche Modifiable ? À quoi elle sert
Ensemble de travail (lignes en cours) Oui, jusqu'au prochain envoi Les lignes que le vendeur chiffre en ce moment - ajoutées, retirées, quantités changées
Brouillon Oui ; écarté après l'envoi La proposition non enregistrée de l'éditeur : prix, remise, livraison, conditions, échéance, message
Versions Jamais modifiées Un instantané complet par offre envoyée et par contre-offre reçue, numérotées 1, 2, 3 …

L'ensemble de travail a le droit de changer parce que personne ne l'a vu. Dès qu'une offre part, elle devient une version, et l'ensemble de travail se met à jour pour la refléter. Une version n'est jamais écrite qu'une seule fois.

Ce qu'une version doit contenir

Une version doit se suffire à elle-même : quelqu'un qui la lit dans un an ne doit avoir besoin ni de l'ensemble de travail, ni du catalogue, ni du produit pour la comprendre.

Partie Champs Pourquoi c'est copié et non référencé
Identité Devis, numéro de version, qui l'a créée (vendeur, acheteur, système), quand Les tours sont ordonnés par numéro, pas par horodatage
Chaque ligne Identifiants du produit et de la variante, titre et titre de variante tels qu'affichés, quantité, quatre prix : le prix catalogue au moment où la ligne est entrée dans le devis, la référence catalogue (en B2B, le prix du site de l'entreprise), le prix demandé par l'acheteur, le prix proposé ; le total de la ligne Les produits sont renommés et changent de prix ; l'offre ne doit pas changer
Visibilité de la ligne Si l'acheteur voit le prix de la ligne ; une ligne à prix masqué affiche « Inclus » et doit être à 0,00 Les totaux de l'acheteur proviennent du chiffre stocké, jamais de la somme des lignes affichées : une ligne masquée qui porterait un prix pourrait être révélée par soustraction (pourquoi les totaux sont stockés)
Montants Sous-total ; la remise sur l'ensemble du devis avec son type, sa valeur saisie et son montant résolu ; le montant de la livraison et s'il est définitif ; l'estimation de taxe ; les totaux ; la devise Une remise en pourcentage donne un autre montant sur un autre sous-total
Conditions Conditions de paiement, pourcentage d'acompte, échéance, message à l'acheteur Les conditions font partie de ce qui a été proposé

Une petite décision fait beaucoup de travail : une ligne de version garde une référence à la ligne de l'ensemble de travail dont elle provient - délibérément pas une clé étrangère. La ligne de l'ensemble de travail peut être supprimée plus tard ; la version doit lui survivre.

Créer une version : copy-on-write

Un envoi lit l'ensemble de travail et le brouillon, écrit une nouvelle version, et ne touche jamais une version antérieure. Voici comment QuotWay crée une version du vendeur, en simplifié à partir du code de production, sans les noms internes :

async function createVersion(db: Db, quoteId: string, input: ProposalInput) {
  validate(input); // every line priced, nothing negative, fixed discount <= subtotal,
                   // a price-hidden line must be 0.00 (totals are stored, not summed)
  return db.transaction(async (tx) => {
    // Any approval chain still running on the previous version is superseded.
    await tx.approvalChains.markSuperseded({ quoteId, states: ["PENDING", "IN_PROGRESS"] });

    // The next number comes from an atomic increment on the quote row (see "numbering").
    const { currentVersionNumber: versionNumber, currency } =
      await tx.quotes.increment(quoteId, "currentVersionNumber");

    const live = await tx.lineItems.findMany({ quoteId, ids: input.lines.map((l) => l.lineId) });
    const lines = input.lines.map((u, i) => {
      const src = live.byId(u.lineId);
      const qty = u.quantity ?? src.quantity;
      return {
        sourceLineId: src.id,            // a reference, deliberately not a foreign key
        sortOrder: i,
        productId: src.productId, variantId: src.variantId, title: src.title,
        quantity: qty,
        catalogPrice: src.catalogPrice,  // as captured when the line entered the quote
        catalogBaseline: u.catalogBaseline ?? src.catalogBaseline,
        requestedPrice: src.requestedPrice,
        finalPrice: u.finalPrice,
        lineTotal: u.finalPrice.times(qty),
        buyerVisibility: u.buyerVisibility ?? src.buyerVisibility,
      };
    });
    const subtotal = sum(lines.map((l) => l.lineTotal));
    const discount = resolveDiscount(subtotal, input.discountType, input.discountValue);
    const total = subtotal.minus(discount).plus(input.shipping ?? 0).plus(input.taxEstimate);

    // The working set catches up to the offer...
    await tx.lineItems.updateEach(lines.map((l) => ({ id: l.sourceLineId, finalPrice: l.finalPrice, quantity: l.quantity })));
    // ...and the offer itself is a new row. Nothing here updates an earlier version.
    return tx.versions.create({
      quoteId, versionNumber, origin: "MERCHANT", currency,
      subtotal, discount, discountType: input.discountType, discountValue: input.discountValue,
      shipping: input.shipping, taxEstimate: input.taxEstimate, total,
      paymentTerms: input.paymentTerms, depositPct: input.depositPct, expiresAt: input.expiresAt,
      lines,
    });
  });
}

Une fois une version écrite, les seules écritures ultérieures dans QuotWay sont des marquages : l'indication qu'une contre-offre de l'acheteur l'a remplacée, et l'enregistrement de l'acceptation. Aucun chemin de code ne modifie les lignes ou les chiffres d'une version. C'est une propriété du code, pas de la base de données - rien au niveau de la base n'empêcherait une mise à jour -, la formulation honnête est donc « jamais modifiée par l'application », et non une garantie apportée par le stockage. L'article sur le journal d'audit montre comment faire de l'append-only une propriété de la base, avec le même raisonnement.

Une contre-offre est une version, pas un correctif

Il est tentant de stocker une contre-offre comme une liste de modifications par rapport à l'offre du vendeur. Ne le faites pas : un correctif ne se lit qu'en le rejouant sur la bonne base, et cette base peut elle-même avoir changé. Une contre-offre est une version complète, créée par l'autre partie :

  • Copiez toutes les lignes de la version visée par la contre-offre. Les lignes que l'acheteur n'a pas touchées reprennent les prix du vendeur.
  • N'écrasez que ce que l'acheteur a le droit de modifier. Dans QuotWay, ce sont le prix et la quantité de chaque ligne, les notes de ligne, un message et - seulement si la version visée en a déjà un - le pourcentage d'acompte.
  • Reprenez le reste : type et valeur de la remise, livraison, taxe et conditions. Recalculez une remise en pourcentage sur le nouveau sous-total ; refusez une contre-offre dont le sous-total passe sous une remise fixe.
  • Ignorez une contre-offre sur une ligne à prix masqué ; elle garde son prix d'origine.
  • Marquez la version visée comme remplacée par le nouveau numéro, dans la même transaction.

La réponse du vendeur à une contre-offre est à nouveau la version suivante. Rien ne limite le nombre de tours ; l'enregistrement s'allonge d'un instantané par coup joué, et le plus récent est l'offre en vigueur.

Le verrou d'envoi

Avant l'envoi d'une offre, tout est modifiable. Après, plus rien ne l'est - et la vérification doit être la même partout d'où une modification peut venir : l'éditeur, les modifications de lignes, l'automatisation et l'API.

const EDITABLE = new Set(["SUBMITTED", "IN_REVIEW", "COUNTERED"]);

// The editor, line edits and the send endpoint all gate on the same set.
function assertEditable(status: QuoteStatus) {
  if (!EDITABLE.has(status)) {
    throw new NotEditable(`Quote is ${status}. A sent proposal can't change; the next move is a new version.`);
  }
}

// From PROPOSAL_SENT, every edge belongs to the buyer or the clock:
const FROM_PROPOSAL_SENT = [
  { to: "COUNTERED",          actor: "BUYER" },  // the counter is a new version
  { to: "FULLY_ACCEPTED",     actor: "BUYER" },
  { to: "PARTIALLY_ACCEPTED", actor: "BUYER" },
  { to: "DECLINED",           actor: "BUYER" },
  { to: "EXPIRED",            actor: "SYSTEM" },
];

Dans QuotWay, l'API renvoie 409 not_editable pour la même raison. Regardez ce que ce tableau d'états laisse de côté : il n'y a aucune transition du vendeur au départ d'une proposition envoyée. Une offre envoyée reste acceptable jusqu'à ce que l'acheteur agisse ou qu'elle expire ; la version suivante du vendeur fait suite à la contre-offre de l'acheteur, au rejet par l'un des approbateurs du vendeur lui-même, ou - après une acceptation partielle - à la réouverture des lignes toujours ouvertes.

Si votre processus exige qu'un vendeur remplace une offre déjà partie, concevez-le comme un remplacement (supersede), jamais comme une modification : une nouvelle version est créée et celle qui a été envoyée est marquée comme remplacée, si bien qu'une acceptation de l'ancienne échoue à la garde décrite plus bas. Le reste de cette conception prend déjà en charge cette transition ; la proposer ou non est une décision produit, qui dépend de la confiance qu'un acheteur peut accorder à une offre une fois qu'il l'a en main. QuotWay ne la propose pas aujourd'hui.

Les numéros de version face aux accès concurrents

Deux processus peuvent créer une version au même instant : un vendeur qui envoie et une automatisation, ou deux membres du personnel. Les numéros ne doivent jamais entrer en collision. Deux éléments le garantissent :

// An atomic increment on the parent row inside the version's transaction.
// Two writers serialise on that row and receive N and N+1, never the same number.
const { currentVersionNumber } = await tx.quotes.update({
  where: { id: quoteId },
  data: { currentVersionNumber: { increment: 1 } },   // UPDATE ... SET n = n + 1 RETURNING n
});
// Backed by: UNIQUE (quote_id, version_number)

L'incrément est sûr sans verrou explicite grâce à la façon dont l'isolation par défaut de PostgreSQL traite un second UPDATE sur la même ligne : « the would-be updater will wait for the first updating transaction to commit or roll back » (la transaction qui veut mettre à jour attend que la première soit validée ou annulée), puis la ligne « is re-evaluated to see if the updated version of the row still matches » (est réévaluée pour vérifier que sa version mise à jour correspond toujours). La contrainte d'unicité sert de filet de sécurité si quoi que ce soit d'autre écrit un jour un numéro.

Verrouillage optimiste ou verrouillage pessimiste ?

Optimiste Pessimiste
Principe Détecter une modification concurrente au moment de l'écriture, et refuser l'écriture Prendre d'abord un accès exclusif, pour qu'aucun conflit ne soit possible
En SQL UPDATE … WHERE id = $1 AND updated_at = $seen - 0 ligne signifie que quelqu'un est passé avant SELECT … FOR UPDATE, maintenu jusqu'à la fin de la transaction
En HTTP If-Match avec un ETag ; 412 quand la précondition échoue, ou 409 pour un conflit avec l'état actuel Pas d'équivalent standard
Coût Une nouvelle tentative ou un message d'erreur pour le perdant Des écritures bloquées ; un verrou qui ne doit pas survivre à la transaction
Convient à Le temps de réflexion humain : des minutes ou des jours entre la lecture et l'écriture Un travail court et disputé en base, au sein d'une seule requête

Une négociation est le cas d'école du verrouillage optimiste. Un tour peut durer des jours, et vous ne pouvez pas tenir un verrou de base de données pendant le déjeuner d'un acheteur, un week-end ou une validation - le guide EF Core de Microsoft fait la même remarque, en notant que verrouiller pendant toute la durée d'une modification « requires a transaction to span all the operations » (exige qu'une transaction couvre toutes les opérations). L'optimistic offline lock de Martin Fowler « prevents conflicts between concurrent business transactions by detecting a conflict and rolling back the transaction » (évite les conflits entre transactions métier concurrentes en détectant un conflit et en annulant la transaction) ; la version pessimiste permet « only one business transaction at a time » (une seule transaction métier à la fois).

Deux détails sur lesquels on se trompe :

  • Le verrouillage optimiste n'a pas besoin d'une colonne de version. EF Core permet à n'importe quelle colonne de servir de jeton de concurrence, HTTP utilise des ETags, et une condition sur l'état plus l'horodatage fonctionne. Ce qu'il faut, c'est une condition dans la clause WHERE et une vérification qu'une ligne a changé. PostgreSQL compte comme mises à jour les « matched rows whose values did not change » (lignes correspondantes dont les valeurs n'ont pas changé) : vérifiez donc le nombre de lignes, pas si des données ont bougé.
  • SELECT … FOR UPDATE n'empêche pas les lectures. Les verrous de ligne « block only writers and lockers to the same row » (ne bloquent que les écritures et les verrouillages sur la même ligne). Un acheteur peut toujours charger la page ; c'est la seconde écriture qui attend.

QuotWay utilise les deux, chacun là où il convient : des conditions optimistes sur chaque changement d'état (le updatedAt du devis doit être encore celui qui a été lu), et la sérialisation au niveau de la ligne pour l'incrément du numéro, dans une transaction.

La garde contre l'acceptation périmée

Le bouton d'acceptation de l'acheteur doit nommer la version qu'il accepte, et le serveur doit refuser tout ce qui n'est pas l'offre en vigueur. Voici comment QuotWay accepte, en simplifié :

async function accept(db: Db, quoteId: string, versionId: string, buyer: Actor, lineIds?: string[]) {
  return db.transaction(async (tx) => {
    const quote = await tx.quotes.get(quoteId);           // status, updatedAt, currentVersionNumber
    const version = await tx.versions.get(versionId);
    if (!version || version.quoteId !== quoteId) throw new Conflict("VERSION_NOT_FOUND");
    if (version.supersededBy != null)                 throw new Conflict("VERSION_SUPERSEDED");
    if (version.versionNumber !== quote.currentVersionNumber)
      throw new Conflict("WRONG_VERSION");                // a stale screen -> 409

    const lines = await tx.versionLines.findMany({ versionId });
    const accepted = lineIds ? lines.filter((l) => lineIds.includes(l.id)) : lines;
    const decisions = Object.fromEntries(lines.map((l) =>
      [l.id, accepted.includes(l) ? "ACCEPT" : "DEFER"])); // not taken = still open, not declined

    const subset = await tx.acceptances.create({
      quoteId, versionId, actor: buyer, decisions,
      hash: sha256(canonicalJson({ versionId, lines: accepted.map((l) =>
        ({ id: l.id, qty: l.quantity, unit: String(l.finalPrice), total: String(l.lineTotal) })) })),
      lines: accepted.map((l) => ({ versionLineId: l.id, quantity: l.quantity,
                                    unitPrice: l.finalPrice, lineTotal: l.lineTotal })),
    });

    // Second guard: the status moves only if nobody changed the quote since it was read,
    // and only along a legal edge (PROPOSAL_SENT -> *_ACCEPTED).
    await tx.quotes.transition(quoteId, {
      expectedUpdatedAt: quote.updatedAt,                 // 0 rows -> conflict
      to: accepted.length === lines.length ? "FULLY_ACCEPTED" : "PARTIALLY_ACCEPTED",
    });
    return subset;
  });
}

Deux gardes, deux rôles. La vérification de version fait qu'« accepter » signifie « accepter cette offre ». Le changement d'état conditionnel fait que, de deux actions de l'acheteur en concurrence - une acceptation et une contre-offre depuis deux onglets -, exactement une aboutit ; l'autre reçoit un conflit et un écran rafraîchi. L'exemple de 409 donné par la RFC 9110 elle-même est un conflit de versions : c'est donc le bon statut dans les deux cas.

Des numéros uniques ne désignent pas un gagnant unique

L'incrément garantit que deux envois simultanés reçoivent des numéros différents. Il ne décide pas lequel aurait dû avoir lieu. Si deux personnes envoient depuis le même état au même instant, vous obtenez les versions N et N+1, et l'acheteur voit N+1 - qui n'est peut-être pas celle qui a été envoyée par e-mail ou validée. La solution est de revendiquer le changement d'état dans la même transaction que la version, pour que le second expéditeur échoue avant d'écrire quoi que ce soit. Une forme recommandée, pas du code QuotWay :

await db.transaction(async (tx) => {
  const claimed = await tx.quotes.updateMany({
    where: { id: quoteId, status: { in: ["SUBMITTED", "IN_REVIEW", "COUNTERED"] }, updatedAt: seenAt },
    data: { status: "SENDING" },                   // or straight to the target status
  });
  if (claimed.count === 0) throw new Conflict("Someone else sent or changed this quote");
  const v = await createVersionInTx(tx, quoteId, input);
  await evaluateApprovalInTx(tx, v);               // the version and its gate commit together
});

Une nouvelle version remplace les validations en cours

Une validation appartient aux chiffres qu'elle a approuvés. Dans QuotWay, une chaîne de validation est liée à une version, et la création d'une version - par l'une ou l'autre partie - commence par marquer comme remplacée toute chaîne encore en cours sur le devis, dans la même transaction. Un approbateur ne peut pas valider une offre qui a déjà été remplacée. Le journal d'audit des validations détaille ce que cette chaîne enregistre.

Les changements de catalogue ne rechiffrent pas un devis ouvert

La version stocke ses prix : un changement de catalogue après l'envoi ne touche donc pas l'offre. Il ne doit pas non plus toucher la commande : quand un devis accepté devient une commande provisoire Shopify, chaque ligne doit être envoyée avec le prix accepté - pour une ligne de variante, un prix forcé explicite - sinon Shopify la chiffre au prix catalogue actuel de la variante. QuotWay envoie le prix unitaire accepté sur chaque ligne. Les vérifications à la conversion couvrent ensuite ce qui peut légitimement bouger après l'acceptation - taxe, livraison et stock - et pas le prix négocié, qui est fixé. Pourquoi un devis se convertit au mauvais prix présente les cas d'échec, et l'article sur la conversion idempotente explique comment créer cette commande provisoire exactement une fois.

C'est une nouvelle version du vendeur qui est le bon moment pour reprendre un prix modifié. Dans QuotWay, la référence catalogue du site de l'entreprise est recalculée à chaque envoi du vendeur pour les devis B2B ; le prix catalogue capturé, lui, ne l'est pas.

Acceptation partielle : ce que l'acheteur n'a pas pris reste ouvert

Une acceptation est un enregistrement à part entière, lié à une version, avec une décision par ligne - accepter, différer ou refuser - et une copie de la quantité et du prix de chaque ligne acceptée. Différé ne veut pas dire refusé : cela signifie que l'acheteur n'a pas décidé. Plus tard, l'acheteur peut accepter d'autres lignes parmi celles qu'il a différées (un nouvel enregistrement d'acceptation ; les précédents restent intacts), ou le vendeur peut les rouvrir dans une nouvelle version qui ne reprend que les lignes toujours ouvertes, jamais les lignes refusées. Perdu ou toujours ouvert traite du reporting de cette même distinction.

Montrer ce qui a changé

Comme chaque tour est un instantané complet, un diff est une fonction pure de deux versions. Comparez les versions consécutives, faites correspondre les lignes par leur référence à la ligne d'origine, et signalez :

  • les lignes ajoutées, retirées et modifiées, avec les écarts de prix et de quantité ;
  • le total, et la livraison ;
  • la remise, les conditions, l'acompte et l'échéance en tant que champs - pas seulement par leur effet sur le total.

QuotWay affiche, dans la chronologie du vendeur, un diff de chaque version par rapport à la précédente : lignes, quantités, total et livraison. Les changements de remise et de conditions apparaissent à travers le total. L'acheteur voit l'offre en vigueur et la conversation, pas l'historique. Modifier un devis après la demande est le guide côté marchand sur les mêmes règles.

Pourquoi pas des tables temporelles, des dimensions de type 2 ou l'event sourcing ?

Option Ce qu'elle apporte Pourquoi des instantanés par version conviennent mieux aux devis
Tables temporelles versionnées par le système (SQL Server) Un historique automatique des lignes à chaque mise à jour Elles enregistrent chaque écriture - « even if no column values change » (même si aucune valeur de colonne ne change) - et non les offres envoyées. PostgreSQL range les tables versionnées par le système parmi les fonctionnalités SQL non prises en charge : sur Postgres, vous modélisez donc les versions vous-même de toute façon
Dimension à évolution lente de type 2 Une nouvelle ligne par changement, avec des dates d'effet et un indicateur de ligne courante La bonne forme ; une table de versions est ce schéma, avec un numéro de version à la place des dates
Event sourcing Chaque changement comme un événement jamais modifié ; l'état reconstruit par rejeu Lourd pour ce travail - Microsoft conseille de ne l'adopter que quand « its benefits … justify the pattern's complexity » (ses avantages justifient la complexité du modèle) - et rejouer un devis qui a touché Shopify n'est pas sûr
Instantanés par version Un enregistrement complet et lisible par offre Lisible sans rejeu ; l'historique est la suite des offres, ce sur quoi porte un litige

Les versions par instantanés se situent entre les deux : append-only là où cela compte, des tables ordinaires partout ailleurs.

Une checklist pour votre propre développement

  • Une offre envoyée n'est jamais modifiée ; chaque changement est une nouvelle version complète.
  • Une version contient chaque ligne avec ses prix, sa quantité et sa visibilité, ainsi que la remise, la livraison, la taxe, les totaux, la devise, les conditions, l'acompte et l'échéance.
  • Une ligne à prix masqué est à 0,00 et les totaux sont stockés, pas additionnés à partir des lignes affichées.
  • Les lignes de version référencent la ligne de l'ensemble de travail, sans passer par une clé étrangère.
  • Une contre-offre est une version complète ; les lignes non touchées sont reprises.
  • Une seule vérification d'état modifiable, utilisée par l'éditeur, les modifications de lignes, l'automatisation et l'API.
  • Les numéros de version proviennent d'un incrément atomique dans la même transaction, adossé à une contrainte d'unicité.
  • Chaque changement d'état est conditionné à ce qui a été lu ; le perdant reçoit un conflit, pas un écrasement silencieux.
  • Une acceptation nomme sa version et est refusée sauf si cette version est la version en vigueur.
  • Une nouvelle version remplace les validations en cours sur l'ancienne.
  • Le prix accepté est imposé sur la commande ; les changements de catalogue ne peuvent pas rechiffrer un devis accepté.
  • Les lignes différées restent ouvertes ; les lignes refusées ne reviennent pas.
  • Les diffs sont calculés entre versions consécutives et incluent la remise et les conditions en tant que champs.
  • Si les vendeurs peuvent remplacer une offre envoyée, c'est un remplacement, jamais une modification.

Où cela se trouve dans QuotWay

Chaque proposition envoyée par un vendeur et chaque contre-offre faite par un acheteur est une version numérotée et complète ; les lignes et les chiffres d'une version envoyée ne sont jamais modifiés par l'application. Une fois une proposition partie, elle ne change plus : la version suivante vient de la contre-offre de l'acheteur (à partir du forfait Starter), du rejet par l'un de vos propres approbateurs, ou de la réouverture des lignes toujours ouvertes après une acceptation partielle. Une acceptation nomme sa version et est refusée s'il en existe une plus récente, et les lignes acceptées sont enregistrées avec leurs prix, leurs quantités et un hachage de ceux-ci. Le prix accepté est envoyé sur la commande provisoire Shopify : un changement de catalogue ne peut donc pas la rechiffrer. Voir propositions et versions, contre-offres, négociation et forfaits.

Questions fréquentes

Puis-je modifier un devis après l'avoir envoyé ?

Pas en toute sécurité sur place. Traitez une offre envoyée comme fixe et faites de chaque changement une nouvelle version : le document que l'acheteur a en main, la validation et l'acceptation pointent ainsi vers les mêmes chiffres. Dans QuotWay, une proposition envoyée ne peut pas être modifiée ; la version suivante fait suite à la contre-offre de l'acheteur.

Une contre-offre est-elle un nouveau devis ou une nouvelle version ?

Une nouvelle version du même devis : un instantané complet de toutes les lignes, avec les modifications de l'acheteur appliquées et tout le reste repris de la version à laquelle il a répondu.

Verrouillage optimiste ou pessimiste pour les devis ?

Optimiste. Un tour de négociation dure de quelques minutes à plusieurs jours, et un verrou de base de données ne peut pas être maintenu aussi longtemps. Conditionnez chaque écriture à ce qui a été lu et rejetez-la quand la condition échoue.

Le verrouillage optimiste a-t-il besoin d'une colonne de version ?

Non. Toute condition qui prouve que la ligne n'a pas changé fonctionne - un numéro de version, un horodatage de mise à jour, un état ou un ETag HTTP. Vérifiez le nombre de lignes affectées, qui doit être de un.

Que se passe-t-il si l'acheteur accepte une ancienne version ?

Refusez l'acceptation. Elle doit porter l'identifiant de version, et le serveur doit la rejeter avec un conflit sauf si cette version est la version en vigueur, non remplacée.

Qu'advient-il d'une validation quand le devis change ?

Elle doit être remplacée. Une validation est liée à la version qu'elle a approuvée ; une nouvelle version a besoin de sa propre évaluation.

Un changement de prix catalogue affecte-t-il un devis ouvert ?

Pas si la version stocke ses prix. Envoyez explicitement le prix accepté quand le devis devient une commande, sinon la plateforme chiffrera la ligne au prix catalogue actuel.

Faut-il utiliser des tables temporelles pour l'historique des devis ?

Elles enregistrent chaque écriture plutôt que chaque offre, et PostgreSQL ne les intègre pas nativement. Une table de versions - un jeu complet de lignes par offre - répond aux questions qu'une négociation soulève réellement.

Comment montrer ce qui a changé d'un tour à l'autre ?

Comparez les versions consécutives ligne par ligne : lignes ajoutées, retirées et modifiées avec les écarts de prix et de quantité, plus les totaux, la livraison, la remise et les conditions.

Sources

Lues le 15 octobre 2026 :

Le comportement de QuotWay est décrit d'après le code source de l'application, lu le 15 octobre 2026 ; le code est simplifié, sans les noms internes.

Articles liés

Découvrez comment QuotWay gère cela sur votre boutique.

Nous souhaitons déposer des cookies de mesure d'audience pour comprendre comment le site est utilisé. Ils ne sont pas nécessaires : refuser ne change rien au fonctionnement du site, et vous pouvez revenir sur votre choix à tout moment depuis notre page de confidentialité.