Skip to content

キーワード設定数の上限拡張(段階リリース版)

概要

項目内容
ステータス🔵 提案中
Issue-
担当-
前案#1-2 改訂版

1 ロケーションあたりのキーワード上限を 8 → 15 件 に拡張する。1 ユーザが複数店舗を保有しているが、全店舗が一律に増件するわけではない ため、店舗単位(per-location)の可変上限 を主軸に据える。リスクを抑えるため 3 段階リリース で進める:

  • Phase 1(DB 直接編集リリース) — 設定の仕組みだけ先行リリース。対象店舗の開放は ops が SQL で実施
  • Phase 2(管理画面 UI リリース) — 営業 / CS が画面から店舗別上限を設定できるようにする
  • Phase 3(セルフサービス申請ワークフロー) — 顧客が自身のアカウントから「上限引き上げ申請」を送信し、管理者が承認 / 却下する

提案内容

背景・課題

  • 現状のキーワード件数上限は 8 件(hard-code が 7 箇所に分散:v2 参照
  • 顧客から 10 件以上のキーワード追跡 の要望が増加
  • 1 ユーザが複数店舗を保有mappy_users 1:N mappy_gbp_locations)し、増件は店舗ごとに採否が分かれる
  • v2 の改訂で「店舗別 / 契約別どちらでも課金可能なデータモデル」を提案したが、フル UI を含めるとリリースまでが長い
  • 営業側からは「先に数店舗だけでも 15 件化したい」という早期開放のニーズあり

→ 設定インフラ(DB + バックエンド + 顧客 UI)を Phase 1 で先出しし、Phase 2 で UI 操作工数を削減する 段階リリース を採用

提案するソリューション

Phase範囲開放手段リリースまでの工数目安
Phase 1DB / バックエンド / 顧客 UI / Export / スクレイピングops が SQL で店舗別レコードを INSERT約 7.5 人日
Phase 2管理画面 UI(店舗別上限編集)/ ActivityLog / Admin API営業 / CS が画面から操作約 4.5 人日
Phase 3顧客 UI(申請)/ Admin 承認キュー / Email 通知 / 申請テーブル顧客が申請 → 管理者が承認 → 自動反映約 6.0 人日
合計18.0 人日

v2 案との差分:

  • scope_type='location' を Phase 1 のメインで運用(contract 単位は将来用に予約)
  • リリース範囲を 2 段階に分離(v2 はワンショットリリース前提)
  • 顧客 UI / Export / スクレイピング改修は Phase 1 に集約(Phase 2 を UI のみに絞る)

データモデル

keyword-limit-v2 と同一スキーマを採用し、scope_type の二値性を維持して将来の契約単位課金にも対応できるようにする。

mappy_keyword_limit_settings
├─ id              BIGINT PK
├─ scope_type      ENUM('contract','location')   -- Phase 1 は 'location' のみ運用
├─ scope_id        BIGINT                        -- gbp_location_id(contract の場合は mappy_user_id)
├─ system_id       BIGINT                        -- 既存テナント識別子
├─ max_keywords    TINYINT UNSIGNED              -- 8〜15
├─ effective_from  DATE                          -- 適用開始日
├─ effective_to    DATE NULLABLE                 -- 期限なしは NULL
├─ memo            VARCHAR(255) NULLABLE         -- 営業メモ(課金根拠)
├─ created_at / updated_at
└─ UNIQUE KEY (scope_type, scope_id, effective_from)

上限解決ロジック

resolve_max_keywords(gbp_location_id, user_id, today)
  = location-scope の有効レコード.max_keywords
  ?? contract-scope の有効レコード.max_keywords
  ?? plan-default (permissions.js: user-standard-plan=8, free=1, meo-booster=8)

「有効」= effective_from <= today AND (effective_to IS NULL OR today <= effective_to)

Phase 1 — DB 直接編集による段階リリース

