# 公開射影スクリプト 実装仕様書
# PUBLISH_PROJECTION_SPEC.md
# 言問手形アプリ × 言問名店DB 連携

作成: 2026-06-06
担当: minister-cto（CTO大臣）
実装担当: Codex（夜勤製造ライン）
ステータス: 仕様確定・実装前

---

## 0. ドキュメントの位置づけ

本仕様書はCodexが単独で実装できる粒度で記述する。
「何を作るか」はCTO大臣が本書で確定済み。Codexは実装とテストのみを担う。

---

## 1. 概要と設計原則

### 1.1 目的

meiten-master.json（106社・社内正本）から、手形アプリのGlide/Googleスプレッドシートへ
書き込む公開用派生ビューを生成する。

### 1.2 設計原則（変えてはならない3原則）

1. **単一正本**: meiten-master.json が唯一の正本。手形側に独自マスターを作らない。
   手形スプシは常に meiten-master.json の派生ビューであり、手形側を直接編集しない。

2. **既定非公開（ホワイトリスト方式）**: フィールドは原則非公開。
   §3のホワイトリストに明示されたフィールドのみ公開シートへ書き出す。

3. **課金はオーガニック順位を変えない**: スポンサー枠とオーガニック順位は独立レイヤー。
   順位算定式にスポンサー契約額・spendKnownTotal 等の金銭フィールドを入れてはならない。

---

## 2. 入力・出力の定義

### 2.1 入力

| ファイル/シート | パス | 役割 |
|---|---|---|
| meiten-master.json | `C:\Users\daito\company\projects\kototoi-sanpo-editorial\meiten-business\data\meiten-master.json` | 社内正本（106社） |
| T_TegataLog | 手形スプシの T_TegataLog シート | チェックイン実績（来店データ逆流の入力） |

### 2.2 出力（手形スプレッドシートへの書き込み）

手形スプレッドシートID: **要CEO提供（未確認）**

既存の M_Spot_seed.csv（`C:\Users\daito\Documents\言問事業\02_言問手形\app\data\M_Spot_seed.csv`）
が手形スプシ M_Spot シートの雛型。本スクリプトはこのシートを上書き更新する。

| 出力シート | 役割 | 更新頻度 |
|---|---|---|
| `M_Spot_pub` | 名店公開マスター（公開射影の出力先） | 日次 |
| `meiten-master.json` の `checkinStats` フィールド | 来店実績の逆流書き戻し | 日次 |

**注意**: 既存 M_Spot シート（自社5店＋既存パートナー10店）は触らない。
M_Spot_pub は名店DB由来の外部加盟店専用の別シートとして追加する。
Glide側でM_SpotとM_Spot_pubを同一ソースとして扱うか別テーブルにするかは
Glide構築担当がGLIDE_BUILD_GUIDE.md改訂時に判断する（本スクリプトの範囲外）。

---

## 3. フィールドマッピング表（全フィールド網羅）

凡例:
- 公開 = M_Spot_pub シートに出力する
- 算定入力 = 順位算定の内部計算にのみ使用し、出力には出さない（スコア素点を出さない）
- 社内専用 = いかなる形でも出力しない（絶対）

### 3.1 meiten-master.json の全フィールド

