---
title: "Unveränderliche Angebotsversionen: Verhandlung über mehrere Runden, ohne die Historie zu überschreiben"
description: "Ein gesendetes Angebot darf sich nie ändern. Angebotsversionierung für B2B: Copy-on-Write-Snapshots, Gegenangebote als Versionen und Optimistic Locking."
url: "https://www.quotway.com/de/blog/immutable-quote-versions"
type: "blog post"
category: "Engineering"
published: "2026-10-15"
verified: "2026-10-15"
shopify_api_version: "2026-10"
audience: "Entwickler und Agenturen, die Angebotsverhandlungen auf Shopify oder einer anderen Commerce-Plattform bauen oder prüfen"
scope: "Versionierung von B2B-Angeboten bei Shopify API 2026-10: warum das Bearbeiten eines gesendeten Angebots an Ort und Stelle Dokumente, Freigaben und Annahmen bricht; was Shopifys Bestellentwürfe und Bestellbearbeitungen bieten; die Ebenen Arbeitsstand, Entwurf und Version; was eine Version enthalten muss; Erstellung per Copy-on-Write; Gegenangebote als Versionen; die Versandsperre; Versionsnummern bei Nebenläufigkeit; Optimistic Locking im Vergleich zu Pessimistic Locking; der Schutz vor veralteten Annahmen; das Ablösen von Freigaben; Katalogänderungen; Teilannahme; Diffs; und Temporal Tables, Dimensionen vom Typ 2 und Event Sourcing im Vergleich"
locale: "de"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

# Unveränderliche Angebotsversionen: Verhandlung über mehrere Runden, ohne die Historie zu überschreiben

Ein gesendetes Angebot ist ein kaufmännisches Dokument: Der Käufer hat ein PDF, eine Freigabeperson hat seine Zahlen abgezeichnet, und eine Annahme ist womöglich schon unterwegs. Lässt sich die Zeile dahinter noch bearbeiten, können alle drei unbemerkt auseinanderlaufen. Die Lösung ist eine Angebotsversionierung, bei der sich ein gesendetes Angebot nie ändert – jede Änderung wird zu einer neuen, nummerierten Version –, plus die Regeln für Nebenläufigkeit, die verhindern, dass zwei Personen, oder eine Person und ein veralteter Browser-Tab, gleichzeitig auf unterschiedlichen Versionen handeln.

Diese Seite ist dieses Design, mit vereinfachtem Code: was eine Version enthalten muss, wie man sie per Copy-on-Write erstellt, warum ein Gegenangebot eine Version und kein Patch ist, wie man Versionen bei Nebenläufigkeit nummeriert, Optimistic im Vergleich zu Pessimistic Locking für eine Verhandlung, die Tage dauert, und der Schutz, der verhindert, dass ein Käufer ein Angebot annimmt, das bereits ersetzt wurde. Wo QuotWay das Beispiel ist, wird es so beschrieben, wie es heute funktioniert. [Das Audit-Log für Freigaben](/blog/pricing-approval-audit-log) behandelt den Datensatz, den eine Entscheidung hinterlässt; diese Seite behandelt das, worüber entschieden wird.

Die Shopify- und Datenbankfakten unten wurden am 15. Oktober 2026 gegen die jeweils eigene Dokumentation geprüft, bei Shopify API-Version 2026-10 und PostgreSQL 18; die Quellen stehen am Ende.

## Was das Bearbeiten eines Angebots an Ort und Stelle bricht
Stellen Sie sich eine Angebotszeile mit ihren Positionen vor, die jedes Mal bearbeitet wird, wenn sich das Geschäft bewegt. Vier Dinge gehen schief:

- **Dokument und Daten widersprechen sich.** Das PDF des Käufers nennt einen Preis, die Zeile inzwischen einen anderen. Fragt jemand, was am 3. März angeboten wurde, kann die Datenbank es nicht beantworten.
- **Freigaben zeigen auf Zahlen, die sich bewegt haben.** Eine Freigabe für 18 Prozent Rabatt bedeutet nichts, wenn sich die freigegebenen Positionen nach der Freigabe ändern lassen.
- **Die Annahme läuft der Bearbeitung hinterher.** Ein Käufer, der auf den Bildschirm von letzter Woche schaut, klickt auf „Annehmen“, eine Sekunde nachdem der Verkäufer eine Menge reduziert hat. Welches Angebot hat er angenommen?
- **Runden verschwinden.** Ein Gegenangebot, das die Preise des Verkäufers überschreibt, hinterlässt keine Spur davon, was eine der beiden Seiten vorgeschlagen hat – und genau das ist die ganze Geschichte einer Verhandlung.