スコープ

  • [x] mappy_keyword_limit_settings テーブル新設 + Laravel migration
  • [x] Keyword / GbpLocation モデルに resolveMaxKeywords() 解決メソッド追加
  • [x] FormRequest バリデーションを上限解決ロジックに差し替え
  • [x] 顧客 UI(KeywordSettings.vue)の hard-code MAX_KEYWORDS_COUNT = 8 を撤廃し、API レスポンスから動的取得
  • [x] GbpLocation.php:209.fillKeys(1, 8) を可変化
  • [x] ExportController.php:2291, 2431 の CSV/Excel ヘッダを動的化
  • [x] PlacesAPI export_data [:8][:resolved_max] に拡張
  • [x] スクレイピング側 hard-code 3 箇所を可変化(scraping repo
  • [x] 対象店舗の開放は ops が運用 SQL で実施(次節)

Laravel migration

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('mappy_keyword_limit_settings', function (Blueprint $table) {
            $table->id();
            $table->enum('scope_type', ['contract', 'location'])
                  ->comment('適用範囲: location=店舗単位, contract=ユーザ契約単位');
            $table->unsignedBigInteger('scope_id')
                  ->comment('scope_type=location → gbp_locations.id / contract → mappy_users.id');
            $table->unsignedBigInteger('system_id')->comment('既存テナント識別子');
            $table->unsignedTinyInteger('max_keywords')->comment('8〜15');
            $table->date('effective_from');
            $table->date('effective_to')->nullable();
            $table->string('memo', 255)->nullable()->comment('営業メモ・課金根拠');
            $table->timestamps();

            $table->unique(
                ['scope_type', 'scope_id', 'effective_from'],
                'uk_scope_effective'
            );
            $table->index(['scope_type', 'scope_id'], 'idx_scope');
            $table->index('system_id', 'idx_system');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('mappy_keyword_limit_settings');
    }
};

運用 SQL テンプレート(ops 用)

docs/operations/keyword-limit-release.sql として配置する想定。

sql
-- =========================================================
-- Phase 1 運用 SQL: 特定店舗を 15 件枠に開放する
-- =========================================================
-- 実行前チェック:
--   1. 対象 gbp_location_id をマーケ / CS から書面で受領済み
--   2. system_id は mappy_gbp_locations.system_id を参照
--   3. 実行者・対象店舗・課金理由を Slack #releases に投稿
--   4. 必ず STAGING で先行確認すること
-- =========================================================

START TRANSACTION;

-- 対象店舗の事前確認
SELECT id AS gbp_location_id, system_id, name
FROM mappy_gbp_locations
WHERE id IN (/* 対象 location_id をここに記載 */);

-- INSERT(複数店舗を一括で扱う場合は行を追加)
INSERT INTO mappy_keyword_limit_settings
  (scope_type, scope_id, system_id, max_keywords,
   effective_from, effective_to, memo,
   created_at, updated_at)
VALUES
  ('location',
   /* gbp_location_id */,
   /* system_id */,
   15,
   CURDATE(),
   NULL,
   '営業: ◯◯案件 / 起票: TICKET-XXXX',
   NOW(), NOW());

-- 反映確認
SELECT s.scope_type, s.scope_id, l.name, s.max_keywords,
       s.effective_from, s.effective_to, s.memo
FROM mappy_keyword_limit_settings s
JOIN mappy_gbp_locations l ON l.id = s.scope_id
WHERE s.scope_type = 'location'
  AND s.scope_id IN (/* 対象 location_id をここに記載 */);

-- 問題なければ:
COMMIT;
-- ロールバックする場合:
-- ROLLBACK;

ロールアウト手順

  1. STAGING で動作確認:1 店舗だけ 15 件に開放し、顧客 UI / Export / scraping / PlacesAPI 経路を全部走査
  2. 本番リリース:migration → コード deploy(hard-code 解消も同時に out)
  3. 対象店舗開放:CS から受領した location_id 一覧を運用 SQL に流し込み → COMMIT
  4. 動作確認:対象店舗の顧客アカウントで KeywordSettings 画面を開き、9〜15 番目のスロットが入力可能になっていることを確認

Phase 1 工数

#作業項目工数(人日)担当
1要件確認・設計書作成1.0設計者
2DB / API 設計・解決ロジック設計1.0設計者
3migration + モデル + FormRequest 改修1.0製造者
4顧客 UI 改修(API 取得化・可変入力欄)1.0製造者
5PlacesAPI export_data + スクレイピング改修1.0製造者
6CSV ヘッダ + ランキング表示の動的化1.0製造者
7結合テスト(PlacesAPI / Export / 顧客 UI)1.0製造者
8デプロイ・運用 SQL テンプレ整備・初回開放0.5製造者
Phase 1 合計7.5

Phase 2 — 管理画面 UI による設定の自走化

スコープ

  • 既存の admin guard(auth:admin_api)配下に 店舗別上限設定 UI を新設
  • 既存ページ /admin/users/:userId/detailsMappyUserDetail.vue)に タブ 3「キーワード上限」を追加 — 新規ルート / 新規サイドバー項目は作らない
  • 既存 API /api/mappy/admin/users/{user}/locations-and-groups のレスポンスに max_keywords(resolved + override)を含めるよう最小拡張
  • ユーザ配下の店舗一覧テーブルに max_keywords の入力欄 + 保存ボタンを追加
  • 保存時に ActivityLog に課金根拠を記録(誰が・いつ・どの店舗を・何件に変更したか)
  • バリデーション:8 <= max_keywords <= 15
  • 顧客 UI への即時反映(キャッシュ無効化)

