ガイド 第30

見積 & 証明書

@event/booking@event/asset-kit

@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_bp integer)と揃え、浮動小数の誤差を避けるため
  • 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,567toLocaleString("ja-JP")
  • formatJst(iso|Date)"2025-06-11 14:30"サーバ TZ(Vercel は UTC)に依存せず常に日本時間で出す

保存 driver — QuoteRecord / QuoteStore

発行した見積は QuoteRecordid / 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 blobaddRandomSuffix で 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/uiQuoteDocument として実装済みで、レンダリングは @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 に分離してある。QuoteDocumentruntime 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 の各行を computeQuoteappendQuote でループし、PDF は bulkZip(第39章・並列上限つき worker pool・失敗スキップ)で ZIP にまとめる

見積計算そのものは純関数で完結し、保存・PDF・押印・batch は差し替え可能な層に分かれている。まず computeQuote だけで金額を正しく出し、証明書は QuoteDocumentQuoteRecord を流し込むだけ、という順で配線するのが安全。配線手順は recipe add-booking の「見積書 PDF(QuoteDocument + exportPdf)」節に完全コードがある。