Jede dieser Fragen lautet: *welches* Angebot? Und eine Zeile, die an Ort und Stelle bearbeitet wird, kann immer nur „das aktuelle“ antworten. Deshalb gibt [die Angebotsarchitektur](/blog/shopify-b2b-quote-architecture) die Angebotsversionen der Angebotsebene und nicht Shopify.

## Was Shopify Ihnen gibt
Shopify hat kein Angebotsobjekt. Eine Angebotsebene auf Shopify nutzt also entweder Bestellentwürfe als ihre Angebote oder führt eigene. Bestellentwürfe werden an Ort und Stelle bearbeitet: `draftOrderUpdate` „updates a draft order“ (aktualisiert einen Bestellentwurf), und das `DraftOrder`-Objekt trägt eine `events`-Liste und einen `updatedAt`-Zeitstempel, aber keine Historie seines Inhalts. Zwei dokumentierte Details sind hier wichtig:

- „If a checkout has been started for a draft order, any update to the draft will unlink the checkout.“ (Wurde für einen Bestellentwurf ein Checkout begonnen, löst jede Aktualisierung des Entwurfs die Verknüpfung mit dem Checkout.)
- „Draft orders created on or after April 1, 2025 will be automatically purged after one year of inactivity.“ (Bestellentwürfe, die am oder nach dem 1. April 2025 erstellt wurden, werden nach einem Jahr Inaktivität automatisch gelöscht.)

Ein Bestellentwurf ist also ein gutes *Ergebnis* einer Verhandlung und ein schlechter Nachweis davon. [Bestellentwurf vs. Angebot](/blog/shopify-draft-order-vs-quote) geht die Zustandsmaschine durch, die ein Angebot braucht und die einem Bestellentwurf fehlt.

Shopifys eigenes Design für das Ändern einer aufgegebenen Bestellung ist aufschlussreich: Eine Bestellbearbeitung wird bereitgestellt, bevor sie gilt. `orderEditBegin` liefert eine berechnete Bestellung mit den angewendeten Änderungen, und „the session tracks all staged changes until you commit or abandon them“ (die Sitzung verfolgt alle bereitgestellten Änderungen, bis Sie sie festschreiben oder verwerfen). Die festgeschriebene Bestellung ist nie halb bearbeitet. Eine Angebotsebene will dieselbe Eigenschaft für jedes Angebot, das sie sendet.

## Drei Ebenen der Versionierung: Arbeitsstand, Entwurf, Versionen
| Ebene | Veränderbar? | Wofür sie da ist |
| --- | --- | --- |
| **Arbeitsstand** (Live-Positionen) | Ja, bis zum nächsten Versand | Die Positionen, die der Verkäufer gerade bepreist – hinzugefügt, entfernt, mit neuer Menge |
| **Entwurf** | Ja; nach dem Versand verworfen | Das ungespeicherte Angebot im Editor: Preise, Rabatt, Versand, Konditionen, Ablaufdatum, Nachricht |
| **Versionen** | Nie bearbeitet | Ein vollständiger Snapshot je gesendetem Angebot und je erhaltenem Gegenangebot, nummeriert 1, 2, 3 … |

Der Arbeitsstand darf sich ändern, weil ihn niemand gesehen hat. In dem Moment, in dem ein Angebot hinausgeht, wird es zu einer Version, und der Arbeitsstand zieht nach. Eine Version wird immer nur einmal geschrieben.

## Was eine Version enthalten muss
Eine Version muss für sich allein stehen: Wer sie in einem Jahr liest, darf weder den Arbeitsstand noch den Katalog noch das Produkt brauchen, um sie zu verstehen.