| masterフィールド | 分類 | M_Spot_pub での列名 | 備考 |
|---|---|---|---|
| storeId | 公開 | `spot_id` | 手形のspot_idとしてそのまま使用 |
| name | 公開 | `name_ja` | |
| nameNormalized | 公開 | `name_normalized` | 検索用 |
| category | 公開 | `category` | |
| area.ward | 公開 | `ward` | |
| area.town | 公開 | `town` | |
| address | 公開 | `address_ja` | |
| lat | 公開 | `lat` | |
| lng | 公開 | `lng` | |
| googleMapsUrl | 公開 | `google_maps_url` | |
| officialSite | 公開 | `official_site` | null/"不明" は空文字に変換 |
| reviewComment | 公開 | `description_ja` | 店の紹介文として使用 |
| tags | 公開 | `tags_json` | JSON文字列としてシリアライズ |
| is_stamp_target | 公開 | `is_stamp_target` | ※後述§3.2で定義する派生フィールド |
| photo_url | 公開 | `photo_url` | ※後述§3.2で定義（masterには未存在・null許容） |
| sponsor_tier | 公開 | `sponsor_tier` | ※後述§3.2で定義する派生フィールド |
| organic_rank | 公開 | `organic_rank` | ※後述§3.2で定義する派生フィールド（順位帯） |
| is_sponsored | 公開 | `is_sponsored` | sponsor_tierが"non"以外ならTRUE |
| **label** | **社内専用** | - | L1a/L2等のセグメントラベル。絶対に出さない |
| **labelNote** | **社内専用** | - | ラベル補足。絶対に出さない |
| **issuesPlaced** | **社内専用** | - | 掲載号リスト。絶対に出さない |
| **lastIssue** | **社内専用** | - | 最終掲載号。絶対に出さない |
| **status** | **社内専用** | - | 継続/途絶等。絶対に出さない |
| **contractType** | **社内専用** | - | 契約種別。絶対に出さない |
| **spendKnownTotal** | **社内専用** | - | 累計出稿額。絶対に出さない |
| **lastSpend** | **社内専用** | - | 直近出稿額。絶対に出さない |
| **lapsed** | **社内専用** | - | 途絶フラグ。絶対に出さない |
| **potentialTier** | **社内専用** | - | ポテンシャル評価。絶対に出さない |
| **tegataFit.reason** | **社内専用** | - | 手形適性の理由。絶対に出さない |
| **nextAction** | **社内専用** | - | 営業次アクション。絶対に出さない |
| **salesReps** | **社内専用** | - | 営業担当者情報。絶対に出さない |
| **researchConfidence** | **社内専用** | - | 調査確度。絶対に出さない |
| tegataFit.level | 算定入力 | - | 高/中/低を数値変換して順位算定に使用 |
| hotelEastFit | 算定入力 | - | 高/中/低を数値変換して順位算定に使用 |
| worldviewFit | 算定入力 | - | テキストから高/中/低を抽出して順位算定に使用 |
| rating | 算定入力 | - | score数値を順位算定に使用。URLや出典は出さない |
| checkinStats | 算定入力 | - | 逆流してきた来店実績。算定に使用（§5参照） |

### 3.2 M_Spot_pub 追加派生フィールド（masterに存在しない・スクリプトが算定・付与）

| フィールド名 | 型 | 定義 |
|---|---|---|
| `is_stamp_target` | BOOLEAN | 手形スタンプラリー対象フラグ。tegataFit.level=="高" かつ status が社内で"継続"のものをTRUE。ただし status は内部判定にのみ使い、出力には出さない |
| `sponsor_tier` | STRING | "premium" / "standard" / "light" / "non" の4値（§4.3参照） |
| `is_sponsored` | BOOLEAN | sponsor_tier != "non" の場合 TRUE |
| `organic_rank_band` | STRING | "top10" / "top30" / "other" の3帯。素点は出さない |
| `photo_url` | STRING | 写真URL（masterに存在しないためnull許容。将来フィールド追加で対応） |
| `updated_at` | DATETIME | 本スクリプトの実行タイムスタンプ |

---

## 4. 射影ロジック（擬似コード）