管理画面の動作仕様

API 設計

MethodPath用途
GET/api/mappy/admin/users/{user}/locations-and-groups既存 API。max_keywords(resolved + override)を含めるよう最小拡張
PATCH/api/mappy/admin/locations/{gbp_location}/keyword-limit店舗別上限を upsert(body: { max_keywords, memo })— 新設
DELETE/api/mappy/admin/locations/{gbp_location}/keyword-limit店舗別 override を解除(plan default に戻す)— 新設

Phase 2 工数

#作業項目工数(人日)担当
1UI 設計(wireframe + admin モック追加)0.5設計者
2Admin API 実装(GET / PATCH / DELETE)1.0製造者
3管理画面 UI 実装(UserDetail にタブ追加 + KeywordLimitTab.vue 新設)1.5製造者
4ActivityLog 連携 + 即時反映処理0.5製造者
5結合テスト + デプロイ1.0製造者
Phase 2 合計4.5

Phase 3 — セルフサービス申請ワークフロー

Phase 2 のリリース後、顧客が自分で「上限引き上げ申請」を送信 できるようにする。営業 / CS が手動で受け付けて画面操作するモデルを、顧客起点の承認ワークフロー に進化させる。

スコープ

  • 申請テーブル mappy_keyword_limit_requests を新設
  • 顧客 UI(KeywordSettings.vue)に 「上限引き上げを申請する」 ボタン + 申請モーダルを追加
  • 顧客 UI に 申請ステータスバッジ(申請中 / 却下)を表示
  • 顧客は pending 状態の申請をキャンセル可能
  • Admin に 承認キュー画面 /admin/keyword-limit-requests を新設(サイドバー項目「申請承認」を追加)
  • 承認時:自動で mappy_keyword_limit_settings に INSERT + ActivityLog 記録 + 顧客にメール
  • 却下時:理由 memo を保存 + 顧客にメール
  • メール配信は NotificationSettings のメールアドレスリストを使用

データモデル(Phase 3 で追加)

mappy_keyword_limit_requests
├─ id                   BIGINT PK
├─ mappy_user_id        BIGINT          -- 申請者
├─ gbp_location_id      BIGINT          -- 対象店舗
├─ current_max          TINYINT         -- 申請時点の resolved 値(履歴用)
├─ requested_max        TINYINT (9〜15)
├─ reason               VARCHAR(500)    -- 顧客が記入した理由
├─ status               ENUM('pending','approved','rejected','cancelled')
├─ requested_at         DATETIME
├─ reviewed_by          BIGINT NULL     -- 承認した admin の user_id
├─ reviewed_at          DATETIME NULL
├─ review_note          VARCHAR(500) NULL  -- 管理者メモ(却下理由など)
├─ applied_setting_id   BIGINT NULL     -- 承認時に作成された settings の FK
└─ created_at / updated_at

INDEX (mappy_user_id, status)         -- 顧客側の自分の申請一覧
INDEX (status, requested_at)          -- admin キュー(pending 古い順)

状態遷移

申請〜承認の全体フロー

API 設計(Phase 3 で追加)

MethodPath用途認証
POST/api/mappy/keyword-limit-requests顧客が申請を送信auth:api(顧客)
GET/api/mappy/keyword-limit-requests顧客が自分の申請一覧を取得auth:api(顧客)
DELETE/api/mappy/keyword-limit-requests/{id}顧客が pending を取消auth:api(顧客)
GET/api/mappy/admin/keyword-limit-requestsadmin が申請キューを取得(status filter)auth:admin_api
PATCH/api/mappy/admin/keyword-limit-requests/{id}/approve承認auth:admin_api
PATCH/api/mappy/admin/keyword-limit-requests/{id}/reject却下auth:admin_api

