エンジニアリング
見積のバージョンを書き換えない:履歴を上書きせずに複数ラウンドの交渉を行う
著者 Jahangir Alam · 2026年10月15日 · 17分で読めます
- 最終確認日
- Shopify API
- 2026-10
- 対象読者
- Shopifyまたはその他のコマースプラットフォームで見積交渉を開発・レビューする開発者とエージェンシー
- 範囲
- Shopify API 2026-10時点でのBtoB見積のバージョン管理。送信済みの見積をその場で編集すると、ドキュメント、承認、承諾が壊れる理由。Shopifyの下書き注文と注文編集が提供するもの。作業セット、下書き、バージョンの3つの層。バージョンが保持すべきもの。コピーオンライトによる作成。バージョンとしてのカウンターオファー。送信ロック。同時実行下でのバージョン番号。楽観ロックと悲観ロック。古い承諾を防ぐガード。承認の置き換え。カタログの変更。部分承諾。差分。そしてテンポラルテーブル、タイプ2ディメンション、イベントソーシングとの比較
送信済みの見積は商取引の文書です。バイヤーはPDFを持ち、承認者はその数字を承認し、承諾がすでに向かっている途中かもしれません。その裏にある行がまだ編集できるなら、この3つは気づかないうちに食い違っていきます。解決策は、送信したオファーが決して変わらないデータモデルです。変更はすべて、番号のついた新しいバージョン、つまり見積の修正版になります。それに加えて、2人の担当者、あるいは1人の担当者と古いブラウザのタブが、同時に別々のバージョンに対して操作しないようにする同時実行の規則が必要です。
このページでは、その設計を簡略化したコードとともに説明します。バージョンが保持すべきもの、コピーオンライトでバージョンを作る方法、カウンターオファーがパッチではなくバージョンである理由、同時実行下でのバージョン番号の付け方、何日も続く交渉における楽観ロックと悲観ロック、そしてすでに置き換えられたオファーをバイヤーが承諾するのを止めるガードです。QuotWayを例にする箇所では、現在の動作をそのまま説明します。決定が後に残す記録は承認の監査ログで扱っています。このページが扱うのは、決定の対象そのものです。
以下のShopifyとデータベースに関する事実は、2026年10月15日に、ShopifyのAPIバージョン2026-10とPostgreSQL 18で、それぞれのドキュメントと照合しました。出典は末尾にあります。
見積をその場で編集すると何が壊れるか
見積の行が1つあり、その明細を商談が動くたびに編集する場面を考えてください。4つの問題が起きます。
- ドキュメントとデータが食い違う。 バイヤーのPDFにはある価格が書かれ、行には今は別の価格が入っています。3月3日に何をオファーしたのかと聞かれても、データベースは答えられません。
- 承認が、動いた数字を指している。 18パーセントの値引きの承認は、承認した明細が承認後に変更できるなら意味を持ちません。
- 承諾が編集と競合する。 先週の画面を見ているバイヤーが、販売者が数量を減らした1秒後に承諾をクリックします。バイヤーが承諾したのはどのオファーでしょうか。
- ラウンドが消える。 販売者の価格を上書きするカウンターオファーは、双方が何を提案したかの痕跡を残しません。それこそが交渉の経緯のすべてです。
どれもどのオファーかという問いであり、その場で編集する行は「現在のもの」としか答えられません。だからこそ見積のアーキテクチャでは、プロポーザルのバージョンをShopifyではなく見積レイヤーの役割にしています。
Shopifyが提供するもの
Shopifyには見積のオブジェクトがありません。そのため、Shopify上の見積レイヤーは、下書き注文を見積として使うか、独自の見積を持つかのどちらかです。下書き注文はその場で編集されます。draftOrderUpdate は "updates a draft order"(下書き注文を更新する)もので、DraftOrder オブジェクトは events のリストと updatedAt のタイムスタンプを持ちますが、中身の履歴は持ちません。ここで重要なのは、ドキュメントに書かれた次の2点です。
- "If a checkout has been started for a draft order, any update to the draft will unlink the checkout."(下書き注文でチェックアウトが開始されている場合、下書きを更新するとチェックアウトとのリンクが外れる)
- "Draft orders created on or after April 1, 2025 will be automatically purged after one year of inactivity."(2025年4月1日以降に作成された下書き注文は、1年間操作がないと自動的に削除される)
つまり、下書き注文は交渉の出力としては適していますが、交渉の記録としては不向きです。見積に必要で下書き注文に欠けている状態遷移は、下書き注文と見積の違いで説明しています。
確定した注文を変更するためのShopify自身の設計は参考になります。注文編集はステージングされます。orderEditBegin は変更を適用した計算済みの注文を返し、"the session tracks all staged changes until you commit or abandon them."(セッションは、コミットするか破棄するまで、ステージングしたすべての変更を追跡する)とされています。コミットされた注文が編集途中の状態になることはありません。見積レイヤーは、送信するすべてのオファーで同じ性質を求めます。
3つの層:作業セット、下書き、バージョン
| 層 | 変更できるか | 何のためのものか |
|---|---|---|
| 作業セット(ライブの明細) | できる。次の送信まで | 販売者が今価格をつけている明細。追加、削除、数量の変更 |
| 下書き | できる。送信後に破棄 | 編集中でまだ保存していないプロポーザル。価格、値引き、送料、支払条件、有効期限、メッセージ |
| バージョン | 編集しない | 送信したオファーと受け取ったカウンターごとに1つの完全なスナップショット。番号は1、2、3… |
作業セットは誰も見ていないので変更できます。オファーが出た瞬間にそれはバージョンになり、作業セットはそのバージョンに合わせて更新されます。バージョンは一度しか書き込まれません。見積の修正版はすべて新しいバージョンとして積み重なり、版管理された履歴になります。
バージョンが保持すべきもの
バージョンは単独で成り立たなければなりません。1年後にそれを読む人が、作業セットやカタログや商品を見なくても理解できる必要があります。
| 部分 | 項目 | 参照ではなくコピーする理由 |
|---|---|---|
| 識別 | 見積、バージョン番号、作成者(販売者、バイヤー、システム)、日時 | ラウンドはタイムスタンプではなく番号で順序づける |
| 各明細 | 商品とバリエーションのID、表示されたとおりのタイトルとバリエーションのタイトル、数量、4つの価格(明細が見積に入ったときのカタログ価格、カタログの基準価格(BtoBでは会社所在地の価格)、バイヤーの希望価格、オファー価格)、明細の合計 | 商品は名前も価格も変わるが、オファーは変わってはならない |
| 明細の表示 | バイヤーに明細の価格を見せるかどうか。価格非表示の明細は「込み」と表示され、0.00でなければならない | バイヤーの合計は保存された金額から取り、表示された明細を足し合わせて求めることは決してない。そのため、価格のついた非表示の明細は引き算でわかってしまう(合計を保存する理由) |
| 金額 | 小計。見積全体の値引きの種類、入力値、確定した金額。送料とそれが確定かどうか。税の見積額。合計。通貨 | パーセントの値引きは、小計が違えば違う金額になる |
| 条件 | 支払条件、デポジットの割合、有効期限、バイヤーへのメッセージ | 条件もオファーの一部である |
小さな決定が大きな役割を果たします。バージョンの明細は、元になった作業セットの明細への参照を持ちますが、あえて外部キーにはしません。作業セットの明細は後で削除されることがあり、バージョンはそれを越えて残らなければならないからです。
バージョンの作成:コピーオンライト
送信は作業セットと下書きを読み、新しいバージョンを書き込み、それより前のバージョンには一切触れません。QuotWayが販売者のバージョンを作る方法を、本番のコードから簡略化し、内部の名前を取り除いて示します。
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,
});
});
}
バージョンが書き込まれた後、QuotWayで行われる書き込みはスタンプだけです。バイヤーのカウンターがそのバージョンを置き換えたという印と、承諾の記録です。バージョンの明細や金額を変更するコードパスはありません。これはコードの性質であって、データベースの性質ではありません。データベースのレベルでは更新を止めるものは何もないため、正確な言い方は「アプリが編集することはない」であり、「ストレージ上で変更できない」ではありません。追記のみをデータベースの性質にする方法は、同じ理由づけで監査ログの記事が説明しています。
カウンターオファーはパッチではなくバージョン
カウンターを、販売者のオファーに対する変更のリストとして保存したくなるかもしれません。そうしないでください。パッチは正しい基準に適用し直さなければ読めず、その基準自体が変わっているかもしれません。カウンターは、相手側が作った完全なバージョンです。
- カウンターの対象となるバージョンのすべての明細をコピーします。バイヤーが触れなかった明細は、販売者の価格をそのまま引き継ぎます。
- バイヤーが変更できるものだけを上書きします。QuotWayでは、各明細の価格と数量、明細のメモ、メッセージ、そしてカウンターの対象となるバージョンにすでにある場合に限り、デポジットの割合です。
- 残りは引き継ぎます。値引きの種類と値、送料、税、支払条件です。パーセントの値引きは新しい小計で計算し直し、小計が固定額の値引きを下回るカウンターは拒否します。
- 価格非表示の明細へのカウンターは無視します。その明細は元の価格を保ちます。
- カウンターの対象となったバージョンに、新しい番号によって置き換えられたという印を、同じトランザクションでつけます。
カウンターへの販売者の返答も、また次のバージョンです。ラウンドの数に制限はありません。記録は1回の動きごとにスナップショット1つずつ増え、最新のものが有効なオファーです。
送信ロック
オファーを送信する前は、すべてを編集できます。送信した後は、何も編集できません。そしてこのチェックは、変更が来る可能性のあるすべての場所で同じでなければなりません。エディター、明細の編集、自動化、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" },
];
QuotWayでは、同じ理由でAPIが 409 not_editable を返します。この状態表に含まれていないものに注目してください。送信済みのプロポーザルから出る販売者の遷移はありません。送信したオファーは、バイヤーが行動するか期限が切れるまで承諾できる状態のままです。販売者の次のバージョンが作られるのは、バイヤーのカウンターの後、販売者側の承認者による却下の後、または部分承諾の後にまだ未決定の明細を再オープンするときです。
送信済みのオファーを販売者が差し替える必要があるプロセスなら、それを編集ではなく置き換え(supersede)として設計してください。新しいバージョンを作り、送信済みのものに置き換え済みの印をつけます。そうすれば、古いバージョンへの承諾は後述のガードで失敗します。この設計の残りの部分は、すでにこの遷移に対応しています。それを提供するかどうかは、バイヤーがオファーを手にした後どこまでそれに頼れるべきかという、プロダクトとしての判断です。QuotWayは現在これを提供していません。
同時実行下でのバージョン番号
2つの書き込み元が同時にバージョンを作ることがあります。送信する販売者と自動化、あるいは2人のスタッフです。番号は決して衝突してはなりません。それを保証するのは次の2つです。
// 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)
明示的なロックなしでこのインクリメントが安全なのは、PostgreSQLのデフォルトの分離レベルが、同じ行への2つ目の UPDATE をどう扱うかによります。"the would-be updater will wait for the first updating transaction to commit or roll back"(更新しようとする側は、最初に更新したトランザクションがコミットまたはロールバックするのを待つ)。その後、行は "is re-evaluated to see if the updated version of the row still matches."(更新後の行がまだ条件に一致するかを再評価される)。ほかの何かが番号を書き込むことがあれば、一意制約が最後の防壁になります。
楽観ロックと悲観ロックの違い:見積交渉にはどちらか
| 楽観ロック | 悲観ロック | |
|---|---|---|
| 考え方 | 書き込むときに競合する変更を検出し、拒否する | 先に排他的なアクセスを取り、競合が起きないようにする |
| SQLでは | UPDATE … WHERE id = $1 AND updated_at = $seen - 0行なら先に誰かが書き込んだ |
SELECT … FOR UPDATE。トランザクションの終了まで保持する |
| HTTPでは | ETagを使った If-Match。前提条件が満たされなければ412、現在の状態との競合なら409 |
標準的な対応物はない |
| コスト | 負けた側の再試行またはエラーメッセージ | 書き込み側がブロックされる。ロックがトランザクションより長く残ってはならない |
| 向いている場面 | 人が考える時間:読み取りから書き込みまで数分から数日 | 1つのリクエスト内の、短く競合の多いデータベース処理 |
交渉は、楽観ロックの教科書どおりの例です。1ラウンドに数日かかることがあり、バイヤーの昼休み、週末、承認をまたいでデータベースのロックを保持することはできません。MicrosoftのEF Coreのガイドも同じ点を指摘しており、編集のあいだロックをかけ続けるには "requires a transaction to span all the operations."(すべての操作にわたるトランザクションが必要になる)と述べています。Martin Fowlerの楽観的オフラインロックは "prevents conflicts between concurrent business transactions by detecting a conflict and rolling back the transaction"(競合を検出してトランザクションをロールバックすることで、並行するビジネストランザクション間の競合を防ぐ)もので、悲観的なものは "only one business transaction at a time"(一度に1つのビジネストランザクションだけ)を許します。
楽観ロックの実装方法で誤解されやすい点が2つあります。
- 楽観ロックにバージョン列は必須ではありません。 EF Coreではどの列でも同時実行トークンにでき、HTTPはETagを使い、ステータスとタイムスタンプを組み合わせた条件でも機能します。必要なのは、
WHERE句の条件と、1行が変更されたことの確認です。PostgreSQLは "matched rows whose values did not change"(一致したが値が変わらなかった行)も更新済みとして数えるため、データが変わったかどうかではなく、件数を確認してください。 SELECT … FOR UPDATEは読み取りを止めません。 行ロックは "block only writers and lockers to the same row."(同じ行への書き込みとロックだけをブロックする)。バイヤーはページを読み込めます。待たされるのは2つ目の書き込みです。
QuotWayは両方を、それぞれ適した場所で使っています。すべてのステータス変更には楽観ロックの条件(見積の updatedAt が読み取ったときのままであること)を、番号のインクリメントにはトランザクション内での行レベルの直列化を使います。
古い承諾を防ぐガード
バイヤーの承諾ボタンは、承諾するバージョンを指定しなければならず、サーバーは現在のオファーでないものをすべて拒否しなければなりません。QuotWayの承諾の方法を簡略化して示します。
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;
});
}
2つのガードには、それぞれ別の役割があります。バージョンのチェックは、「承諾」を「このオファーの承諾」という意味にします。条件つきのステータス変更は、2つのバイヤーの操作が競合したとき、たとえば2つのタブからの承諾とカウンターのうち、ちょうど1つだけが通るようにします。もう一方は競合のエラーと最新の画面を受け取ります。RFC 9110自身が挙げる409の例はバージョン管理の競合であり、どちらのガードにも適したステータスです。
番号が一意でも、勝者が1人とは限らない
インクリメントは、同時に行われた2つの送信が別々の番号を受け取ることを保証します。しかし、どちらの送信が行われるべきだったかは決めません。2人が同じ状態から同時に送信すると、バージョンNとN+1ができ、バイヤーにはN+1が見えます。それはメールで送られたものでも、承認されたものでもないかもしれません。解決策は、バージョンと同じトランザクションでステータス変更を確保することです。そうすれば、2人目の送信者は何かを書き込む前に失敗します。以下は推奨する形であり、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
});
新しいバージョンは進行中の承認を置き換える
承認は、それが承認した数字に属します。QuotWayでは、承認チェーンは1つのバージョンに結びついており、どちらの側がバージョンを作る場合も、まず見積でまだ進行中のチェーンに置き換え済みの印を、同じトランザクションでつけます。承認者が、すでに差し替えられたオファーを承認することはできません。そのチェーンが記録する内容は承認の監査ログで説明しています。
カタログの変更は、進行中の見積の価格を変えない
バージョンは自分の価格を保存しているので、送信後にカタログが変わってもオファーには影響しません。注文にも影響してはなりません。承諾された見積がShopifyの下書き注文になるとき、各明細は承諾された価格で送る必要があります。バリエーションの明細なら価格を明示的に上書きします。そうしなければ、Shopifyはそのバリエーションの現在のカタログ価格で明細に価格をつけます。QuotWayはすべての明細で承諾された単価を送ります。変換時のチェックは、承諾後に正当に変わりうるもの、つまり税、送料、在庫を対象とし、固定された交渉済みの価格は対象にしません。失敗するケースは見積が間違った価格で変換される理由に、その下書き注文をちょうど1回だけ作る方法は冪等な変換の記事にまとめています。
変更された価格を取り込む場所は、新しい販売者のバージョンです。QuotWayでは、BtoBの見積について、会社所在地のカタログの基準価格を販売者の送信のたびに解決し直します。取り込み時のカタログ価格は解決し直しません。
部分承諾:バイヤーが選ばなかった明細は未決定のまま
承諾は、1つのバージョンに結びついた独立した記録です。明細ごとの決定(承諾、保留、却下)と、承諾した各明細の数量と価格のコピーを持ちます。保留は辞退ではありません。バイヤーがまだ決めていないという意味です。その後、バイヤーは保留中の明細をさらに承諾できます(新しい承諾の記録になり、以前の記録はそのまま残ります)。あるいは販売者が、まだ未決定の明細だけを含む新しいバージョンとして再オープンできます。却下された明細は含めません。同じ区別をレポートの側から見たものが失注か、まだ未決定かです。
何が変わったかを示す
各ラウンドが完全なスナップショットなら、差分は2つのバージョンの純粋な関数です。連続するバージョンの差分を取り、元の明細への参照で明細を対応づけ、次を報告します。
- 追加、削除、変更された明細と、その価格と数量の差。
- 合計と送料。
- 値引き、支払条件、デポジット、有効期限。合計への影響だけでなく、項目として示します。
QuotWayは、販売者のタイムラインで各バージョンとその前のバージョンとの差分を表示します。明細、数量、合計、送料です。値引きと支払条件の変更は合計を通じて表れます。バイヤーに見えるのは現在のオファーと会話だけで、履歴は見えません。同じ規則をマーチャント側から説明したガイドが依頼の後で見積を編集するです。
テンポラルテーブル、タイプ2ディメンション、イベントソーシングを使わない理由
| 選択肢 | 得られるもの | 見積にはスナップショット行のほうが合う理由 |
|---|---|---|
| システムバージョン管理のテンポラルテーブル(SQL Server) | 更新のたびに自動で行の履歴が残る | 送信したオファーではなく、すべての書き込みを記録する。"even if no column values change"(列の値が変わらなくても)記録される。PostgreSQLはシステムバージョン管理テーブルを未対応のSQL機能として挙げているため、Postgresではいずれにせよバージョンを自分でモデル化する |
| 緩やかに変化するディメンションのタイプ2 | 変更ごとに新しい行。有効日付と現在行のフラグを持つ | 形は正しい。バージョンのテーブルは、日付の代わりにバージョン番号を使ったこのパターンそのものである |
| イベントソーシング | すべての変更を変更されないイベントとして持ち、再生で状態を組み立てる | この用途には重い。Microsoftは "its benefits … justify the pattern's complexity"(その利点がパターンの複雑さに見合う)場合にだけ採用するよう勧めている。また、Shopifyに触れた見積を再生するのは安全ではない |
| バージョンごとのスナップショット行 | オファーごとに1つの、完全で読める記録 | 再生しなくても読める。履歴はオファーの並びであり、それこそが紛争で問われるものである |
スナップショットのバージョンはその中間にあります。重要なところでは追記のみ、それ以外はすべて普通のテーブルです。
自分で作るときのチェックリスト
- 送信したオファーは編集しない。変更はすべて、新しい完全なバージョンにする。
- バージョンは、すべての明細の価格、数量、表示設定に加え、値引き、送料、税、合計、通貨、支払条件、デポジット、有効期限を保持する。
- 価格非表示の明細は0.00にし、合計は表示された明細を足し合わせず、保存する。
- バージョンの明細は作業セットの明細を参照するが、外部キーにはしない。
- カウンターは完全なバージョンにする。触れられなかった明細は引き継ぐ。
- 編集可能な状態のチェックは1つにし、エディター、明細の編集、自動化、APIで共通に使う。
- バージョン番号は同じトランザクション内の原子的なインクリメントで決め、一意制約で裏づける。
- すべてのステータス変更は、読み取ったときの状態を条件にする。負けた側は、黙って上書きされるのではなく競合を受け取る。
- 承諾はバージョンを指定し、そのバージョンが現在のものでなければ拒否する。
- 新しいバージョンは、古いバージョンで進行中の承認を置き換える。
- 承諾された価格を注文に強制する。カタログの変更で、承諾された見積の価格が変わることはない。
- 保留した明細は未決定のまま残す。却下した明細は戻さない。
- 差分は連続するバージョンの間で計算し、値引きと支払条件を項目として含める。
- 販売者が送信済みのオファーを差し替えられるようにするなら、編集ではなく置き換えにする。
QuotWayでの位置づけ
販売者が送信するすべてのプロポーザルと、バイヤーが行うすべてのカウンターは、番号のついた完全なバージョンです。送信済みのバージョンの明細と金額を、アプリが編集することはありません。プロポーザルは一度出たら変わりません。次のバージョンは、バイヤーのカウンター(Starter以上)、自社の承認者による却下、または部分承諾の後にまだ未決定の明細の再オープンから生まれます。承諾はバージョンを指定し、新しいバージョンがあれば拒否されます。承諾された明細は、価格と数量、そしてそれらのハッシュとともに記録されます。承諾された価格はShopifyの下書き注文に送られるため、カタログが変わっても価格は変わりません。プロポーザルとバージョン、カウンターオファー、交渉、料金プランをご覧ください。
よくある質問
送信した後で見積を編集できますか?
その場で編集するのは安全ではありません。送信したオファーは固定されたものとして扱い、変更はすべて新しいバージョンにしてください。そうすれば、バイヤーが持つドキュメント、承認、承諾がすべて同じ数字を指します。QuotWayでは送信済みのプロポーザルは編集できず、次のバージョンはバイヤーのカウンターの後に作られます。
カウンターオファーは新しい見積ですか、新しいバージョンですか?
同じ見積の新しいバージョンです。すべての明細の完全なスナップショットで、バイヤーの変更を適用し、それ以外はすべてカウンターの対象となったバージョンから引き継ぎます。
見積には楽観ロックと悲観ロックのどちらを使うべきですか?
楽観ロックです。交渉の1ラウンドは数分から数日にわたり、そのあいだデータベースのロックを保持することはできません。各書き込みを読み取ったときの状態を条件にし、条件が満たされなければ拒否してください。
楽観ロックにバージョン列は必要ですか?
必要ありません。行が変わっていないことを証明できる条件なら何でも機能します。バージョン番号、更新日時のタイムスタンプ、ステータス、HTTPのETagなどです。影響を受けた行数を確認し、それが1であることを確かめてください。
バイヤーが古いバージョンを承諾したらどうなりますか?
拒否してください。承諾はバージョンIDを持つべきであり、そのバージョンが現在の、置き換えられていないものでない限り、サーバーは競合として拒否すべきです。
見積が変わったら、承認はどうなりますか?
置き換えられるべきです。承認は、それが承認したバージョンに結びついています。新しいバージョンには、それ自身の評価が必要です。
カタログの価格変更は進行中の見積に影響しますか?
バージョンが自分の価格を保存していれば影響しません。見積が注文になるときに承諾された価格を明示的に送ってください。そうしなければ、プラットフォームは現在のカタログ価格で明細に価格をつけます。
見積の履歴にはテンポラルテーブルを使うべきですか?
テンポラルテーブルはオファーごとではなく書き込みごとに記録し、PostgreSQLには組み込まれていません。オファーごとに1組の完全な行を持つバージョンのテーブルなら、交渉で実際に生じる問いに答えられます。
ラウンド間で何が変わったかをどう示せばよいですか?
連続するバージョンの差分を明細ごとに取ります。追加、削除、変更された明細と、その価格と数量の差に加え、合計、送料、値引き、支払条件です。
出典
2026年10月15日に確認:
- Shopify(API 2026-10):draftOrderUpdate、DraftOrder、orderEditBegin
- PostgreSQL 18:トランザクションの分離、明示的なロック、UPDATE、制約、未対応のSQL機能
- Martin Fowler、Optimistic Offline LockとPessimistic Offline Lock
- Microsoft Learn:EF Coreの同時実行の競合、テンポラルテーブル、Event Sourcing pattern
- Kimball Group、Type 2: add new row
- RFC 9110, HTTP Semantics:条件付きリクエスト(13.1.1)、409 Conflict(15.5.10)、412 Precondition Failed(15.5.13)
QuotWayの動作は、2026年10月15日に確認したアプリのソースに基づいて説明しています。コードは簡略化し、内部の名前を取り除いています。
関連記事
QuotWayがこれをあなたのストアでどう扱うかをご覧ください。