```python
# =============================================================================
# publish_projection.py  ─  公開射影スクリプト（擬似コード）
# =============================================================================

# ---- 定数 ----------------------------------------------------------------

WHITELIST = [
    "storeId", "name", "nameNormalized", "category",
    "area.ward", "area.town", "address", "lat", "lng",
    "googleMapsUrl", "officialSite", "reviewComment", "tags",
    # 派生フィールド（スクリプトが算定後に付与）
    "is_stamp_target", "sponsor_tier", "is_sponsored",
    "organic_rank_band", "photo_url", "updated_at",
]

FORBIDDEN_FIELDS = [
    "label", "labelNote", "issuesPlaced", "lastIssue",
    "status", "contractType", "spendKnownTotal", "lastSpend",
    "lapsed", "potentialTier", "tegataFit.reason",
    "nextAction", "salesReps", "researchConfidence",
]

SPONSOR_TIER_MAP = {
    # contractType → sponsor_tier のマッピング（社内フィールドを入力とし、
    # 出力は tier 区分のみ。金額・contractType は出力しない）
    # ★ 現時点では contractType と手形加盟の対応が確定していないため
    # 初期値はすべて "non" とし、別途 sponsor_tier_override.json で上書きする方式を採る
    # sponsor_tier_override.json のスキーマは §4.4 参照
}

# ---- 順位算定 ------------------------------------------------------------

def compute_organic_score(store, checkin_stats):
    """
    オーガニック順位スコア算定。
    入力: masterレコード + 来店実績
    出力: float スコア（外部には出さない。バンドに変換して公開）

    スコア式（重みは初期値。月次PDCAで調整可）:
        score = w1 * tegata_fit_score
              + w2 * worldview_fit_score
              + w3 * hotel_east_fit_score
              + w4 * rating_score
              + w5 * checkin_count_score

    w1=0.35, w2=0.25, w3=0.15, w4=0.15, w5=0.10（合計1.0）

    各スコアの変換:
        tegata_fit_score:
            高=1.0, 中=0.5, 低=0.0, None=0.0
        worldview_fit_score:
            "高"を含む=1.0, "中"を含む=0.5, "低"を含む=0.0, None=0.0
        hotel_east_fit_score:
            高=1.0, 中=0.5, 低=0.0, None=0.0
        rating_score:
            float(rating.score) を 0〜1 に正規化（最小2.5〜最大5.0の線形スケール）
            rating が None=0.5（中立）
        checkin_count_score:
            過去30日のチェックイン件数を 0〜1 に正規化（上位1%=1.0）
            実績ゼロ=0.0
    """
    score = (0.35 * tegata_fit_score(store)
           + 0.25 * worldview_fit_score(store)
           + 0.15 * hotel_east_fit_score(store)
           + 0.15 * rating_score(store)
           + 0.10 * checkin_score(store, checkin_stats))
    return score

def score_to_rank_band(score, all_scores):
    """
    スコアを順位帯に変換（素点は外部に出さない）
    上位10位 → "top10"
    11〜30位 → "top30"
    それ以外 → "other"
    """
    sorted_scores = sorted(all_scores, reverse=True)
    rank = sorted_scores.index(score) + 1
    if rank <= 10:
        return "top10"
    elif rank <= 30:
        return "top30"
    else:
        return "other"

# ---- スポンサー区分付与 ---------------------------------------------------

def resolve_sponsor_tier(store, override_map):
    """
    加盟ティアを解決する。
    優先順位: sponsor_tier_override.json の明示値 > masterのcontractType派生 > "non"

    sponsor_tier_override.json は社内担当者が手動管理する別ファイル。
    スキーマ: {"MTN-001": "premium", "MTN-002": "standard", ...}

    contractType から tier を推定する場合のルール（暫定・運用中に確定）:
        表4年契 / 表3年契 → "standard"（精査後に override で上書き）
        言問ブランド年契 → "standard"
        単発掲載 / L6未接触 → "non"
        L4グループ内 → "non"（自社施設は非スポンサー扱い）

    重要: contractType・spendKnownTotal は出力しない。tier区分のみ出力。
    """
    store_id = store["storeId"]
    if store_id in override_map:
        return override_map[store_id]
    # fallback: contractType 派生（暫定ロジック）
    ct = store.get("contractType", "")
    if "年契" in ct and store.get("label", "").startswith("L1"):
        return "standard"
    return "non"

# ---- ゲート判定（出力対象かどうか） -------------------------------------

def is_publishable(store):
    """
    公開対象かどうかの判定。
    以下の条件を ALL 満たす場合のみ公開する。

    1. tegataFit.level が "高" または "中"
       （"低" / None の店はアプリ上での表示価値が低い）
    2. category が "不動産" / "建設" / "住宅" 等の非来街型カテゴリでない
       EXCLUDE_CATEGORIES = ["不動産", "建設", "住宅", "金融", "保険"]
    3. lat / lng が存在する（地図表示不能なレコードは除外）
    4. sponsor_tier が "non" 以外 OR tegataFit.level == "高"
       （非加盟かつ手形適性が低い店は一旦除外）

    ※ status（継続/途絶）は社内フィールドのため判定に使えない。
       代わりに tegataFit.level を実質的なアクティブ判定として使う。
    """
    ...

# ---- ホワイトリスト適用 --------------------------------------------------

def apply_whitelist(store):
    """
    masterレコードからホワイトリストフィールドのみを抽出する。
    FORBIDDEN_FIELDSが1つでも含まれていたらValueErrorを投げる（フェイルセーフ）。
    """
    pub = {}
    for field in WHITELIST:
        if "." in field:
            parent, child = field.split(".", 1)
            pub[field.replace(".", "_")] = (store.get(parent) or {}).get(child)
        else:
            pub[field] = store.get(field)
    # セキュリティゲート: 禁止フィールドが含まれていないことを確認
    for forbidden in FORBIDDEN_FIELDS:
        assert forbidden not in pub, f"SECURITY GATE FAILURE: {forbidden} in output"
    return pub

# ---- メインフロー --------------------------------------------------------

def run_projection(master_path, tegata_sheet_id, override_path, dry_run=False):
    """
    メインエントリーポイント。

    1. meiten-master.json を読み込む
    2. T_TegataLog シートからチェックイン実績を集計
    3. 各 store に対して:
       a. is_publishable() でゲート判定
       b. organic_score を算定
       c. sponsor_tier を解決
       d. apply_whitelist() でホワイトリスト適用
       e. 派生フィールド（is_stamp_target / is_sponsored / organic_rank_band）を付与
    4. organic_rank_band を全店の相対順位で算定
    5. M_Spot_pub シートを冪等に更新（§6参照）
    6. checkinStats を meiten-master.json に書き戻す（§5参照）
    7. セキュリティテスト（§7参照）を実行
    8. ログを記録（§6.4参照）
    """
    ...
```