顧客 UI 改修ポイント

場所改修
KeywordSettings.vue ヘッダーresolved max 表示の隣に申請ステータスバッジ
KeywordSettings.vue 入力欄下「上限引き上げを申請する」ボタン(resolved max < 15 のときのみ表示)
新規 KeywordLimitRequestModal.vue申請フォーム(requested_max スライダー / 理由 textarea / 送信ボタン)
申請中の表示9 〜 requested_max のスロットを「申請中」グレーアウト

管理画面(Phase 3 で新設)

サイドバー追加項目

resources/js/mappy/_navigationAdmin.js1 項目だけ追加

js
{ text: '申請承認', value: 'KeywordLimitRequests', icon: '/images/...', badge: 'pending_count' }

badge: 'pending_count' は未承認件数を赤バッジで表示(既存パターンを踏襲)。

キュー画面 /admin/keyword-limit-requests

要素内容
フィルタステータス(pending / approved / rejected / all) / 期間 / ユーザ検索
テーブル申請日時 / loginId / 店舗名 / 現在値 / 申請値 / 理由 / ステータス
行クリックサイドペインで詳細表示 → 「承認」「却下(理由必須)」ボタン
一括操作チェックボックス選択 → まとめて承認 / 却下(同じ review_note)

Email 通知

Laravel Mailable 2 種類を新設:

クラストリガ件名主な内容
KeywordLimitRequestApprovedMailapprove API【MAPPY】キーワード上限引き上げ申請が承認されました申請内容 + 承認日時 + 反映後の上限
KeywordLimitRequestRejectedMailreject API【MAPPY】キーワード上限引き上げ申請について申請内容 + 却下理由(review_note)

送信先:NotificationSettings で設定された mailAddresses 全件(最低 1 件は契約時のメイン email)。

Phase 3 工数

#作業項目工数(人日)担当
1要件確認 + Phase 3 設計書(state machine 含む)1.0設計者
2migration(requests テーブル)+ Model + ステートマシン0.5製造者
3顧客 API 3 本(POST / GET / DELETE)0.5製造者
4Admin API 3 本(GET / approve / reject)+ 自動 INSERT0.5製造者
5Email Mailable 2 種類 + テンプレート0.5製造者
6顧客 UI(モーダル + バッジ + 申請中スロット表示)1.0製造者
7Admin 承認キュー画面 + サイドバー追加1.0製造者
8結合テスト(メール送信 / 状態遷移 / 自動反映)0.5製造者
9デプロイ + 動作確認0.5製造者
Phase 3 合計6.0

画面モック

顧客側(Phase 1 で改修)

キーワード管理

登録キーワード8 / 8件
1MEO対策
2美容室 渋谷
3ヘアサロン
4カット 安い
5縮毛矯正
6トリートメント
7ヘッドスパ
8カラー 渋谷
上限に達しています。上限撤廃モードを有効にしてください。

変更内容

項目現行変更後
キーワード上限8件無制限(推奨10件以上)
スクレイピング8件固定登録数に応じて動的
表示固定レイアウトスクロール対応

管理側(Phase 2 で新規)

詳細な UI 設計は 画面設計:管理画面 — 店舗別キーワード上限設定 を参照。

アカウント情報

user-standard-planプラン上限:8 件契約:2025-04-01 〜 2026-03-31(12ヶ月)所属店舗数:5
このユーザ配下の店舗ごとに 8〜15 件の範囲でキーワード上限を設定できます。
対象ユーザ配下の店舗一覧
店舗名プラン値現在の上限新しい上限メモ(課金根拠)有効期間操作
渋谷本店
location_id: 8801
815
2026-06-01
〜 無期限
新宿西口店
location_id: 8802
88
〜 無期限
池袋東口店
location_id: 8803
812
2026-05-15
〜 2026-12-31
横浜西口店
location_id: 8804
88
〜 無期限
川崎駅前店
location_id: 8805
810
2026-06-01
〜 無期限
上限の範囲は 8〜15 件。8 にすると override が解除され、プランデフォルト適用に戻ります。

