@event/booking の見積系(quote.ts)は、xdeal-lp の M&A 御見積(外税・basis-point 税率)と ivs-cms の group-pass(内税・見積番号採番)を taxMode で統合したもの。中核の computeQuote は純関数なので DB もネットワークも要らず、dev でそのまま動く — これを参照 UI の主役にする。
computeQuote — 純関数・税 2 モード
computeQuote(lineItems, taxMode, taxRateBp, options?) => QuoteTotals
lineItems:{ label?, qty, unitPrice }[]。行金額はlineAmount({ qty, unitPrice }) = round(qty × unitPrice)taxMode:"exclusive"(外税 = 単価は税抜・消費税を別建てで加算。見積書の標準)/"inclusive"(内税 = 単価は税込・合計から消費税を逆算。group-pass 方式)taxRateBp: 税率を basis-point 整数で受ける(1000 = 10%)。DB 保持形式(tax_rate_bpinteger)と揃え、浮動小数の誤差を避けるためoptions.discountAmount/options.discountBasis: 値引きとその基準(外税時のみ意味を持つ)
戻り値 QuoteTotals は { taxMode, taxRateBp, subtotalExcl, discount, taxable, tax, total }。total が顧客の支払額(税込)。UI はこの内訳をそのまま表に落とせばよい。
割引の基準(discountBasis)— 外税のみ
pre_tax(既定): 小計(税抜) − 値引き = 課税対象 → 消費税 → 合計。消費税も値引き後の額で計算されるpost_tax: 消費税は値引き前の小計で計算し、税込合計から値引きを引く
「本体値引き」か「税込からの一律値引き」かで税額が変わる。既定は前者。内税では税が単価に埋め込まれているため basis の区別はせず、値引きは税込合計から引いて消費税を逆算する。
すべて整数円で丸める(Math.round)純関数なので、同じ入力は必ず同じ出力になり、client でも server でも一致する。参照 UI では 公開ページ(server component)が catalog を持ち、client の入力フォームは /api/booking/quote に投げて computeQuote 結果を受け取る(@event/booking の barrel は @vercel/blob / drizzle / node:crypto を含む server-only モジュールなので、client から直接 import しない)。
採番と表示 — makeEstimateNo / yen / formatJst
makeEstimateNo(prefix = "Q", now?)→Q20250611-4F2A({prefix}YYYYMMDD-XXXX)。ランダム 4 桁を含むので毎回異なる = ライブ試算では採番せず「発行」操作でだけ呼ぶyen(v)→¥1,234,567(toLocaleString("ja-JP"))formatJst(iso|Date)→"2025-06-11 14:30"。サーバ TZ(Vercel は UTC)に依存せず常に日本時間で出す
保存 driver — QuoteRecord / QuoteStore
発行した見積は QuoteRecord(id / estimateNo / issuedAt / company / 明細 / totals / delivered / downloaded…)として保存する。QuoteStore interface は appendQuote / listQuotes / getQuote / markDownloaded の 4 つ。driver は 2 系統:
createBlobQuoteStore(dev fs / 本番 Blob)
export const quoteStore = createBlobQuoteStore({ contentStore, pathPrefix: "quotes/", fsResource: "quotes" });
- 本番(
BLOB_READ_WRITE_TOKEN有)= 1 レコード = 1 blob(addRandomSuffixで URL 非推測)。PII を git に置かない追記専用パターン(第39章appendRecordと同思想) - dev(トークン無)=
content/quotes.json(@event/coreの ContentStore fs)。トークン無しでも動くので参照 UI がそのまま回る markDownloadedは Blob が追記専用のため「新レコードを put → 旧 blob を del」で更新する。list()は eventual consistent なので存在確認は byte 中心に寄せる(第39章と同じ既知事情)
createNeonQuoteStore(drizzle 注入)
defineQuoteTable(tableName) で Neon テーブルを定義し(税率は tax_rate_bp integer、明細/メタは jsonb)、createNeonQuoteStore({ db, table }) を渡す。第15章の DB を入れているイベントはこちらで一元管理できる。QuoteStore interface は共通なので、admin / API は driver を意識しない。
証明書(御見積書)— QuoteDocument で A4 PDF 化【実装済み】
証明書(御見積書・領収書・参加証)は見積の応用。帳票 UI は @event/booking/ui の QuoteDocument として実装済みで、レンダリングは @event/asset-kit の capture に委譲する(第39章)。
import { QuoteDocument } from "@event/booking/ui";
import { exportPdf } from "@event/asset-kit/capture";
await exportPdf(
<QuoteDocument record={rec} issuer={{ name, address, tel, email, sealText }} />,
{ w: 794, h: 1123, exportScale: 2, pageMm: [210, 297] }, // px 撮影 + 物理 mm A4
"#ffffff",
rec.estimateNo,
);
@event/booking/uiサブパス: barrel(.)は@vercel/blob/drizzle/node:cryptoを含む server-only なので、帳票 UI だけ./uiに分離してある。QuoteDocumentは runtime import ゼロ(型のみimport type)の純 React で、server / client どちらでも描画でき、client(CertificatePanel)が同じ要素をexportPdfに渡して焼く- props:
{ record: QuoteRecord; issuer: { name, address?, tel?, email?, logoUrl?, sealText? }; title?(既定「御見積書」)}。A4 縦(w 794px ≈ A4@96dpi)の帳票を inline style で組み、色は--doc-*CSS 変数(--doc-accent/--doc-seal等)を祖先で定義すれば差し替えられる - 金額表示: 明細は
label / qty / unitPrice / 金額をyen()で、日付はformatJstで出す(client/server 境界を越えないよう UI 内に同一仕様の pure 版を持つ)。合計内訳(小計 / 値引 / 課税対象 / 消費税 / 合計)はQuoteTotalsをそのまま表に落とす - 押印:
issuer.sealTextを渡すと発行者名の右に朱印風の丸枠が出る。社判 PNG を使うならlogoUrlに重ねて capture すれば PDF に焼ける(PDF に後付けするより DOM 合成の方が座標が確実) - PNG 可逆:
exportPdfは PNG のまま pdf-lib で物理 mm(既定pageMm = [210,297])に埋め込む。JPEG 化しないのは低 opacity の押印・透かし階調が潰れて「プレビューと違う」事故になるため - ダウンロード追跡: 初回 DL 時に
markDownloaded(id)を呼べばdownloaded/downloadedAtが記録される(送達管理・二重発行検知) - batch CSV: 複数社ぶんを一括発行するなら、CSV の各行を
computeQuote→appendQuoteでループし、PDF はbulkZip(第39章・並列上限つき worker pool・失敗スキップ)で ZIP にまとめる
見積計算そのものは純関数で完結し、保存・PDF・押印・batch は差し替え可能な層に分かれている。まず computeQuote だけで金額を正しく出し、証明書は QuoteDocument に QuoteRecord を流し込むだけ、という順で配線するのが安全。配線手順は recipe add-booking の「見積書 PDF(QuoteDocument + exportPdf)」節に完全コードがある。