---

## 4.3 sponsor_tier の定義（別ファイル管理）

スポンサー情報は以下の2ファイルで管理する。

| ファイル | パス | 役割 | 管理者 |
|---|---|---|---|
| `sponsor_tier_override.json` | `meiten-business/data/sponsor_tier_override.json` | storeId → tier の明示マッピング | 社内担当者 |
| `meiten-master.json` | 同上 | contractType等の社内情報（入力のみ使用） | 編集局 |

```json
// sponsor_tier_override.json のスキーマ例
{
  "_meta": {
    "description": "手形アプリ掲載ティア。premium/standard/light/non の4値。",
    "last_updated": "2026-06-06"
  },
  "MTN-002": "standard",
  "MTN-003": "standard"
}
```

**重要**: このファイルには金額・契約詳細は書かない。tier区分のみ。

---

## 4.4 景表法・ステマ規制の実装上の注意

### 景表法（優良誤認・有利誤認）
- `organic_rank_band` を「おすすめ」「人気」等の表現で表示する場合、
  算定根拠（チェックイン数・評価等）を「選出基準」としてアプリ内に記載すること。
  スクリプト側の責務ではなく、Glide UI 実装側の責務。

### ステマ規制（景品表示法5条3号・2023年10月施行）
- `is_sponsored == TRUE` の店舗は、Glide UI 側で必ず「PR」「広告」「提携」等の
  **明示的な表示**をすること。スクリプトは `is_sponsored` フラグを正確に出力する責務を持つ。
