Plan 4-D-2: LINE 連携 User と旧 manual User のマージ管理画面
概要 (TL;DR)
- Plan 4-D-1 で「LINE 自己登録は突合せず常に新規 User 作成」と決めた結果、既存通院患者 (manual) が LINE 登録すると同一人物に 2 つの User レコードが並存する
- 受付が staff-web 上で 2 レコードを 同一患者として登録 (= マージ) する管理機能を実装する
- 判定は phone (正規化) + kana 両方一致 = 強候補 / kana のみ一致 = 弱候補 の 2 階層
- 残す側は manual User。LINE の uid / provider を manual に上書きし、LINE は soft_delete
- 誤マージのため undo を期限なしサポート。audit log (
user_merge_logs) で復元 - UI 用語は受付向け: 「同一患者として登録」「登録を取り消し」「要確認」
1. 背景
Plan 4-D-1 で LINE セルフ登録を導入した結果、患者を表す users レコードが 2 経路で作られるようになった。同一人物が DB 上で別レコードになると、以下の運用問題が発生する。
❌ Before (放置)
- LINE アプリから過去の予約履歴が見えない (履歴は旧 manual 側)
- 受付が「この LINE User は誰?」と判断に迷う
- 予約リマインドが LINE に届かない (manual 側に LINE 連携なし)
- 同じ患者の予約が新旧 2 レコードに分散
✓ After (Plan 4-D-2 で解決)
- マージ後の 1 User に予約・連携・通知が集約
- 受付は「要確認」フラグから 1 クリックでマージ
- カルテ番号 (P00012) を継続。受付の業務との断絶なし
- 誤マージは undo で即復元 (期限なし)
2. 用語
UI 表記は受付向けにわかりやすく、コード/DB/API は技術用語で統一する。
2 つの User を 1 つの実体として統合する操作
マージ前の状態へ戻す逆操作
同一人物の可能性がある manual / LINE のペア
phone (正規化) + kana が両方一致
kana のみ一致 (phone 不一致 / NULL)
候補一覧画面のタイトル
3. 主要シナリオ
マージ画面は 3 つの入口 + undo 履歴 の 4 シナリオで使われる。
受付が予約画面で即マージ
- 予約カレンダー/一覧を開く
- LINE 患者予約に「要確認」フラグ
- フラグ → モーダル → 比較表確認
- 「同一患者として登録」
- モーダル閉じる → 予約が manual に紐付き
患者一覧から確認してマージ
- 患者一覧を開く
- LINE User の行に「要確認」フラグ
- クリック → モーダル → 確認 → 登録
棚卸し作業 (ダッシュボードから)
- 「要確認 N 件」バッジをクリック
/merge-candidatesへ遷移- 強候補一覧 (弱トグル ON/OFF)
- 1 件選択 → 詳細比較 → 登録
- 連続処理
誤マージの取り消し (undo)
/merge-candidatesの「登録履歴」タブ- 過去マージを一覧
- 「登録を取り消し」 → 確認
- manual / LINE が元の状態に復元
4. 照合ロジック (候補検出)
候補は永続化せず 都度 SQL クエリで計算する (MergeCandidateFinder service)。マージ済 (provider=line) や soft_deleted は where 句で自動除外されるため、「一生フラグが残る」問題は構造的に発生しない。
強度ラベルと表示先
| 表示先 | strong | weak |
|---|---|---|
| ダッシュボードバッジ「要確認 N 件」 | 主表示 | サブ表示 (例: "弱候補 12 件") |
| 予約画面 / 患者画面のフラグ | 表示 | 非表示 |
/merge-candidates 一覧 | 既定 | 「弱候補も表示」トグル ON 時 |
phone 正規化
-- manual: '090-1234-5678' → '09012345678'
regexp_replace(m.phone, '[^0-9]', '', 'g') = l.phone
5. データ移植ルール (マージ実行時)
残す側 = manual User、吸収側 = LINE User。snapshot を user_merge_logs.snapshot (JSONB) に保存して undo で逆適用する。
manual 側 (残る、上書きあり)
LINE 側 (吸収、休眠化)
snapshot の中身 (undo 用)
{
"manual_original_provider": "manual",
"manual_original_uid": null,
"manual_original_date_of_birth_was_blank": true,
"line_original_uid": "U7e9c...",
"line_original_jti": "<uuid>",
"line_original_deleted_at": null,
"line_original_active": true
}
6. データモデル
users テーブルはスキーマ変更なし。新規 1 テーブルのみ追加。
新規テーブル: user_merge_logs
create_table :user_merge_logs do |t|
t.references :merged_into_user, null: false,
foreign_key: { to_table: :users }, index: true
t.references :merged_from_user, null: false,
foreign_key: { to_table: :users }, index: true
t.references :executed_by_user, null: false,
foreign_key: { to_table: :users }, index: true
t.datetime :executed_at, null: false
t.datetime :undone_at
t.references :undone_by_user,
foreign_key: { to_table: :users }
t.jsonb :snapshot, null: false, default: {}
t.timestamps
end
# undo 後は再マージ可能なため partial unique index
# merged_from (LINE) に partial unique: 同時に 1 manual にしか紐付かない
# merged_into (manual) は unique にしない: 過去複数の LINE 紐付け / 取消が可能
add_index :user_merge_logs,
[:merged_from_user_id],
unique: true,
where: "undone_at IS NULL",
name: "index_user_merge_logs_active_from"
7. API 設計
認可は staff / admin 両方可。React Query は ["merge-candidates", ...] プレフィックスで invalidate を一括化。
候補一覧 (ページング、強度フィルタ)
バッジ用カウント { strong, weak } (cache 30s)
単一 User 起点の候補 (予約/患者画面フラグ)
{ manual_user_id, line_user_id } でマージ実行
undo: snapshot を逆適用
エラーコード詳細
| HTTP | 条件 |
|---|---|
| 409 | manual がすでに provider: line / LINE がすでに soft_deleted / 候補に該当しない (kana 不一致) |
| 422 | manual_user_id と line_user_id の役割が逆 (provider 不一致) |
| 409 (undo) | すでに undo 済 / 復元先 manual に別の LINE uid が再 attach 済 |
8. 認可
Pundit policy 新規追加。Plan 3-B の患者削除と同等の権限レベル。
class UserMergeLogPolicy < ApplicationPolicy
def index? = staff_or_admin?
def show? = staff_or_admin?
def merge? = staff_or_admin?
def undo? = staff_or_admin?
end
9. 競合制御
2 受付が同じ LINE User を同時にマージしようとした場合、後勝ち側は 409 で弾く。DB 層と Service 層の二重防御。
def execute(manual_user_id:, line_user_id:, executed_by:)
ActiveRecord::Base.transaction do
manual = User.lock("FOR UPDATE").find(manual_user_id)
line = User.lock("FOR UPDATE").find(line_user_id)
raise Mismatch unless mergeable?(manual:, line:)
snapshot = build_snapshot(manual:, line:)
apply_merge!(manual:, line:)
UserMergeLog.create!(...)
end
end
10. staff-web 構成
共通コンポーネントを MergeFieldDiff に集約し、モーダル (S1/S2) とページ (S3) で再利用。
ルート & コンポーネント
| パス / コンポーネント | 役割 |
|---|---|
/merge-candidates | 候補一覧 + 詳細比較 + 連続マージ (S3)。?tab=history で undo 履歴 (S4)。shadcn Tabs で切替 |
MergeConfirmDialog | モーダル本体。予約画面 / 患者画面のフラグから起動 (S1 / S2) |
MergeFieldDiff | manual / LINE / 採用値 の 3 列比較表。差分強調 |
MergeCandidateRow | 一覧の 1 行。強度バッジ + プロフィール抜粋 + 「同一患者として登録」 |
RequireConfirmFlag | 「要確認」インジケータ。予約 / 患者 Row に配置 |
React Query キー
mergeCandidates: {
list: (filters) => ["merge-candidates", "list", filters],
count: () => ["merge-candidates", "count"],
forUser: (id) => ["merge-candidates", "for-user", id],
}
mergeLogs: { list: (page) => ["merge-logs", "list", page] }
マージ / undo 後 invalidate: ["merge-candidates"] 全体、["patients"] 全体、["reservations"] 全体。
11. テスト戦略
API request spec
- 候補一覧 (強 / 弱 / マージ済除外 / ページング)
- バッジカウント (weak オプション)
- 単一 User 起点 (provider 自動判定)
- マージ (成功 / 403 / 404 / 409 / 422 / 並行)
- undo (成功 / 403 / 404 / 409 / partial unique 衝突)
staff-web Vitest + MSW
- MergeFieldDiff: 差分強調 / 採用値
- MergeConfirmDialog: 開閉 / 実行 / エラー
- MergeCandidatesPage: フィルタ / 連続 / 履歴
- DashboardBadge: 0 件非表示
- RequireConfirmFlag: 表示判定
E2E (Playwright)
- W20: 予約画面マージ (S1)
- W21: 棚卸し連続マージ (S3)
- W22: undo (S4)
12. スコープ
✓ IN (本 Plan)
- 候補検出 SQL (強 / 弱)
- API 5 エンドポイント
user_merge_logsテーブル/merge-candidatesページ- MergeConfirmDialog モーダル
- 「要確認」フラグ (予約 / 患者)
- ダッシュボードバッジ
- 認可 (Pundit)
- audit log + undo
- request spec / Vitest / E2E
✗ OUT (別 Plan)
- 「別人として無視」機能 (Plan 4-D-3 候補)
- manual 登録時の phone 必須緩和 (別件 Issue)
- Google ログイン enum 完全撤廃 (別件)
- 複数 manual ↔ 1 LINE の振り分け選択
- リアルタイム push 通知
- マージ前後の患者通知
♻ REUSE
- Plan 4-D-1: LINE セルフ登録 (provider/uid 体系)
- Plan 3-B: PatientFormSheet / PatientRow パターン
- Plan 4-C: audit_log 構造 (reservation_audit_logs)
- 既存 User.active scope / soft_delete!
- FOR UPDATE + transaction (single-use トークン前例)
13. 非機能要件
| 項目 | 要件 |
|---|---|
| 候補検出パフォーマンス | 患者 10,000 件想定で /count が 500ms 以下 |
| 候補一覧の N+1 | MergeCandidateFinder で includes / preload 明示 |
| undo 整合性 | マージ後 manual に別 LINE uid が attach されていたら undo 不可 (409) |
| 監査 | user_merge_logs で誰がいつどのペアをマージ / 取消したか追跡 |
| i18n | staff-web の既存日本語慣行。英語化はスコープ外 |
14. リスクと緩和策
lock("FOR UPDATE") + partial unique indexmanual.uid 一致確認。違えば 409provider / active / deleted_at 厳密絞り15. 実装フェーズ (writing-plans で詳細化予定)
| Phase | 内容 | 規模 |
|---|---|---|
| API-1 | user_merge_logs migration + User 拡張 + MergeCandidateFinder + 5 API + Policy + request spec | 中 |
| Web-1 | MergeFieldDiff + MergeConfirmDialog + API クライアント + Vitest | 中 |
| Web-2 | /merge-candidates ページ + 履歴タブ + フィルタ + 連続マージ | 中 |
| Web-3 | ダッシュボードバッジ + 予約 / 患者画面フラグ + RequireConfirmFlag | 小 |
| E2E | W20 / W21 / W22 | 小 |
| Polish | コピー / コントラスト / a11y / N+1 検証 / pr-review 反映 | 小 |