コンテンツにスキップ

顧客登録・検索

Document ID: SRS-CUS-001 Parent: SRS-ROOT-001 v0.5 Version: 0.2 Status: Implemented (API + Web UI 最小) Last Updated: 2026-05-06 Depends on: SRS-TEN-001 v0.4, SRS-TEN-002 v0.2, SRS-TEN-003 v0.2, SRS-MST-001 v0.6 依存される: SRS-CUS-002, SRS-RES-002 v0.5, SRS-RES-003

本書は SRS-ROOT-001 v0.5 に従う。


店舗オペレーターが顧客を新規登録し、店舗内で高速に検索できるようにする。

Phase 1 では namename_kana最小必須入力とし、電話番号は E.164 で保存、重複検出は phone_e164 完全一致 warning のみで扱う。顧客統合 UI は Phase 2 以降の SRS-CUS-003 に委譲する。CSV 一括インポートは別 SRS-CUS-004(注: link 管理 SRS と番号衝突する場合は SRS-CUS-005 などへ振り直す。本 Phase 1 では SRS-CUS-004 は operator_staff_link を所有しているため、CSV インポートは Phase 2 に持ち越す形で OQ-CUS-001-CSV を残す)。


  • As a 受付, I want to 来店中に最小入力で顧客を素早く作成したい, so that 電話・店頭対応を止めずに予約へ進める
  • As a 店長, I want to カナで顧客検索したい, so that 同音異字の顧客も取りこぼさない
  • As a 受付, I want to 同じ電話番号の既存顧客がいたら警告してほしい, so that 重複作成を減らせる
  • As a 店長, I want to 店舗内通番の顧客番号を持たせたい, so that 受付票や会員証の運用に使える

  1. オペレーターが「お客様管理 > 新規登録」を開く
  2. namename_kana を入力する
  3. 任意で phone, email, birthday, gender, occupation, post_mail_opt_in, preferred_staff_id を入力する
  4. サーバが phone を E.164 へ正規化する
  5. トランザクション内で店舗単位 advisory lock を取得し、store_settings.customer_no_seq+1 して customer_no を採番する
  6. customer を INSERT する
  7. phone_e164 完全一致の既存顧客があれば warning を返す
  8. 一覧に遷移し、作成した顧客を表示する
  • AF-1 電話番号なしで登録する: 保存可。重複検出は行わない
  • AF-2 予約作成ダイアログからインライン新規作成: API 契約は同一、UI だけ簡略化
  • AF-3 preferred_staff_id だけ設定: スタッフ未リンクのオペレーターでも保存可
  • AF-4 PII 閲覧権限を持たないオペレーターが検索: 顧客は検索できるが phone/email/birthday はマスクされる(Phase 1 では admin:customer:read 内部の表示制御で実装、read_pii_sensitive 別 key は Phase 2)
  • EF-1 name 空文字: 422 CUSTOMER.INPUT_INVALID
  • EF-2 name_kana 空文字: 422 CUSTOMER.INPUT_INVALID
  • EF-3 phone が E.164 正規化不能: 422 CUSTOMER.PHONE_INVALID
  • EF-4 preferred_staff_id が同店舗の現役 staff でない: 422 CUSTOMER.PREFERRED_STAFF_INVALID
  • EF-5 他店舗顧客検索・更新試行: RLS により 404
  • EF-6 削除済み顧客は既定検索に出さない
  • EF-7 advisory lock 取得失敗: 503 CUSTOMER.RETRY または backoff retry

  • 顧客一覧検索画面
  • 顧客新規登録ダイアログ / 単独画面
  • 予約作成導線からの簡易顧客作成
  • 検索条件: q, phone, customer_no, preferred_staff_id
  • 一覧列: customer_no_display, name, name_kana, preferred_staff_name, created_at
  • 新規登録フォーム: 必須 name, name_kana
  • 任意項目: phone, email, birthday, gender, occupation, post_mail_opt_in, preferred_staff_id
  • 重複警告バナー: 「同一電話番号の顧客が存在」
  • name: 1..200、trim 後空不可
  • name_kana: 1..200、trim 後空不可(zod regex でカタカナ・ひらがな・スペース許可)
  • phone: E.164 正規化後保存
  • gender: female | male | unspecified
  • occupation: enum (student | employee | part_time | homemaker | self_employed | other)
  • post_mail_opt_in: boolean

MethodPath用途permission
GET/api/admin/customers顧客検索一覧admin:customer:read
POST/api/admin/customers顧客新規作成admin:customer:create
GET/api/admin/customers/{id}顧客サマリ取得admin:customer:read

GET /api/admin/customers query:

  • q?: string(name / name_kana に pg_trgm GIN 検索)
  • phone?: string(E.164 正規化後 exact match)
  • customer_no?: string
  • preferred_staff_id?: uuid
  • cursor?: string
  • limit?: 1..100 default 50

POST /api/admin/customers request:

  • name: string
  • name_kana: string
  • phone?: string
  • email?: string
  • birthday?: YYYY-MM-DD
  • gender?: 'female' | 'male' | 'unspecified'
  • occupation?: enum
  • post_mail_opt_in?: boolean
  • preferred_staff_id?: uuid | null

response:

  • data.customer.id
  • data.customer.customer_no
  • data.customer.customer_no_display(6 桁ゼロパディング)
  • data.customer.name
  • data.customer.name_kana
  • warnings?: [{ code: 'CUSTOMER.DUPLICATE_PHONE', duplicate_customer_ids: uuid[] }]
  • CUSTOMER.INPUT_INVALID
  • CUSTOMER.PHONE_INVALID
  • CUSTOMER.PREFERRED_STAFF_INVALID
  • CUSTOMER.FORBIDDEN
  • CUSTOMER.NOT_FOUND