- `is_sponsored` フラグが FALSE なのに PR 表示する、または TRUE なのに PR 表示しない
  実装は規制違反。Glide 構築担当に本仕様書を共有して周知すること。

### 個人情報（来店ログ）
- T_TegataLog の `member_id` は個人を特定できる識別子。
  スクリプトは集計済みの件数・統計値のみを checkinStats に書き戻し、
  個人単位の来店記録を meiten-master.json に書き込んではならない。

---

## 5. 来店データ逆流（checkinStats フィールド）

### 5.1 逆流フィールドの定義

meiten-master.json の各 store レコードに以下のフィールドを追加する:

```json
"checkinStats": {
  "total_checkins": 42,
  "last_30d_checkins": 8,
  "last_checkin_at": "2026-06-05T14:23:00Z",
  "updated_at": "2026-06-06T03:00:00Z"
}
```

### 5.2 逆流ロジック

```python
def aggregate_checkins(tegata_log_rows, store_id):
    """
    T_TegataLog から spot_id が store_id に一致するレコードを集計。
    T_TegataLog のスキーマ:
        log_id, member_id, spot_id, card_id, acquired_at, acquired_method, device_lang

    注意: spot_id と storeId の名前空間が異なる可能性がある。
    spot_id 側は "spot_001" 形式、storeId 側は "MTN-001" 形式。
    両者を突合するためのマッピングテーブルが必要。

    ★ 要確認: spot_id と storeId のマッピング方法は手形PM（pm-kototoi-tegata）に確認。
    現時点での暫定実装: M_Spot_pub に spot_id を master の storeId と同値で書き込み、
    T_TegataLog の spot_id と突合する。
    """
    rows = [r for r in tegata_log_rows if r["spot_id"] == store_id]
    total = len(rows)
    cutoff = datetime.utcnow() - timedelta(days=30)
    last_30d = len([r for r in rows if parse_dt(r["acquired_at"]) >= cutoff])
    last_at = max((r["acquired_at"] for r in rows), default=None)
    return {
        "total_checkins": total,
        "last_30d_checkins": last_30d,
        "last_checkin_at": last_at,
        "updated_at": datetime.utcnow().isoformat() + "Z",
    }
```

### 5.3 逆流時の制約

- 逆流は meiten-master.json の `checkinStats` フィールドのみを更新する。
- 他の社内フィールド（label/status/spendKnownTotal等）には触れない。
- 逆流前に meiten-master.json のバックアップを取る（§6.3参照）。

---

## 6. 冪等性・差分更新・運用設計

### 6.1 冪等性の実装

```python
def upsert_spot_pub(sheets_service, spreadsheet_id, pub_rows):
    """
    M_Spot_pub シートを冪等に更新する。

    1. 既存シートを全行読み込む
    2. spot_id をキーにして、変更があった行のみ更新（差分更新）
    3. 新規 spot_id は末尾に追加
    4. マスターから削除された spot_id は M_Spot_pub から行を削除しない（論理削除フラグ方式）
       → is_stamp_target=FALSE にして表示から外すにとどめる

    全件上書きは Glide との競合リスクがあるため採用しない。
    """
```

### 6.2 実行頻度

- **日次実行を推奨**: 毎朝 03:30 JST に実行（morning-autoprep の後・04:00完成ゲートの前）。
- 手動実行: `python publish_projection.py --dry-run` でスプシに書かずログのみ出力。
- 実行方法:
  ```
  python publish_projection.py [--dry-run] [--store-id MTN-XXX]
  ```
  `--store-id` オプションで単店テストが可能。

### 6.3 失敗時の安全側（Fail-Safe）

```
実行前: meiten-master.json のバックアップを
        meiten-business/data/backup/meiten-master.YYYYMMDD-HHMMSS.json
        に作成する。バックアップ失敗時はスクリプトをアボートする。

M_Spot_pub 更新中に例外が発生した場合:
    - スプシへの書き込みをロールバックせず途中で止まる（部分更新状態）
    - ログに ERROR を記録
    - 既存の公開シートが壊れた場合に備え、前回の pub_rows.json バックアップから
      再実行できるよう pub_rows を data/backup/ に保存する

meiten-master.json への書き戻し（checkinStats）は、
スプシ更新が成功した後にのみ実行する（スプシ更新が失敗したまま master を書き換えない）。
```

