share_links.kindのCHECK制約に'receipt'が無く、領収証機能が本番デプロイで消えていた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
機能そのものはすでに別ブランチで動く状態まで実装されていたのに、トランクへ一度もマージされないまま82コミット分乖離すると、トランクからの本番デプロイがその機能を丸ごと消す退行を起こす。
機能そのものはすでに別ブランチで動く状態まで実装されていたのに、トランクへ一度もマージされないまま82コミット分乖離すると、トランクからの本番デプロイがその機能を丸ごと消す退行を起こす。
結論
領収証(receipt)機能は2026-06-13、別ブランチfeat/receipt-ryoshusho上で実装・動作していたが、現行トランクへ一度もマージされなかった。 トランクはその後も開発が進み、修正時点でこのブランチから82コミット分先行していた。トランクからの本番デプロイは当然トランクに存在するコードしか含められないため、デプロイするたびに領収証機能自体が存在しない状態になっていた。直し方はこのブランチをマージすることではなく、現行トランクのコードの双子として機能を再実装することだった。
症状
- 領収証機能はブランチ上では完成していたが、トランクにマージされた形跡が無い
- トランク側は共有リンクの種別を表す
share_links.kind列のCHECK制約を、別の目的(請求書集約など)のために複数回(migration 0040/0044/0045/0054)張り替えていた - この張り替えは毎回「トランクが知っている既存の種別一覧」から再構成されており、ブランチ側だけが追加した
'receipt'はトランクの視点では一度も存在しない種別のままだった - 本番デプロイはトランクのコード・トランクのDBスキーマしか反映できないため、ブランチの機能は「実装されているのにデプロイすると存在しない」状態になる
- バグ報告やエラーログとしては現れず、機能そのものが丸ごと無い(または領収証発行がCHECK制約違反になる)という形で発覚する
原因
トランク側の直近のCHECK制約の張り替えは、group_invoice(団体集約請求)を追加するmigration 0054だった。
-- 0054_share_links_group_invoice.sql(a1eb39d272, 10-15行)
-- kind に 'group_invoice' を許可(0045 までの全 kind を保持して張り替え)。
alter table public.share_links
drop constraint if exists share_links_kind_check;
alter table public.share_links
add constraint share_links_kind_check
check (kind in ('invoice','quote','application','report','file','bundle','group_invoice'));
このCHECK制約は「0045までの全kindを保持」と書かれている通り、トランクが知っている種別だけを律儀に積み上げている。これは単体としては正しい変更だ。問題は、ブランチfeat/receipt-ryoshushoが2026-06-13のmigration 0031で追加した'receipt'を、このmigration 0054(2026-06-21、ブランチより8日後)を書いた側が知る手段が無かったことにある。ブランチは一度もマージされていないため、トランクの誰にとっても'receipt'という種別は存在しないものとして扱われ続けた。
修正時点でこの2つの系統がどれだけ乖離していたかは、コミット数で確認できる。
$ git log --oneline feat/receipt-ryoshusho..<トランクの修正直前コミット> | wc -l
82
直し方
修正は、82コミット遅れたブランチをそのままマージするのではなく、現行トランクのコード(請求書発行のinvoice-issue.ts)の双子として領収証ロジックを新規に書き直す方法を取った。
// receipt-issue.ts(3f1d797b2b, 1-101行の冒頭部分)
/**
* 契約の領収証を発行し顧客へメール送付する(請求書 issueInvoiceAndEmail の双子)。
* - 既に有効な receipt 共有リンクがあれば再利用。リンクは /p/[token](非ログイン閲覧)。
* - 呼び出し元: 入金状態の「入金済」への遷移時のみ(admin/contracts の updatePaymentStatus/
* finance の reconcilePayment)。再保存・再消込で再送しないよう呼び出し側が prevPayment でガードする。
* - 団体傘下契約(billed_to が自社以外)は集約請求(group_invoice)側で扱い、単体領収証は出さない
* =請求書 issueInvoiceAndEmail と対称(0053/0054)。
*/
export async function issueReceiptAndEmail(
supabase: DbClient,
contractId: string,
issuerId: string | null
): Promise<boolean> {
次に、CHECK制約を0054までの全kindの和集合として'receipt'込みで冪等に張り替えた。
-- 0055_share_links_receipt_restore.sql(3f1d797b2b, 1-19行)
-- 背景: 領収証機能は feat/receipt-ryoshusho(旧 migration 0031・2026-06-13 本番投入)で
-- share_links.kind に 'receipt' を追加した。しかしその後トランクの 0040/0044/0045/0054 が
-- kind CHECK を「receipt 抜き」で張り替えたため、現行トランク=本番では 'receipt' が
-- 許可されなくなり、領収証の発行(kind='receipt' の share_link 作成)が CHECK 違反になる。
alter table public.share_links drop constraint if exists share_links_kind_check;
alter table public.share_links
add constraint share_links_kind_check
check (kind in ('invoice','quote','application','report','file','bundle','group_invoice','receipt'));
最後に、領収証の自動発行が「入金済への遷移の瞬間」だけに限定されるよう、呼び出し元2箇所(契約画面のステータス更新、financeの消込)それぞれで変更前の入金状態を取得し、同じ状態への再保存・再消込では再送しないガードを入れた。
// admin/contracts/actions.ts(3f1d797b2b, 97-146行)
// 二重発行ガード用に変更前の入金状態を取得(同状態の再保存で帳票を再送しない)。
const { data: before } = await supabase
.from("contracts")
.select("payment_status")
.eq("id", id)
.maybeSingle();
const prevPayment = before?.payment_status as string | null | undefined;
// ...
// 入金済への「遷移時」のみ: チームへ即共有+領収証を自動発行し顧客へメール送付。
if (payment === "入金済" && prevPayment !== "入金済") {
// ...
const sentReceipt = await issueReceiptAndEmail(supabase, id, staff.id);
}
// admin/finance/actions.ts(3f1d797b2b, 256-292行)
// 二重発行ガード用に変更前の入金状態を取得(既に入金済の再消込で領収証を再送しない)。
const { data: before } = await supabase
.from("contracts")
.select("payment_status")
.eq("id", contractId)
.maybeSingle();
const prevPayment = before?.payment_status as string | null | undefined;
// ...
if (prevPayment !== "入金済") {
const sent = await issueReceiptAndEmail(supabase, contractId, user.id);
}
このガードは領収証のためだけではなく、同じ仕組みを流用して請求書発行側(updatePaymentStatusの「請求済」遷移)の潜在的な二重送信も合わせて直している。
再発防止
根拠ファイルには、長期間マージされないブランチを自動検知する仕組み(CIでの分岐コミット数チェックなど)についての記述は無い。この修正はブランチを復活させる形ではなく、トランク側で機能を再実装する形で決着している。
構造的に見れば、この問題はブランチ運用そのものより「スキーマ変更(CHECK制約)が、トランクの視点でしか正しさを検証できない」ことに起因している。migration 0040/0044/0045/0054は、それぞれの変更単体では正しく、レビューも通る。しかし「トランクが認識している種別一覧」と「本番で実際に必要な種別一覧」が、未マージブランチの存在によって静かにズレていたことには、トランク側のレビューでは気づけない。機能を別ブランチで長期間先行実装する運用を取るなら、マージまでの期限を区切るか、トランク側の破壊的スキーマ変更(既存制約の張り替えなど)が未マージの機能と衝突しないかを別途確認する仕組みが無いと、この種の「実装済みなのにデプロイすると存在しない」退行は再現し得る。
よくある質問
Q1消えていた領収証機能自体にバグがあったのですか?
いいえ。領収証機能は2026-06-13、別ブランチ(feat/receipt-ryoshusho)上で実装・動作していました。問題はこの機能のロジックではなく、このブランチが一度もトランクへマージされないまま、トランク側が82コミット分先に進んでしまったことです。トランクからの本番デプロイは、トランクに存在しないコードを当然含められないため、デプロイするたびに領収証機能が丸ごと存在しない状態になっていました。
Q2なぜ本番デプロイのたびに機能が消えるという形で表面化したのですか?
領収証機能のブランチは、共有リンクの種別を区切るshare_links.kind列にCHECK制約で'receipt'を追加していました(migration 0031)。ところがトランク側はこのブランチの存在を知らないまま、別の目的(report・group_invoiceの追加など)でこのCHECK制約を3回以上(migration 0040/0044/0045/0054)張り替えており、その都度『トランクが知っている種別だけ』で制約を再構成していました。結果として、トランクの視点では'receipt'は一度も存在したことのない種別のままで、本番にデプロイされるコードにも反映されません。
Q3直し方はブランチをマージすることだったのですか?
いいえ、82コミット遅れた古いブランチをそのままマージする方法は取りませんでした。現行トランクの請求書発行ロジック(invoice-issue.ts)の『双子』としてreceipt-issue.ts・ReceiptDoc.tsxを新規に再実装し、migration 0055でCHECK制約に'receipt'を冪等に復帰させています。あわせて、入金状態が実際に「入金済」へ遷移した瞬間だけ領収証を自動発行するガード(変更前の状態をprevPaymentとして取得し比較)を、契約画面とfinance消込の両方の呼び出し元に追加し、再保存や再消込で領収証を重複送信しない設計にしました。
確認した環境
- kimiteras-portal: next 16.2.7 / @supabase/supabase-js ^2.106.2
- 元実装(別ブランチ feat/receipt-ryoshusho): 2026-06-13、migration 0031で'receipt'追加
- トランク側CHECK制約の無関係な張り替え: migration 0040/0044/0045、直近は0054(2026-06-21)
- トランク復元コミット: 2026-06-23(3f1d797b2b、ブランチ分岐82コミット時点)
この記事の根拠
- SQLファイル 10〜15行目コミット a1eb39d
- TypeScriptファイル 1〜101行目コミット 3f1d797
- SQLファイル 1〜19行目コミット 3f1d797
- TypeScriptファイル 97〜146行目コミット 3f1d797
- TypeScriptファイル 256〜292行目コミット 3f1d797
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。