Plan 4-D-2: LINE 連携 User と旧 manual User のマージ管理画面

Plan ID
4-D-2
作成日
2026-05-17
前提
Plan 4-D-1 (LINE セルフ登録) main マージ済
対象
staff-web (Vite + React 19, shadcn/ui), API (Rails 8)
主な利用者
受付スタッフ (staff)、院長 (admin)
ブランチ
feat/plan-4-d-2-line-merge

概要 (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 用語は受付向け: 「同一患者として登録」「登録を取り消し」「要確認
2
候補強度 (強 / 弱)
5
API エンドポイント
3
入口 (予約 / 患者 / ダッシュボード)
1
新規テーブル (audit log)
undo 期限

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 は技術用語で統一する。

同一患者として登録
merge

2 つの User を 1 つの実体として統合する操作

登録を取り消し
undo merge

マージ前の状態へ戻す逆操作

要確認
merge candidate

同一人物の可能性がある manual / LINE のペア

強候補
strong candidate

phone (正規化) + kana が両方一致

弱候補
weak candidate

kana のみ一致 (phone 不一致 / NULL)

同一患者の候補
merge candidates list

候補一覧画面のタイトル

3. 主要シナリオ

マージ画面は 3 つの入口 + undo 履歴 の 4 シナリオで使われる。

S1

受付が予約画面で即マージ

  1. 予約カレンダー/一覧を開く
  2. LINE 患者予約に「要確認」フラグ
  3. フラグ → モーダル → 比較表確認
  4. 「同一患者として登録」
  5. モーダル閉じる → 予約が manual に紐付き
S2

患者一覧から確認してマージ

  1. 患者一覧を開く
  2. LINE User の行に「要確認」フラグ
  3. クリック → モーダル → 確認 → 登録
S3

棚卸し作業 (ダッシュボードから)

  1. 「要確認 N 件」バッジをクリック
  2. /merge-candidates へ遷移
  3. 強候補一覧 (弱トグル ON/OFF)
  4. 1 件選択 → 詳細比較 → 登録
  5. 連続処理
S4

誤マージの取り消し (undo)

  1. /merge-candidates の「登録履歴」タブ
  2. 過去マージを一覧
  3. 「登録を取り消し」 → 確認
  4. manual / LINE が元の状態に復元

4. 照合ロジック (候補検出)

候補は永続化せず 都度 SQL クエリで計算する (MergeCandidateFinder service)。マージ済 (provider=line) や soft_deleted は where 句で自動除外されるため、「一生フラグが残る」問題は構造的に発生しない。

manual 候補集合 provider=manual active=true, deleted_at IS NULL LINE 候補集合 provider=line active=true, deleted_at IS NULL JOIN ON m.kana = l.kana 強候補 phone 正規化一致 → ダッシュ/予約/患者すべてに表示 弱候補 phone 不一致 / NULL → /merge-candidates のトグルのみ

強度ラベルと表示先

表示先strongweak
ダッシュボードバッジ「要確認 N 件」主表示サブ表示 (例: "弱候補 12 件")
予約画面 / 患者画面のフラグ表示非表示
/merge-candidates 一覧既定「弱候補も表示」トグル ON 時

phone 正規化

-- manual: '090-1234-5678' → '09012345678'
regexp_replace(m.phone, '[^0-9]', '', 'g') = l.phone
manual の phone はハイフン許容、LINE の phone は Plan 4-D-1 zod 検証で 10〜11 桁数字のみに正規化済。

5. データ移植ルール (マージ実行時)

残す側 = manual User、吸収側 = LINE User。snapshot を user_merge_logs.snapshot (JSONB) に保存して undo で逆適用する。

manual 側 (残る、上書きあり)

providermanual → line 上書き
uidnil → LINE.uid 上書き
date_of_birth空 (nil/blank) の場合のみ LINE 値で更新
notification_pref変更なし
full_name / kana / phone変更なし
patient_number変更なし (継続)
address / memo変更なし

LINE 側 (吸収、休眠化)

uid→ nil (unique 制約回避)
deleted_atTime.current
activefalse
jtiregenerate_jti! (旧 JWT 失効)
他フィールドそのまま (履歴用)

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 を一括化。

GET/api/v1/merge_candidates?strength=strong|all

候補一覧 (ページング、強度フィルタ)

200403
GET/api/v1/merge_candidates/count

バッジ用カウント { strong, weak } (cache 30s)

200403
GET/api/v1/users/:id/merge_candidates

単一 User 起点の候補 (予約/患者画面フラグ)

200403404
POST/api/v1/merge_candidates/merge

{ manual_user_id, line_user_id } でマージ実行

200403404409422
DELETE/api/v1/merge_candidates/merges/:log_id

undo: snapshot を逆適用

200403404409

エラーコード詳細

HTTP条件
409manual がすでに provider: line / LINE がすでに soft_deleted / 候補に該当しない (kana 不一致)
422manual_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 層の二重防御。

受付 A 受付 B DB POST /merge (manual=42, line=88) POST /merge (同上) FOR UPDATE manual=42 line=88 (lock by A) 200 OK + audit log 作成 B のロック解除 line.deleted_at 確認 409 (Mismatch)
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)
MergeFieldDiffmanual / 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+1MergeCandidateFinder で includes / preload 明示
undo 整合性マージ後 manual に別 LINE uid が attach されていたら undo 不可 (409)
監査user_merge_logs で誰がいつどのペアをマージ / 取消したか追跡
i18nstaff-web の既存日本語慣行。英語化はスコープ外

14. リスクと緩和策

Risk
受付による誤マージ
Mitigation
比較表で DOB / 過去予約数 / 住所を強調表示。undo を期限なしサポート
Risk
同姓同名 (弱候補) で誤判定
Mitigation
デフォルトは強候補のみ。弱候補はトグル ON 時のみ
Risk
並行マージで二重実行
Mitigation
lock("FOR UPDATE") + partial unique index
Risk
undo 後の状態不整合
Mitigation
snapshot で元の manual.uid 一致確認。違えば 409
Risk
マージ済バッジ残留
Mitigation
SQL where 句で provider / active / deleted_at 厳密絞り

15. 実装フェーズ (writing-plans で詳細化予定)

Phase内容規模
API-1user_merge_logs migration + User 拡張 + MergeCandidateFinder + 5 API + Policy + request spec
Web-1MergeFieldDiff + MergeConfirmDialog + API クライアント + Vitest
Web-2/merge-candidates ページ + 履歴タブ + フィルタ + 連続マージ
Web-3ダッシュボードバッジ + 予約 / 患者画面フラグ + RequireConfirmFlag
E2EW20 / W21 / W22
Polishコピー / コントラスト / a11y / N+1 検証 / pr-review 反映
本 spec は brainstorming 段階の確定事項。実装計画 (タスク分解 / TDD ステップ / レビュー gate) は次の writing-plans 段階で作成する。