### 6.4 ログ（GR13 準拠）

```
ログファイル: meiten-business/logs/publish-projection.jsonl

1実行 = 1レコード:
{
    "ts": "2026-06-06T03:30:00Z",
    "run_id": "proj-20260606-0330",
    "status": "success" | "partial_failure" | "aborted",
    "stores_evaluated": 106,
    "stores_published": 42,
    "stores_excluded": 64,
    "rows_upserted": 5,
    "rows_added": 2,
    "duration_sec": 12.3,
    "checkin_stats_updated": 42,
    "security_test_passed": true,
    "errors": []
}
```

---

## 7. セキュリティテスト（実行後 assert）

スクリプトは M_Spot_pub の全行に対して以下のテストを実行する。
テスト失敗時はスクリプトが例外を投げて終了し、ログに SECURITY FAILURE と記録する。

```python
FORBIDDEN_COLUMNS = [
    # 列名の完全一致 + 部分一致の両方でチェック
    "label", "labelNote", "issuesPlaced", "lastIssue",
    "status", "contractType", "spendKnownTotal", "lastSpend",
    "lapsed", "potentialTier", "reason",  # tegataFit.reason の reason 部分も検出
    "nextAction", "salesReps", "researchConfidence",
    "spend", "contract", "potential",  # 文字列部分一致で追加キャッチ
]

def run_security_test(pub_rows):
    """
    出力行のキー名に禁止フィールド名（またはその部分文字列）が含まれていないことを確認。
    一つでも発見したら ValueError を投げる。
    """
    for row in pub_rows:
        for key in row.keys():
            for forbidden in FORBIDDEN_COLUMNS:
                if forbidden.lower() in key.lower():
                    raise ValueError(
                        f"SECURITY GATE FAILURE: column '{key}' matches forbidden pattern '{forbidden}'"
                    )
    print("[SECURITY TEST] PASSED: no forbidden fields in output.")
```

---

## 8. Google スプレッドシート連携方式

### 8.1 認証方式（HQ方針準拠）

**GAS方式は禁止**（フィードバック `feedback_google_integration.md` 参照）。
OAuth Desktop App 方式を使用する。

参照: `C:\Users\daito\company\` 配下の Google連携手順書（feedback_google_integration.md）

```python
# 実装の起点（フィードバックの参照実装に準拠）
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]

def get_sheets_service(token_path, client_secret_path):
    """
    token.json が存在すれば再認証不要。
    初回のみ ブラウザ認証フローが走る（Desktop App方式）。
    """
    creds = None
    if os.path.exists(token_path):
        creds = Credentials.from_authorized_user_file(token_path, SCOPES)
    if not creds or not creds.valid:
        flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES)
        creds = flow.run_local_server(port=0)
        with open(token_path, "w") as f:
            f.write(creds.to_json())
    return build("sheets", "v4", credentials=creds)
```

### 8.2 スプレッドシートIDの確認

手形スプレッドシートIDは現時点で未確認。

**要CEO提供**: 手形アプリのGoogleスプレッドシートのURL（
`https://docs.google.com/spreadsheets/d/<ここがID>/edit` ）を提供すること。

設計上の推奨: スプレッドシートIDは `config.json` に外出しし、スクリプトにハードコードしない。

```json
// meiten-business/config.json（要作成）
{
  "tegata_spreadsheet_id": "要CEO提供",
  "master_path": "data/meiten-master.json",
  "override_path": "data/sponsor_tier_override.json",
  "token_path": "auth/token.json",
  "client_secret_path": "auth/client_secret.json",
  "backup_dir": "data/backup",
  "log_dir": "logs"
}
```

---