| Teil | Felder | Warum kopiert und nicht referenziert |
| --- | --- | --- |
| **Identität** | Angebot, Versionsnummer, wer sie erstellt hat (Verkäufer, Käufer, System), wann | Runden werden nach Nummer geordnet, nicht nach Zeitstempel |
| **Jede Position** | Produkt- und Varianten-IDs, Titel und Variantentitel wie angezeigt, Menge, vier Preise: der Katalogpreis zum Zeitpunkt, als die Position ins Angebot kam, die Katalog-Basis (bei B2B der Preis des Unternehmensstandorts), der vom Käufer angefragte Preis, der angebotene Preis; die Positionssumme | Produkte werden umbenannt und neu bepreist; das Angebot darf das nicht |
| **Sichtbarkeit der Position** | Ob der Käufer den Preis der Position sieht; eine Position mit ausgeblendetem Preis zeigt „Inklusive“ und muss 0,00 sein | Die Summen des Käufers stammen aus dem gespeicherten Betrag und werden nie aus den angezeigten Zeilen summiert, sodass sich eine ausgeblendete Position mit Preis per Subtraktion verraten könnte ([warum Summen gespeichert werden](/blog/never-sum-the-total-from-the-rows)) |
| **Geld** | Zwischensumme; der Rabatt auf das ganze Angebot als Typ, Eingabewert und aufgelöster Betrag; Versandbetrag und ob er endgültig ist; Steuerschätzung; Summen; Währung | Ein prozentualer Rabatt löst sich auf einer anderen Zwischensumme anders auf |
| **Konditionen** | Zahlungsziele, Anzahlung in Prozent, Ablaufdatum, die Nachricht an den Käufer | Konditionen sind Teil dessen, was angeboten wurde |

Eine kleine Entscheidung leistet viel: Eine Versionsposition behält eine *Referenz* auf die Position im Arbeitsstand, aus der sie stammt – bewusst keinen Fremdschlüssel. Die Position im Arbeitsstand kann später gelöscht werden; die Version muss das überstehen.

## Eine Version erstellen: Copy-on-Write
Ein Versand liest den Arbeitsstand und den Entwurf, schreibt eine neue Version und berührt nie eine frühere. So erstellt QuotWay die Version eines Verkäufers, vereinfacht aus dem Produktivcode, interne Bezeichner entfernt:

```ts
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,
    });
  });
}
```