カラム制約
iduuidPK
store_iduuidNOT NULL, FK
customer_nobigintNOT NULL
namevarchar(200)NOT NULL, CHECK btrim(name) <> ''
name_kanavarchar(200)NOT NULL, CHECK btrim(name_kana) <> ''
phone_e164varchar(20)NULL
emailvarchar(255)NULL
birthdaydateNULL
gendervarchar(20)NOT NULL DEFAULT ‘unspecified’, CHECK IN ('female','male','unspecified')
occupationvarchar(30)NULL
post_mail_opt_inbooleanNOT NULL DEFAULT false
preferred_staff_iduuidNULL, 複合 FK → staff(store_id, id)
versionintegerNOT NULL DEFAULT 1
created_attimestamptzNOT NULL
updated_attimestamptzNOT NULL
deleted_attimestamptzNULL
制約UNIQUE(store_id, id)
制約UNIQUE(store_id, customer_no)
INDEXGIN (name_kana gin_trgm_ops)
INDEXGIN (name gin_trgm_ops)
INDEX(store_id, phone_e164)
INDEX(store_id, preferred_staff_id, created_at DESC)
INDEX(store_id, deleted_at) (90 日 purge job 用)

0018_customer_crm.sqlcustomer placeholder を昇格 + customer_note, customer_tag, customer_tag_link (CUS-002) も同時導入。

pg_trgm extension は bootstrap で有効化(infra/sql/bootstrap/001_roles_and_extensions.sql)。

customer_no_seq は SRS-TEN-001 v0.4 の 0011 で既に追加済み。

permission backfill (admin:customer:read/create) も 0018 で実施。


  • 店舗単位 advisory lock: pg_advisory_xact_lock(hashtext('customer_no:' || store_id))
  • ロック取得後 store_settings.customer_no_seq + 1 を取得し UPDATE
  • INSERT 時 customer.customer_no にセット
  • 表示は 6 桁ゼロパディング('000001' 等)。bigint なので 999999 超えても運用継続可(表示は 7 桁以上に拡張)
  • 入力 09012345678 → 保存 +819012345678
  • 入力 +819012345678 → 保存 そのまま
  • 不可解な形式 → CUSTOMER.PHONE_INVALID
  • phone_e164 完全一致の active 顧客があれば warning
  • 自動 merge は実施しない(Phase 2 SRS-CUS-003)
  • q パラメータは name / name_kana の trgm GIN
  • phone は exact match
  • customer_no は数値完全一致
  • 削除は deleted_at のみ
  • 90 日経過後の物理削除ジョブ cleanup-deleted-customers-for-store は SRS-WRK-001 で起票
  • customer.create / update
  • customer.delete(CUS-002 から)
  • 検索 GET は通常監査なし(個別の sensitive read は CUS-002 の read_pii_sensitive 系で議論)

  • 顧客検索 API p95 300ms 以下
  • name_kana GIN は 10 万件まで効く想定

  • admin:customer:read: 全 role
  • admin:customer:create: 全 role(受付・スタッフが現場で登録)
  • RLS は store_id 強制
  • PII 表示マスキングは Phase 1 では未実装(admin:customer:read 単一)

10. 受け入れ基準(Given-When-Then)

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1: Given namename_kana を入力 / When 保存 / Then 顧客が作成され customer_no_display が 6 桁で返る
  • GWT-2: Given 同店舗に同一 phone_e164 の顧客 / When 新規作成 / Then 作成成功し CUSTOMER.DUPLICATE_PHONE warning が返る
  • GWT-3: Given 削除済み顧客 / When 既定の一覧検索 / Then 含まれない
  • GWT-4: Given operator が operator_staff_link 無し / When admin:customer:create で登録 / Then 正常保存(self-scope は適用しない)
  • GWT-5: Given カナ検索 'タナカ' / When pg_trgm GIN 検索 / Then 該当顧客が返る
  • GWT-6: Given customer_no_seq=42 / When 新規作成 / Then customer_no=43 で保存され customer_no_display='000043'
  • GWT-7: Given 別店舗のセッション / When 顧客取得 / Then 404
  • GWT-8: Given phone='abc' / When 保存 / Then 422 CUSTOMER.PHONE_INVALID

  • Unit: phone 正規化、customer_no 表示、kana trim
  • Integration: duplicate warning, RLS, advisory lock concurrency
  • Contract: zod schema

  • cleanup-deleted-customers-for-store(SRS-WRK-001)

#内容扱い
OQ-CUS-001-01occupation enum vs free textPhase 1 enum、Phase 2 検討
OQ-CUS-001-02電話番号 suffix match を許すかPhase 1 exact、Phase 2 検討
OQ-CUS-001-03PII マスキング (read_pii_sensitive) を Phase 1 で出すかPhase 2(OQ)
OQ-CUS-001-CSVCSV 一括インポート (本家互換、SRS-CUS-005 仮)Phase 2 へ持越し

VersionDateAuthorChange
0.12026-05-05Codex / yudai初版ドラフト
0.22026-05-05yudai (with Codex co-design)Round 2 反映: migration 番号 0018 確定、name + name_kana 必須、customer_no_seq の advisory lock 採番ロジック確定、operator_staff_link は self-scope に効かないと明記、CSV インポートは Phase 2 OQ-CUS-001-CSV へ持越し