## 9. ファイル構成（実装後の想定）

```
meiten-business/
├── PUBLISH_PROJECTION_SPEC.md    ← 本仕様書（正本）
├── data/
│   ├── meiten-master.json        ← 社内正本（唯一の正本・変更不可）
│   ├── enrich_meiten.py          ← 既存エンリッチ処理（本スクリプトの拡張起点）
│   ├── sponsor_tier_override.json ← 【新規作成】ティア上書きマップ
│   └── backup/                  ← 【新規作成】日次バックアップ保存先
├── scripts/
│   └── publish_projection.py    ← 【新規作成】本スクリプト本体
├── config.json                  ← 【新規作成】スプシID等の設定
├── auth/
│   ├── client_secret.json       ← Google OAuth クライアント情報（CEOから提供）
│   └── token.json               ← 認証トークン（初回認証後に生成）
└── logs/
    └── publish-projection.jsonl ← 実行ログ
```

---

## 10. 実装依頼（Codexへの指示）

Codex が実装すべき範囲:

1. `scripts/publish_projection.py` の実装（§4の擬似コードをフル実装）
2. `data/sponsor_tier_override.json` の初期ファイル作成（空の `{}` + `_meta`）
3. `config.json` の作成（テンプレート値入り）
4. 単体テスト: `tests/test_whitelist.py`（ホワイトリスト適用・セキュリティゲートのテスト）
5. `--dry-run` モードで既存の meiten-master.json を処理し、出力サンプルを
   `data/pub_rows_sample.json` に保存（スプシに書かない）

**Codex に渡す前提情報**:
- Python 3.10以上
- 依存ライブラリ: `google-api-python-client`, `google-auth-httplib2`, `google-auth-oauthlib`
- enrich_meiten.py（同ディレクトリ）を参考にJSONのロード・保存方法を踏襲すること
- 手形スプレッドシートIDは config.json から読む（ハードコード禁止）
- スプシへの書き込みは `--dry-run` フラグがない場合のみ実行

---

## 11. 未確認事項（要CEO提供・要確認）

| # | 事項 | 現状 | アクション |
|---|---|---|---|
| U-1 | 手形スプレッドシートID | **未確認** | **要CEO提供**: SpreadsheetのURLのID部分 |
| U-2 | M_Spot_pub は新規シートか既存M_Spotを拡張か | 未確定 | pm-kototoi-tegata に確認。本仕様書は新規シート追加を推奨 |
| U-3 | spot_id と storeId のマッピング | 未確定 | 現状のM_Spot_seed.csvに storeId 列がない。photo_url同様に手形PM確認が必要 |
| U-4 | スポンサー加盟の確定契約（ティア割当） | 未確定 | 初回は全店 "non" で走らせ、加盟確定後に sponsor_tier_override.json を更新 |
| U-5 | Google OAuth クライアント認証情報 | 未確認 | 既存プロジェクトのOAuth設定を流用するか新規作成するか（費用は発生しない） |
| U-6 | photo_url の管理方法 | 未確定 | master に photo_url フィールドが現時点で存在しない。Drive URL を直接貼る運用かCDN管理かを確定 |

---

## 12. 実装手順（最小ステップ）

Codex実装後にCEOが確認すべきステップ:

```
Step 1: U-1 の手形スプシIDを config.json に設定
Step 2: Google OAuth client_secret.json を auth/ に配置
Step 3: python scripts/publish_projection.py --dry-run
        → pub_rows_sample.json を確認し、社内フィールドが含まれていないことを目視確認
Step 4: python scripts/publish_projection.py --store-id MTN-002
        → ROSSONEROの1店のみをスプシに書き込み、Glideで表示確認
Step 5: 問題なければ全店実行
        python scripts/publish_projection.py
Step 6: Task Scheduler / Windows タスクスケジューラで日次 03:30 自動実行を設定
```

---

*本仕様書の変更は minister-cto が承認すること。実装開始前に §11 の未確認事項を解消することを推奨する。*