Nachdem eine Version geschrieben ist, sind die einzigen späteren Schreibvorgänge in QuotWay Stempel: eine Markierung, dass ein Gegenangebot des Käufers sie abgelöst hat, und der Annahmedatensatz. Kein Codepfad ändert die Positionen oder Zahlen einer Version. Das ist eine Eigenschaft des Codes, nicht der Datenbank – auf Datenbankebene würde nichts ein Update verhindern –, die ehrliche Aussage lautet also „vom App-Code nie bearbeitet“, nicht „unveränderlich im Speicher“. [Der Beitrag zum Audit-Log](/blog/pricing-approval-audit-log#append-only) zeigt mit derselben Begründung, wie man append-only zu einer Eigenschaft der Datenbank macht.

## Ein Gegenangebot ist eine Version, kein Patch
Es liegt nahe, ein Gegenangebot als Liste von Änderungen gegenüber dem Angebot des Verkäufers zu speichern. Tun Sie das nicht: Ein Patch lässt sich nur lesen, indem man ihn auf die richtige Basis anwendet, und die Basis kann sich selbst geändert haben. Ein Gegenangebot ist eine vollständige Version, erstellt von der anderen Seite:

- Kopieren Sie **jede** Position der Version, auf die geantwortet wird. Positionen, die der Käufer nicht angefasst hat, übernehmen die Preise des Verkäufers.
- Überschreiben Sie nur, was der Käufer ändern darf. In QuotWay sind das Preis und Menge jeder Position, Positionsnotizen, eine Nachricht und – nur wenn die Version, auf die geantwortet wird, bereits eine hat – die Anzahlung in Prozent.
- Übernehmen Sie den Rest: Rabatttyp und -wert, Versand, Steuer und Konditionen. Lösen Sie einen prozentualen Rabatt gegen die neue Zwischensumme neu auf; lehnen Sie ein Gegenangebot ab, dessen Zwischensumme unter einen festen Rabatt fällt.
- Ignorieren Sie ein Gegenangebot auf eine Position mit ausgeblendetem Preis; sie behält ihren Quellpreis.
- Markieren Sie die Version, auf die geantwortet wurde, in derselben Transaktion als abgelöst durch die neue Nummer.

Die Antwort des Verkäufers auf ein Gegenangebot ist wieder die nächste Version. Nichts begrenzt die Zahl der Runden; der Datensatz wächst um einen Snapshot je Zug, und der neueste ist das gültige Angebot.

## Die Versandsperre
Bevor ein Angebot gesendet ist, ist alles bearbeitbar. Danach nichts mehr – und die Prüfung muss überall dieselbe sein, wo eine Änderung herkommen kann: im Editor, bei Positionsänderungen, in der Automatisierung und in der API.

```ts
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" },
];
```

In QuotWay liefert die API aus demselben Grund `409 not_editable`. Achten Sie darauf, was diese Zustandstabelle auslässt: Es gibt keinen Übergang des Verkäufers aus einem gesendeten Vorschlag heraus. Ein gesendetes Angebot bleibt annehmbar, bis der Käufer handelt oder es abläuft; die nächste Version des Verkäufers folgt auf das Gegenangebot des Käufers, auf eine Ablehnung durch eine der eigenen Freigabepersonen des Verkäufers oder – nach einer Teilannahme – auf das Wiedereröffnen der Positionen, die weiterhin offen sind.

Braucht Ihr Prozess, dass ein Verkäufer ein bereits gesendetes Angebot ersetzen kann, dann gestalten Sie das als **Ablösung**, nie als Bearbeitung: Eine neue Version wird erstellt und die gesendete als abgelöst markiert, sodass eine Annahme der alten am Schutz weiter unten scheitert. Der Rest dieses Designs unterstützt diesen Übergang bereits; ob man ihn anbietet, ist eine Produktentscheidung darüber, wie sehr sich ein Käufer auf ein Angebot verlassen darf, sobald er es in der Hand hat. QuotWay bietet ihn heute nicht an.

## Versionsnummern bei Nebenläufigkeit
Zwei Schreibende können im selben Moment eine Version erstellen: ein Verkäufer, der sendet, und eine Automatisierung, oder zwei Teammitglieder. Die Nummern dürfen nie kollidieren. Zwei Teile sorgen dafür:

```ts
// 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)
```

Das Hochzählen ist ohne expliziten Lock sicher, weil PostgreSQLs Standard-Isolationsstufe ein zweites `UPDATE` auf derselben Zeile so behandelt: „the would-be updater will wait for the first updating transaction to commit or roll back“ (der zweite Schreibende wartet, bis die erste aktualisierende Transaktion festgeschrieben oder zurückgerollt ist), danach gilt für die Zeile: Sie „is re-evaluated to see if the updated version of the row still matches“ (wird neu geprüft, ob die aktualisierte Fassung der Zeile noch passt). Der Unique-Constraint ist die Absicherung, falls jemals etwas anderes eine Nummer schreibt.

## Optimistic Locking oder Pessimistic Locking?
| | Optimistic Locking | Pessimistic Locking |
| --- | --- | --- |
| Idee | Eine konkurrierende Änderung beim Schreiben erkennen und ablehnen | Zuerst exklusiven Zugriff nehmen, sodass kein Konflikt entstehen kann |
| In SQL | `UPDATE … WHERE id = $1 AND updated_at = $seen` – 0 Zeilen heißt, jemand anderes war schneller | `SELECT … FOR UPDATE`, gehalten bis zum Ende der Transaktion |
| Über HTTP | `If-Match` mit einem ETag; 412, wenn die Vorbedingung scheitert, oder 409 bei einem Konflikt mit dem aktuellen Zustand | Kein standardisiertes Gegenstück |
| Kosten | Ein erneuter Versuch oder eine Fehlermeldung für den Unterlegenen | Blockierte Schreibende; ein Lock, der die Transaktion nicht überdauern darf |
| Passt zu | Menschlicher Bedenkzeit: Minuten oder Tage zwischen Lesen und Schreiben | Kurzer, umkämpfter Datenbankarbeit innerhalb eines Requests |

Eine Verhandlung ist der Lehrbuchfall für Optimistic Locking. Eine Runde kann Tage dauern, und Sie können keinen Datenbank-Lock über die Mittagspause eines Käufers, ein Wochenende oder eine Freigabe hinweg halten – Microsofts Leitfaden zu EF Core sagt dasselbe und merkt an, dass Sperren für die Dauer einer Bearbeitung „requires a transaction to span all the operations“ (erfordert, dass sich eine Transaktion über alle Operationen erstreckt). Martin Fowlers Optimistic Offline Lock „prevents conflicts between concurrent business transactions by detecting a conflict and rolling back the transaction“ (verhindert Konflikte zwischen gleichzeitigen Geschäftstransaktionen, indem es einen Konflikt erkennt und die Transaktion zurückrollt); die pessimistische Variante erlaubt „only one business transaction at a time“ (nur eine Geschäftstransaktion zur selben Zeit).

Zwei Details, die man leicht falsch versteht:

- **Optimistic Locking braucht keine Versionsspalte.** EF Core lässt jede Spalte zum Concurrency Token werden, HTTP nutzt ETags, und eine Bedingung aus Status plus Zeitstempel funktioniert auch. Was es braucht, ist eine Bedingung in der `WHERE`-Klausel und eine Prüfung, dass genau eine Zeile geändert wurde. PostgreSQL zählt „matched rows whose values did not change“ (getroffene Zeilen, deren Werte sich nicht geändert haben) als aktualisiert – prüfen Sie also die Anzahl, nicht, ob sich Daten bewegt haben.
- **`SELECT … FOR UPDATE` verhindert keine Lesezugriffe.** Zeilensperren „block only writers and lockers to the same row“ (blockieren nur Schreibende und Sperrende auf derselben Zeile). Ein Käufer kann die Seite weiterhin laden; es ist der zweite Schreibvorgang, der wartet.

QuotWay nutzt beides, jeweils dort, wo es passt: optimistische Bedingungen bei jeder Statusänderung (das `updatedAt` des Angebots muss noch dem gelesenen Wert entsprechen) und die Serialisierung auf Zeilenebene beim Hochzählen der Nummer innerhalb einer Transaktion.

## Der Schutz vor veralteten Annahmen
Der Annehmen-Button des Käufers muss die Version nennen, die er annimmt, und der Server muss alles ablehnen, was nicht das aktuelle Angebot ist. So nimmt QuotWay an, vereinfacht:

```ts
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;
  });
}
```

Zwei Schutzmechanismen, zwei Aufgaben. Die Versionsprüfung sorgt dafür, dass „Annehmen“ heißt: *dieses* Angebot annehmen. Die bedingte Statusänderung sorgt dafür, dass von zwei konkurrierenden Aktionen des Käufers – einer Annahme und einem Gegenangebot aus zwei Tabs – genau eine landet; die andere bekommt einen Konflikt und einen frischen Bildschirm. Das eigene Beispiel von RFC 9110 für einen 409 ist ein Versionierungskonflikt, und das ist für beide der richtige Status.

## Eindeutige Nummern bestimmen keinen einzigen Gewinner
Das Hochzählen garantiert, dass zwei gleichzeitige Versände unterschiedliche Nummern bekommen. Es entscheidet nicht, welcher Versand hätte stattfinden sollen. Senden zwei Personen im selben Moment aus demselben Zustand, entstehen die Versionen N und N+1, und der Käufer sieht N+1 – womöglich nicht die, die per E-Mail verschickt oder freigegeben wurde. Die Lösung: Beanspruchen Sie die Statusänderung in derselben Transaktion wie die Version, sodass der zweite Sendende scheitert, bevor er irgendetwas schreibt. Eine empfohlene Form, kein QuotWay-Code:

```ts
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
});
```

## Eine neue Version löst laufende Freigaben ab
Eine Freigabe gehört zu den Zahlen, die sie freigegeben hat. In QuotWay ist eine Freigabekette an eine Version gebunden, und das Erstellen einer Version – durch eine der beiden Seiten – markiert zuerst jede noch laufende Kette am Angebot als abgelöst, in derselben Transaktion. Eine Freigabeperson kann kein Angebot abzeichnen, das bereits ersetzt wurde. [Das Audit-Log für Freigaben](/blog/pricing-approval-audit-log#subject) behandelt, was diese Kette festhält.

## Katalogänderungen bepreisen ein offenes Angebot nicht neu
Die Version speichert ihre Preise, also berührt eine Katalogänderung nach dem Versand das Angebot nicht. Die Bestellung darf sie ebenfalls nicht berühren: Wird aus einem angenommenen Angebot ein Shopify-Bestellentwurf, muss jede Position mit dem angenommenen Preis gesendet werden – bei einer Variantenposition als ausdrückliche Preisüberschreibung –, sonst bepreist Shopify sie zum aktuellen Katalogpreis der Variante. QuotWay sendet bei jeder Position den angenommenen Stückpreis. Die Prüfungen bei der Umwandlung decken dann ab, was sich nach der Annahme legitim bewegen kann – Steuer, Versand und Bestand –, nicht aber den verhandelten Preis, der feststeht. [Warum ein Angebot zum falschen Preis umgewandelt wird](/blog/quote-converts-at-wrong-price) enthält die Fehlerfälle, und [der Beitrag zur idempotenten Umwandlung](/blog/idempotent-draft-order-creation) behandelt, wie dieser Bestellentwurf genau einmal erstellt wird.

Eine neue *Verkäufer*-Version ist der Ort, an dem ein geänderter Preis übernommen wird. In QuotWay wird die Katalog-Basis des Unternehmensstandorts bei B2B-Angeboten bei jedem Versand des Verkäufers neu aufgelöst; der erfasste Katalogpreis nicht.

## Teilannahme: Was der Käufer nicht genommen hat, ist weiterhin offen
Eine Annahme ist ein eigener Datensatz, an eine Version gebunden, mit einer Entscheidung je Position – annehmen, zurückstellen oder ablehnen – und einer Kopie von Menge und Preis jeder angenommenen Position. Zurückgestellt ist nicht abgelehnt: Es heißt, dass der Käufer noch nicht entschieden hat, und die Position ist weiterhin offen. Später kann der Käufer weitere der zurückgestellten Positionen annehmen (ein neuer Annahmedatensatz; die früheren bleiben unberührt), oder der Verkäufer kann sie als neue Version wiedereröffnen, die nur die weiterhin offenen Positionen trägt, nie die abgelehnten. [Verloren oder weiterhin offen](/blog/quote-lost-or-still-open) ist die Reporting-Seite derselben Unterscheidung.

## Zeigen, was sich geändert hat
Ist jede Runde ein vollständiger Snapshot, ist ein Diff eine reine Funktion zweier Versionen. Vergleichen Sie aufeinanderfolgende Versionen, ordnen Sie Positionen über ihre Referenz auf die Quellposition zu und berichten Sie:

- hinzugefügte, entfernte und geänderte Positionen, mit Preis- und Mengendifferenzen;
- die Summe und den Versand;
- Rabatt, Konditionen, Anzahlung und Ablaufdatum als Felder – nicht nur über ihre Wirkung auf die Summe.

QuotWay zeigt in der Timeline des Verkäufers einen Diff jeder Version gegenüber der vorherigen: Positionen, Mengen, Summe und Versand. Änderungen an Rabatt und Konditionen zeigen sich über die Summe. Der Käufer sieht das aktuelle Angebot und die Unterhaltung, nicht die Historie. [Ein Angebot nach der Anfrage bearbeiten](/blog/edit-a-quote-after-the-request) ist der Leitfaden für Händler zu denselben Regeln.

## Warum keine Temporal Tables, Dimensionen vom Typ 2 oder Event Sourcing?
| Option | Was sie bietet | Warum Snapshot-Zeilen besser zu Angeboten passen |
| --- | --- | --- |
| **System-versionierte Temporal Tables** (SQL Server) | Automatische Zeilenhistorie bei jedem Update | Sie halten jeden Schreibvorgang fest – „even if no column values change“ (auch wenn sich keine Spaltenwerte ändern) –, nicht die Angebote, die gesendet wurden. PostgreSQL führt system-versionierte Tabellen als nicht unterstütztes SQL-Feature, auf Postgres modellieren Sie Versionen also ohnehin selbst |
| **Slowly Changing Dimension Typ 2** | Eine neue Zeile je Änderung, mit Gültigkeitsdaten und einem Kennzeichen für die aktuelle Zeile | Die richtige Form; eine Versionstabelle *ist* dieses Muster, mit einer Versionsnummer statt Daten |
| **Event Sourcing** | Jede Änderung als unveränderliches Event; Zustand per Replay wiederhergestellt | Schwer für diese Aufgabe – Microsoft rät, es nur einzusetzen, wenn „its benefits … justify the pattern's complexity“ (seine Vorteile … die Komplexität des Musters rechtfertigen) –, und ein Angebot, das Shopify berührt hat, erneut abzuspielen, ist nicht sicher |
| **Snapshot-Zeilen je Version** | Ein vollständiger, lesbarer Datensatz je Angebot | Lesbar ohne Replay; die Historie ist die Abfolge der Angebote, und genau danach fragt ein Streitfall |

Snapshot-Versionen liegen zwischen beiden: append-only, wo es darauf ankommt, gewöhnliche Tabellen überall sonst.

## Eine Checkliste für Ihren eigenen Aufbau
- Ein gesendetes Angebot wird nie bearbeitet; jede Änderung ist eine neue, vollständige Version.
- Eine Version enthält jede Position mit ihren Preisen, ihrer Menge und Sichtbarkeit, dazu Rabatt, Versand, Steuer, Summen, Währung, Konditionen, Anzahlung und Ablaufdatum.
- Eine Position mit ausgeblendetem Preis ist 0,00, und Summen werden gespeichert, nicht aus angezeigten Zeilen summiert.
- Versionspositionen referenzieren die Position im Arbeitsstand, nicht über einen Fremdschlüssel.
- Ein Gegenangebot ist eine vollständige Version; unberührte Positionen werden übernommen.
- Eine einzige Prüfung auf bearbeitbaren Zustand, genutzt vom Editor, von Positionsänderungen, von der Automatisierung und von der API.
- Versionsnummern stammen aus einem atomaren Hochzählen in derselben Transaktion, abgesichert durch einen Unique-Constraint.
- Jede Statusänderung ist an das Gelesene gebunden; der Unterlegene bekommt einen Konflikt, kein stilles Überschreiben.
- Eine Annahme nennt ihre Version und wird abgelehnt, wenn diese Version nicht die aktuelle ist.
- Eine neue Version löst Freigaben ab, die auf der alten laufen.
- Der angenommene Preis wird der Bestellung aufgezwungen; Katalogänderungen können ein angenommenes Angebot nicht neu bepreisen.
- Zurückgestellte Positionen bleiben offen; abgelehnte kommen nicht zurück.
- Diffs werden zwischen aufeinanderfolgenden Versionen berechnet und enthalten Rabatt und Konditionen als Felder.
- Dürfen Verkäufer ein gesendetes Angebot ersetzen, ist das eine Ablösung, nie eine Bearbeitung.

## Wo das in QuotWay steckt
Jedes Angebot, das ein Verkäufer sendet, und jedes Gegenangebot eines Käufers ist eine nummerierte, vollständige Version; die Positionen und Zahlen einer gesendeten Version werden vom App-Code nie bearbeitet. Ist ein Angebot einmal draußen, ändert es sich nicht: Die nächste Version entsteht aus dem Gegenangebot des Käufers (ab Starter), aus der Ablehnung durch eine Ihrer eigenen Freigabepersonen oder aus dem Wiedereröffnen von Positionen, die nach einer Teilannahme weiterhin offen sind. Eine Annahme nennt ihre Version und wird abgelehnt, wenn es eine neuere gibt, und angenommene Positionen werden mit ihren Preisen, Mengen und einem Hash darüber festgehalten. Der angenommene Preis wird an den Shopify-Bestellentwurf übergeben, sodass eine Katalogänderung ihn nicht neu bepreisen kann. Siehe [Angebote und Versionen](/docs/negotiation/proposals-and-versions), [Gegenangebote](/docs/negotiation/counter-offers-and-buyer-prices), [Verhandlung](/features/negotiation) und [Tarife](/pricing).

## Häufige Fragen

### Kann ich ein Angebot bearbeiten, nachdem es gesendet wurde?

Nicht sicher an Ort und Stelle. Behandeln Sie ein gesendetes Angebot als feststehend und machen Sie jede Änderung zu einer neuen Version, damit das Dokument in der Hand des Käufers, die Freigabe und die Annahme alle auf dieselben Zahlen zeigen. In QuotWay lässt sich ein gesendetes Angebot nicht bearbeiten; die nächste Version folgt auf das Gegenangebot des Käufers.

### Ist ein Gegenangebot ein neues Angebot oder eine neue Version?

Eine neue Version desselben Angebots: ein vollständiger Snapshot jeder Position, mit den Änderungen des Käufers und allem Übrigen aus der Version übernommen, auf die er geantwortet hat.

### Optimistic oder Pessimistic Locking für Angebote?

Optimistic Locking. Eine Verhandlungsrunde dauert Minuten bis Tage, und ein Datenbank-Lock lässt sich nicht so lange halten. Binden Sie jeden Schreibvorgang an das Gelesene und lehnen Sie ihn ab, wenn die Bedingung scheitert.

### Braucht Optimistic Locking eine Versionsspalte?

Nein. Jede Bedingung, die belegt, dass die Zeile unverändert ist, funktioniert – eine Versionsnummer, ein Updated-at-Zeitstempel, ein Status oder ein HTTP-ETag. Prüfen Sie die Anzahl der betroffenen Zeilen; sie muss eins sein.

### Was, wenn der Käufer eine alte Version annimmt?

Lehnen Sie es ab. Die Annahme sollte die Versions-ID tragen, und der Server sollte sie mit einem Konflikt ablehnen, wenn diese Version nicht die aktuelle, nicht abgelöste ist.

### Was passiert mit einer Freigabe, wenn sich das Angebot ändert?

Sie sollte abgelöst werden. Eine Freigabe ist an die Version gebunden, die sie freigegeben hat; eine neue Version braucht ihre eigene Prüfung.

### Wirkt sich eine Änderung des Katalogpreises auf ein offenes Angebot aus?

Nicht, wenn die Version ihre Preise speichert. Senden Sie den angenommenen Preis ausdrücklich mit, wenn aus dem Angebot eine Bestellung wird, sonst bepreist die Plattform die Position zum aktuellen Katalogpreis.

### Sollte ich Temporal Tables für die Angebotshistorie nutzen?

Sie halten jeden Schreibvorgang fest statt jedes Angebots, und PostgreSQL hat sie nicht eingebaut. Eine Versionstabelle – ein vollständiger Satz Zeilen je Angebot – beantwortet die Fragen, die eine Verhandlung tatsächlich aufwirft.

### Wie zeige ich, was sich zwischen den Runden geändert hat?

Vergleichen Sie aufeinanderfolgende Versionen Position für Position: hinzugefügte, entfernte und geänderte Positionen mit Preis- und Mengendifferenzen, dazu Summen, Versand, Rabatt und Konditionen.

## Quellen

Gelesen am 15. Oktober 2026:

- Shopify (API 2026-10): [draftOrderUpdate](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/draftOrderUpdate), [DraftOrder](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DraftOrder), [orderEditBegin](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/orderEditBegin)
- PostgreSQL 18: [Transaktionsisolation](https://www.postgresql.org/docs/current/transaction-iso.html), [explizite Sperren](https://www.postgresql.org/docs/current/explicit-locking.html), [UPDATE](https://www.postgresql.org/docs/current/sql-update.html), [Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html), [nicht unterstützte SQL-Features](https://www.postgresql.org/docs/current/unsupported-features-sql-standard.html)
- Martin Fowler, [Optimistic Offline Lock](https://martinfowler.com/eaaCatalog/optimisticOfflineLock.html) und [Pessimistic Offline Lock](https://martinfowler.com/eaaCatalog/pessimisticOfflineLock.html)
- Microsoft Learn: [EF Core concurrency conflicts](https://learn.microsoft.com/en-us/ef/core/saving/concurrency), [temporal tables](https://learn.microsoft.com/en-us/sql/relational-databases/tables/temporal-tables), [Event Sourcing pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing)
- Kimball Group, [Type 2: add new row](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/type-2/)
- [RFC 9110, HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html) – bedingte Requests (13.1.1), 409 Conflict (15.5.10), 412 Precondition Failed (15.5.13)

Das Verhalten von QuotWay ist aus dem Quellcode der App beschrieben, gelesen am 15. Oktober 2026; der Code ist vereinfacht, interne Bezeichner sind entfernt.