呼び出し API(参考)

MethodPath役割
GET/api/mappy/admin/users/{user}/locations-and-groups店舗一覧取得(既存 API を拡張:resolved + override を含める)
PATCH/api/mappy/admin/locations/{loc}/keyword-limit店舗別上限を upsert(新設)
DELETE/api/mappy/admin/locations/{loc}/keyword-limitoverride 解除(新設)

顧客側 申請フロー(Phase 3 で新規)

キーワード管理

申請中 (申請: 12 件)
登録キーワード8 / 8 件
1
2
3
4
5
6
7
8
9
申請中
10
申請中
11
申請中
12
申請中
13
🔒 ロック
14
🔒 ロック
15
🔒 ロック

管理側 承認キュー(Phase 3 で新規)

キーワード上限引き上げ申請

未承認 3 件本日 2 件承認待ち最古:2026-05-28 16:45
申請日時申請者対象店舗現在希望理由ステータス
2026-05-29 10:32
田中 太郎
tanaka_taro_001
渋谷本店812新メニュー導入により追加で 4 キーワードを追跡したい未承認
2026-05-29 09:18
佐藤 花子
sato_hanako_023
横浜駅前店815競合分析強化のため上限まで申請未承認
2026-05-28 16:45
鈴木 一郎
suzuki_ichi_007
池袋東口店1215冬季キャンペーン用に+3未承認

設計検討事項(v2 から継続)

PlacesAPI 側の影響

  • 1 ロケーションあたり 8 → 15 件で API 呼び出し +87%
  • 現在の 3 GCP プロジェクトキー構成(QPM 1800)で必要 QPM 940 → 余裕あり(プロジェクト増設不要)
  • 利用料は Basic SKU で無料
  • 所要時間:200 秒 → ~375 秒(夜間バッチで吸収可能)
  • Phase 1 では対象店舗が少ないため負荷増は限定的

スクレイピング側の影響

  • 残存 ~33 店舗のみ → 全件 15 件化でも所要時間 +39 分
  • ハードコード修正は 3 箇所のみ

課金システム連携の方針

mappy は外部プラットフォーム(KUCHIKOMIONE / G-COR / pipit)からの支払い通知を受け取る構造で、課金エンジンは持たない。本案件は 「管理画面で手動設定可能」までを範囲 とし、課金プラットフォーム側の商品マスタ追加・通知 payload 拡張は別建ての後続案件とする。

概算工数(AI 前提)

体制

役割人数担当内容
設計者1 名要件確認 → AI に設計書作成指示 → レビュー → 製造へ指示
製造者1 名ISSUE を元に AI に作成指示 → コードレビュー → テスト実施 → デプロイ

合計

Phase工数
Phase 1(DB 直接編集リリース)7.5 人日
Phase 2(管理画面 UI リリース)4.5 人日
Phase 3(セルフサービス申請ワークフロー)6.0 人日
合計18.0 人日

前提条件・制約

  • ハードリミット = 15、最小 = 8(既存契約据え置き)
  • Phase 1 リリース直後は ops が SQL で開放、Phase 2 で UI 化
  • 課金プラットフォーム連携は本案件範囲外
  • PlacesAPI 既存 3 キー構成で 15 件化のリクエスト増を吸収(追加プロジェクト不要)

スケジュール

タスク担当日数6/16/26/36/46/56/66/76/86/96/106/116/126/136/146/156/166/176/186/196/206/216/226/236/246/256/26
P1 要件確認・設計書作成設計者1d
P1 DB・API 設計設計者1d
P1 migration + モデル製造者1d
P1 顧客 UI 改修製造者1d
P1 PlacesAPI/スクレイピング製造者1d
P1 CSV/ランキング表示製造者1d
P1 結合テスト製造者1d
P1 デプロイ + 初回開放製造者1d
P2 UI 設計設計者1d
P2 Admin API製造者1d
P2 管理画面 UI製造者2d
P2 ActivityLog + 結合テスト + デプロイ製造者1d
P3 要件確認 + 設計設計者1d
P3 申請テーブル + Model製造者1d
P3 API(顧客 + 管理)+ Mailable製造者2d
P3 顧客 UI(モーダル + バッジ)製造者1d
P3 承認キュー画面製造者1d
P3 結合テスト + デプロイ製造者1d