コンテンツにスキップ

SALON BOARD クローン ERD (Mermaid)

境界コンテキスト別に分割。テナント境界は全テーブル store_id 必須。FK制約は同一テナント内のみ(将来のRLS化を見据える)。graphile-worker の graphile_worker.* スキーマは別管理(自動マイグレーション)。

規約(詳細は親SRS §7.2 参照):

  • id は原則 UUIDv7(Postgres uuid 型、16バイト、アプリ層で生成)
  • 例外的に bigserial を許容するテーブル
    • ログ系(operator_action_log, reservation_event, message_delivery_log
    • 中間テーブル(customer_tag_link, role_permission
    • 純内部マスタ(business_hours, day_override, shift, cash_movement
    • 条件:外部露出なし+件数漏れ無害+大量生成、の3条件を満たす場合のみ
  • menu_staff_eligibility / menu_equipment_requirement は複合主キー(独立IDなし、親SRS §7.14)
  • 表示用IDcustomer_nocoupon_codesalon_slug 等)は主キーと別列、店舗内unique
  • 金額は全て整数(税抜・税額・税込を分離、bigint型)
  • タイムスタンプは timestamptz
  • ソフトデリートは基本使わない(必要な箇所のみ deleted_at
  • 集計用カラム(visit_count_cache 等)は掲載しない(ビューまたはマテビューで算出)
  • 全テーブル store_id 必須、FK制約は同一テナント内のみ(RLS + 複合FK)、graphile-workerの graphile_worker.* は別管理

1. テナント・認証コンテキスト

Section titled “1. テナント・認証コンテキスト”

注:OPERATOR は単一店舗に縛られない(親SRS用語集 §2、§4.1)。1オペレーターは複数店舗に所属しうるため、店舗スコープは OPERATOR_STORE_LINK(M:N、role 紐付け)で表現する。role / role_permission / permission / operator_permission_override の詳細スキーマは SRS-TEN-001 §6.1 を参照(本図では関係概略のみ)。

erDiagram
STORE ||--o{ OPERATOR_STORE_LINK : "scopes"
OPERATOR ||--o{ OPERATOR_STORE_LINK : "scoped_to"
STORE ||--o{ STORE_SETTING : "has"
STORE ||--o{ ROLE : "defines"
ROLE ||--o{ OPERATOR_STORE_LINK : "assigned_via"
OPERATOR ||--o{ OPERATOR_SESSION : "has"
OPERATOR ||--o{ PASSKEY_CREDENTIAL : "registers"
OPERATOR ||--o{ TOTP_SECRET : "owns"
STORE {
string id PK
string code UK "H000551123相当"
string name
string timezone "Asia/Tokyo"
string plan "free/paid"
timestamptz created_at
}
STORE_SETTING {
string store_id PK "FK"
int reservation_slot_min "5/10/15/30"
int hpb_slot_min "10/15/30"
int net_accept_weeks "1..12"
string net_cutoff_policy "json"
string cancel_policy "json"
boolean accept_no_nominate
string customer_question_json
}
OPERATOR {
string id PK
string email UK
string name
boolean totp_enabled
timestamptz last_login_at
timestamptz created_at
}
OPERATOR_STORE_LINK {
string operator_id PK "FK"
string store_id PK "FK"
string role_id FK "owner/manager/staff/receptionist"
timestamptz created_at
}
ROLE {
string id PK
string store_id FK
string key "owner/manager/staff/receptionist"
string name
boolean is_preset
timestamptz created_at
}
OPERATOR_SESSION {
string id PK
string operator_id FK
string token_hash UK
timestamptz expires_at
string ip
string user_agent
}
PASSKEY_CREDENTIAL {
string id PK
string operator_id FK
string credential_id UK
bytea public_key
bigint sign_count
string transports
timestamptz created_at
}
TOTP_SECRET {
string operator_id PK "FK"
string secret_encrypted
timestamptz activated_at
}

2. マスタ(リソース)コンテキスト

Section titled “2. マスタ(リソース)コンテキスト”

注:メニュー対応関係は親SRS §7.14 に従い MENU_STAFF_ELIGIBILITY(スタッフ対応、ホワイトリスト)と MENU_EQUIPMENT_REQUIREMENT(設備要件)に分離する。MENU.restrict_staff_required は仮置き名で命名は SRS-MST-002 で確定。STAFF の確定スキーマは SRS-MST-001 が所有(本図では業務上の関係を示すスケッチに留める)。

erDiagram
STORE ||--o{ STAFF : "employs"
STORE ||--o{ EQUIPMENT : "owns"
STORE ||--o{ MENU : "offers"
STORE ||--o{ BUSINESS_HOURS : "opens"
STORE ||--o{ DAY_OVERRIDE : "overrides"
STORE ||--o{ SHIFT : "schedules"
STAFF ||--o{ SHIFT : "works"
MENU ||--o{ MENU_STAFF_ELIGIBILITY : "whitelists"
STAFF ||--o{ MENU_STAFF_ELIGIBILITY : "performs"
MENU ||--o{ MENU_EQUIPMENT_REQUIREMENT : "requires"
EQUIPMENT ||--o{ MENU_EQUIPMENT_REQUIREMENT : "satisfies"
STAFF {
string id PK
string store_id FK
string name
boolean is_bookable
int display_order
timestamptz terminated_at "離職"
timestamptz created_at
timestamptz updated_at
}
EQUIPMENT {
string id PK
string store_id FK
string name
boolean bookable
int capacity "同時予約可能数"
int display_order
}
MENU {
string id PK
string store_id FK
string category "フェイシャル/ボディ/店販..."
string name
int duration_min
int price_excl_tax
int tax_rate_bp "1000=10%"
boolean restrict_staff_required "仮置き、§7.14 / SRS-MST-002 で確定"
boolean active
}
MENU_STAFF_ELIGIBILITY {
string store_id PK "FK"
string menu_id PK "FK"
string staff_id PK "FK"
}
MENU_EQUIPMENT_REQUIREMENT {
string store_id PK "FK"
string menu_id PK "FK"
string equipment_id PK "FK"
}
BUSINESS_HOURS {
string id PK
string store_id FK
int weekday "0=Mon..6=Sun, 7=祝"
time open_at
time close_at
int capacity
boolean closed
}
DAY_OVERRIDE {
string id PK
string store_id FK
date target_date
time open_at
time close_at
int capacity
boolean closed
string memo
}
SHIFT {
string id PK
string store_id FK
string staff_id FK
date work_date
time start_at
time end_at
string breaks_json
}

erDiagram
STORE ||--o{ CUSTOMER : "owns"
CUSTOMER ||--o{ CUSTOMER_NOTE : "has"
CUSTOMER ||--o{ CUSTOMER_TAG_LINK : "tagged"
STORE ||--o{ CUSTOMER_TAG : "defines"
CUSTOMER_TAG ||--o{ CUSTOMER_TAG_LINK : "used_by"
CUSTOMER {
string id PK
string store_id FK
string name_kanji
string name_kana
date birthday
string gender "F/M/NA"
string tel
string email
string occupation
boolean post_mail_opt_in
boolean watch_flag "要注意"
string preferred_staff_id
boolean direct_member
timestamptz created_at
timestamptz deleted_at
}
CUSTOMER_NOTE {
string id PK
string customer_id FK
string type "memo/sketch/karte"
string body
string sketch_url
string author_operator_id
timestamptz created_at
}
CUSTOMER_TAG {
string id PK
string store_id FK
string name
string color
}
CUSTOMER_TAG_LINK {
string customer_id FK
string tag_id FK
}

4. 予約・会計コンテキスト(コア)

Section titled “4. 予約・会計コンテキスト(コア)”

予約の状態遷移は RESERVATION_EVENT に時系列で記録する(イベントソース)。後続の分析・リピート率・キャンセル発生タイミング集計で必須。

erDiagram
STORE ||--o{ RESERVATION : "accepts"
CUSTOMER ||--o{ RESERVATION : "books"
STAFF ||--o{ RESERVATION : "assigned_to"
EQUIPMENT ||--o{ RESERVATION : "uses"
RESERVATION ||--o{ RESERVATION_MENU : "includes"
RESERVATION ||--o{ RESERVATION_EVENT : "transitions"
RESERVATION ||--o| VISIT : "checks_out_as"
VISIT ||--o{ VISIT_LINE : "composes"
MENU ||--o{ RESERVATION_MENU : "snapshotted"
RESERVATION {
string id PK
string store_id FK
string customer_id FK
string staff_id FK
string equipment_id FK
string code UK "人間可読"
string channel_code "phone_own/phone_hpb/hpb/direct/walkin"
int status "1..11"
boolean confirmed
date visit_date
timestamptz start_at
timestamptz end_at
int total_excl_tax
int total_tax
int cancel_fee
string cancel_reason
timestamptz cancelled_at
string note
string external_ref "HPB等"
timestamptz created_at
timestamptz updated_at
}
RESERVATION_MENU {
string id PK
string reservation_id FK
string menu_id FK
string name_snapshot
int duration_min
int price_excl_tax
int tax_rate_bp
}
RESERVATION_EVENT {
string id PK
string reservation_id FK
int from_status
int to_status
string actor_type "system/operator/customer"
string actor_id
string reason
timestamptz occurred_at
}
VISIT {
string id PK
string reservation_id FK "UK"
timestamptz checkin_at
timestamptz checkout_at
int gross_excl_tax
int discount
int tax
int net
string payment_type "cash/card/smartpay/other"
timestamptz settled_at
}
VISIT_LINE {
string id PK
string visit_id FK
string type "service/goods/option/discount"
string source_menu_id
string name_snapshot
int unit_price_excl_tax
int quantity
int amount_excl_tax
int tax_rate_bp
}

5. レジ締め・キャッシュフロー

Section titled “5. レジ締め・キャッシュフロー”
erDiagram
STORE ||--o{ REGISTER_CLOSING : "closes"
STORE ||--o{ CASH_MOVEMENT : "records"
REGISTER_CLOSING ||--o{ CASH_MOVEMENT : "groups"
REGISTER_CLOSING {
string id PK
string store_id FK
date closing_date UK
int expected_cash
int counted_cash
int diff
string operator_id
string memo
timestamptz closed_at
}
CASH_MOVEMENT {
string id PK
string store_id FK
string closing_id FK
string type "in/out"
int amount
string category "opening/sales_cash/refund/withdraw/expense"
string memo
timestamptz occurred_at
}

6. メッセージ配信コンテキスト

Section titled “6. メッセージ配信コンテキスト”
erDiagram
STORE ||--o{ MESSAGE : "sends"
STORE ||--o{ AUTO_MESSAGE_RULE : "defines"
STORE ||--o{ COUPON : "issues"
AUTO_MESSAGE_RULE ||--o{ MESSAGE : "spawns"
COUPON ||--o{ MESSAGE : "attached_to"
MESSAGE ||--o{ MESSAGE_RECIPIENT : "delivers_to"
CUSTOMER ||--o{ MESSAGE_RECIPIENT : "receives"
MESSAGE {
string id PK
string store_id FK
string kind "manual/auto"
string title
string body
string audience_filter_json
string coupon_id FK
string auto_rule_id FK
timestamptz scheduled_at
timestamptz sent_at
int delivered_count
int failed_count
}
MESSAGE_RECIPIENT {
string id PK
string message_id FK
string customer_id FK
string status "queued/sent/bounced/failed"
timestamptz delivered_at
string error
}
AUTO_MESSAGE_RULE {
string id PK
string store_id FK
string trigger_event "reservation_confirmed/visit_completed/no_visit_days..."
int offset_hours "負数=来店前"
string title
string body_template
string coupon_id
boolean enabled
}
COUPON {
string id PK
string store_id FK
string name
string scope "hpb/direct/both"
int discount_amount
int discount_bp "1000=10%"
string condition_json
date valid_from
date valid_to
int max_uses
}

7. スマート支払い・精算コンテキスト

Section titled “7. スマート支払い・精算コンテキスト”
erDiagram
STORE ||--o{ SMART_PAY_TXN : "receives"
RESERVATION ||--o{ SMART_PAY_TXN : "settled_by"
STORE ||--o{ POINT_LEDGER : "accrues"
STORE ||--o{ SETTLEMENT : "receives_payout"
SMART_PAY_TXN {
string id PK
string store_id FK
string reservation_id FK
string provider "stripe/gmo/komoju"
string provider_txn_id UK
int auth_amount
int captured_amount
int refund_amount
int cancel_fee_amount
string status "authorized/captured/refunded/cancelled/failed"
timestamptz authorized_at
timestamptz captured_at
}
POINT_LEDGER {
string id PK
string store_id FK
string year_month UK "YYYYMM"
int earned
int redeemed
int settle_amount
string status "pending/confirmed/paid"
timestamptz settled_at
}
SETTLEMENT {
string id PK
string store_id FK
string year_month
string kind "smartpay/point/paygfee"
int gross
int fee
int net
string status
timestamptz paid_at
}

8. 掲載コンテンツ(媒体連携)コンテキスト

Section titled “8. 掲載コンテンツ(媒体連携)コンテキスト”
erDiagram
STORE ||--o{ SALON_LISTING : "publishes"
SALON_LISTING ||--o{ STAFF_LISTING : "features"
SALON_LISTING ||--o{ MENU_LISTING : "features"
SALON_LISTING ||--o{ PHOTO_GALLERY : "features"
STAFF ||--o| STAFF_LISTING : "represented_by"
MENU ||--o| MENU_LISTING : "represented_by"
STORE ||--o{ SALON_KODAWARI : "has"
KODAWARI_TAG ||--o{ SALON_KODAWARI : "applied_to"
STORE ||--o{ SALON_FEATURE_ENTRY : "entries"
FEATURE ||--o{ SALON_FEATURE_ENTRY : "hosts"
STORE ||--o{ BLOG_POST : "writes"
STAFF ||--o{ BLOG_POST : "authors"
STORE ||--o{ REVIEW : "receives"
RESERVATION ||--o| REVIEW : "reviewed_as"
SALON_LISTING {
string id PK
string store_id FK
string plan_code
string plan_name
date period_from
date period_to
jsonb draft
jsonb published
timestamptz published_at
}
STAFF_LISTING {
string id PK
string store_id FK
string staff_id FK
string listing_id FK
string catch_copy
string profile
string specialty
jsonb photos
}
MENU_LISTING {
string id PK
string store_id FK
string menu_id FK
string listing_id FK
string public_name
string price_display
string description
}
PHOTO_GALLERY {
string id PK
string store_id FK
string listing_id FK
string category "hair/nail/interior/before_after"
string url
string caption
int display_order
}
KODAWARI_TAG {
string id PK
string name
string category
}
SALON_KODAWARI {
string store_id FK
string tag_id FK
}
FEATURE {
string id PK
string name
date period_from
date period_to
}
SALON_FEATURE_ENTRY {
string id PK
string store_id FK
string feature_id FK
string status "draft/submitted/approved"
}
BLOG_POST {
string id PK
string store_id FK
string author_staff_id
string title
string body_rich
string cover_image
string category
string status "draft/scheduled/published/archived"
timestamptz scheduled_at
timestamptz published_at
int view_count
int like_count
}
REVIEW {
string id PK
string store_id FK
string customer_id FK
string reservation_id FK
int rating "1..5"
string body
timestamptz posted_at
string reply
timestamptz replied_at
string replied_by_staff_id
string moderation_status "visible/hidden/reported"
}

補足: マルチテナント運用の規約

Section titled “補足: マルチテナント運用の規約”
  1. 全テーブルに store_id。ただし純粋なマスタ(KODAWARI_TAG, FEATURE)は例外。
  2. FK制約はテナント内のみ を PostgreSQL のチェック制約で強制。
    ALTER TABLE reservation
    ADD CONSTRAINT fk_customer_same_tenant
    FOREIGN KEY (store_id, customer_id)
    REFERENCES customer (store_id, id);
    → 複合FKで「違うテナントのリソースを参照できない」をDBレベルで保証。
  3. RLSポリシー(Postgres移行/本番化のタイミングで有効化):
    ALTER TABLE reservation ENABLE ROW LEVEL SECURITY;
    CREATE POLICY tenant_isolation ON reservation
    USING (store_id = current_setting('app.current_store_id'));
  4. アプリ層ではリクエスト開始時に SET LOCAL app.current_store_id を必ず呼ぶ。
  5. graphile-worker のジョブペイロードにも storeId を必ず含める。ワーカー側でもテナントコンテキストを復元してからクエリを実行。

補足: 予約ステータスと状態遷移

Section titled “補足: 予約ステータスと状態遷移”
stateDiagram-v2
[*] --> 仮予約確定待ち : ネット予約受信
[*] --> 受付待ち : 電話/直接登録
仮予約確定待ち --> 受付待ち : サロンが確定
仮予約確定待ち --> お断り : サロンが拒否
仮予約確定待ち --> 自動キャンセル : 期限切れ
仮予約確定待ち --> お客様キャンセル : 客都合
受付待ち --> 施術中 : チェックイン
受付待ち --> お客様キャンセル : 客都合
受付待ち --> サロンキャンセル : サロン都合
受付待ち --> 無断キャンセル : 来店せず
施術中 --> 来店処理待ち : 施術完了
来店処理待ち --> 会計済み : レジ会計
会計済み --> [*]
お断り --> [*]
お客様キャンセル --> [*]
サロンキャンセル --> [*]
無断キャンセル --> [*]
自動キャンセル --> [*]

補足: graphile-worker が使うジョブの例

Section titled “補足: graphile-worker が使うジョブの例”

予約ドメインから派生する非同期タスクの棚卸し。全ペイロードに storeId を含める。

タスク名起点ペイロード遅延
send-reservation-reminder予約確定時にenqueue{storeId, reservationId}visitDate - 2日
auto-cancel-expired-reservation予約作成時にenqueue{storeId, reservationId}受付期限到達時
charge-cancel-fee-smartpayステータス変更時{storeId, reservationId}即時
send-message-batch配信予約時{storeId, messageId}scheduled_at
auto-message-trigger-scancron: 5分毎なし-
aggregate-daily-salescron: 日次 3:00なし-
settle-monthly-pointscron: 月次 1日 4:00なし-
export-reservations-csvユーザー操作{storeId, operatorId, filter, password}即時
resize-gallery-photoアップロード時{storeId, photoId}即時
cleanup-old-sessionscron: 日次 2:00なし-