キーワード設定数の上限拡張(段階リリース版)
概要
| 項目 | 内容 |
|---|---|
| ステータス | 🔵 提案中 |
| 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 1 | DB / バックエンド / 顧客 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-codeMAX_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
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 として配置する想定。
-- =========================================================
-- 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;ロールアウト手順
- STAGING で動作確認:1 店舗だけ 15 件に開放し、顧客 UI / Export / scraping / PlacesAPI 経路を全部走査
- 本番リリース:migration → コード deploy(hard-code 解消も同時に out)
- 対象店舗開放:CS から受領した
location_id一覧を運用 SQL に流し込み → COMMIT - 動作確認:対象店舗の顧客アカウントで KeywordSettings 画面を開き、9〜15 番目のスロットが入力可能になっていることを確認
Phase 1 工数
| # | 作業項目 | 工数(人日) | 担当 |
|---|---|---|---|
| 1 | 要件確認・設計書作成 | 1.0 | 設計者 |
| 2 | DB / API 設計・解決ロジック設計 | 1.0 | 設計者 |
| 3 | migration + モデル + FormRequest 改修 | 1.0 | 製造者 |
| 4 | 顧客 UI 改修(API 取得化・可変入力欄) | 1.0 | 製造者 |
| 5 | PlacesAPI export_data + スクレイピング改修 | 1.0 | 製造者 |
| 6 | CSV ヘッダ + ランキング表示の動的化 | 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/details(MappyUserDetail.vue)に タブ 3「キーワード上限」を追加 — 新規ルート / 新規サイドバー項目は作らない - 既存 API
/api/mappy/admin/users/{user}/locations-and-groupsのレスポンスにmax_keywords(resolved + override)を含めるよう最小拡張 - ユーザ配下の店舗一覧テーブルに
max_keywordsの入力欄 + 保存ボタンを追加 - 保存時に ActivityLog に課金根拠を記録(誰が・いつ・どの店舗を・何件に変更したか)
- バリデーション:
8 <= max_keywords <= 15 - 顧客 UI への即時反映(キャッシュ無効化)
管理画面の動作仕様
API 設計
| Method | Path | 用途 |
|---|---|---|
| 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 工数
| # | 作業項目 | 工数(人日) | 担当 |
|---|---|---|---|
| 1 | UI 設計(wireframe + admin モック追加) | 0.5 | 設計者 |
| 2 | Admin API 実装(GET / PATCH / DELETE) | 1.0 | 製造者 |
| 3 | 管理画面 UI 実装(UserDetail にタブ追加 + KeywordLimitTab.vue 新設) | 1.5 | 製造者 |
| 4 | ActivityLog 連携 + 即時反映処理 | 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 で追加)
| Method | Path | 用途 | 認証 |
|---|---|---|---|
| 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-requests | admin が申請キューを取得(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.js に 1 項目だけ追加:
{ 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 種類を新設:
| クラス | トリガ | 件名 | 主な内容 |
|---|---|---|---|
KeywordLimitRequestApprovedMail | approve API | 【MAPPY】キーワード上限引き上げ申請が承認されました | 申請内容 + 承認日時 + 反映後の上限 |
KeywordLimitRequestRejectedMail | reject API | 【MAPPY】キーワード上限引き上げ申請について | 申請内容 + 却下理由(review_note) |
送信先:NotificationSettings で設定された mailAddresses 全件(最低 1 件は契約時のメイン email)。
Phase 3 工数
| # | 作業項目 | 工数(人日) | 担当 |
|---|---|---|---|
| 1 | 要件確認 + Phase 3 設計書(state machine 含む) | 1.0 | 設計者 |
| 2 | migration(requests テーブル)+ Model + ステートマシン | 0.5 | 製造者 |
| 3 | 顧客 API 3 本(POST / GET / DELETE) | 0.5 | 製造者 |
| 4 | Admin API 3 本(GET / approve / reject)+ 自動 INSERT | 0.5 | 製造者 |
| 5 | Email Mailable 2 種類 + テンプレート | 0.5 | 製造者 |
| 6 | 顧客 UI(モーダル + バッジ + 申請中スロット表示) | 1.0 | 製造者 |
| 7 | Admin 承認キュー画面 + サイドバー追加 | 1.0 | 製造者 |
| 8 | 結合テスト(メール送信 / 状態遷移 / 自動反映) | 0.5 | 製造者 |
| 9 | デプロイ + 動作確認 | 0.5 | 製造者 |
| Phase 3 合計 | 6.0 |
画面モック
顧客側(Phase 1 で改修)
キーワード管理
変更内容
| 項目 | 現行 | 変更後 |
|---|---|---|
| キーワード上限 | 8件 | 無制限(推奨10件以上) |
| スクレイピング | 8件固定 | 登録数に応じて動的 |
| 表示 | 固定レイアウト | スクロール対応 |
管理側(Phase 2 で新規)
詳細な UI 設計は 画面設計:管理画面 — 店舗別キーワード上限設定 を参照。
アカウント情報
| 店舗名 | プラン値 | 現在の上限 | 新しい上限 | メモ(課金根拠) | 有効期間 | 操作 |
|---|---|---|---|---|---|---|
渋谷本店 location_id: 8801 | 8 | 15 | 2026-06-01 〜 無期限 | |||
新宿西口店 location_id: 8802 | 8 | 8 | — 〜 無期限 | |||
池袋東口店 location_id: 8803 | 8 | 12 | 2026-05-15 〜 2026-12-31 | |||
横浜西口店 location_id: 8804 | 8 | 8 | — 〜 無期限 | |||
川崎駅前店 location_id: 8805 | 8 | 10 | 2026-06-01 〜 無期限 |
呼び出し API(参考)
| Method | Path | 役割 |
|---|---|---|
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-limit | override 解除(新設) |
顧客側 申請フロー(Phase 3 で新規)
キーワード管理
管理側 承認キュー(Phase 3 で新規)
キーワード上限引き上げ申請
| 申請日時 | 申請者 | 対象店舗 | 現在 | 希望 | 理由 | ステータス | ||
|---|---|---|---|---|---|---|---|---|
| 2026-05-29 10:32 | 田中 太郎 tanaka_taro_001 | 渋谷本店 | 8 | 12 | 新メニュー導入により追加で 4 キーワードを追跡したい | 未承認 | ||
| 2026-05-29 09:18 | 佐藤 花子 sato_hanako_023 | 横浜駅前店 | 8 | 15 | 競合分析強化のため上限まで申請 | 未承認 | ||
| 2026-05-28 16:45 | 鈴木 一郎 suzuki_ichi_007 | 池袋東口店 | 12 | 15 | 冬季キャンペーン用に+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/1 | 6/2 | 6/3 | 6/4 | 6/5 | 6/6 | 6/7 | 6/8 | 6/9 | 6/10 | 6/11 | 6/12 | 6/13 | 6/14 | 6/15 | 6/16 | 6/17 | 6/18 | 6/19 | 6/20 | 6/21 | 6/22 | 6/23 | 6/24 | 6/25 | 6/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 |