# 言問名店 営業アプリ — API契約書

**作成：** 2026-06-06 / **更新：** 2026-06-06（追加: invoice/contact/invoices 請求・集金管理3本 / issue-status 号フェーズ切替 / 全書込アトミック化）  
**実装：** `C:\Users\daito\company\ops\hq-server.py`（ThreadingHTTPServer :8788）  
**UI参照元：** meiten-sales.html がこの契約に従って実装すること

---

## 共通仕様

- すべてのレスポンスは `application/json; charset=utf-8`
- CORS: `Access-Control-Allow-Origin: *`（全エンドポイント）
- 成功: `{"ok": true, ...}`
- エラー: `{"ok": false, "error": "エラーメッセージ"}`
- 500 は予期せぬ例外時のみ

---

## POST /api/meiten/claim

担当宣言。既に別ユーザーがclaimしている場合は 409 を返す（重複ロック）。

**リクエスト body**
```json
{
  "storeId": "MTN-063",
  "user": "阿部"
}
```

**レスポンス 200**
```json
{
  "ok": true,
  "storeId": "MTN-063",
  "claimedBy": "阿部"
}
```

**レスポンス 409（既に別人が担当）**
```json
{
  "ok": false,
  "error": "既に金井が担当しています"
}
```

**副作用**
- `meiten-master.json` の該当店に `claimedBy`, `claimedAt`（ISO8601）を書き込む
- `activity.jsonl` に `{ts, user, storeId, storeName, type:"claim"}` を追記

**補足**
- 同一ユーザーが再クレームした場合は上書き（`claimedAt` 更新）
- 解除はUI側で `claimedBy` を空にする操作（API未実装 → 将来 `POST /api/meiten/unclaim`）

---

## POST /api/meiten/action

アクション記録（挨拶/訪問/商談/受注/情報/電話）。

**リクエスト body**
```json
{
  "storeId": "MTN-063",
  "user": "阿部",
  "actionType": "商談",
  "memo": "社長と面談。前向きな反応。",
  "nextAction": "見積もり送付"
}
```

**レスポンス 200**
```json
{"ok": true}
```

**副作用**
- `activity.jsonl` に `{ts, user, storeId, storeName, type, memo, nextAction}` を追記
- `actionType == "受注"` の場合、店の `vol16.status = "受注"` に更新し `targets.json` を再計算

---

## POST /api/meiten/deal

商談進捗・金額を更新し、targets.json のVol.16集計を再計算する。

**リクエスト body**
```json
{
  "storeId": "MTN-063",
  "user": "阿部",
  "status": "受注",
  "amount": 50000,
  "issue": 16
}
```

`status` の選択肢: `"未着手"` / `"商談中"` / `"受注"` / `"NG"`

**レスポンス 200**
```json
{
  "ok": true,
  "summary": {
    "target": 1800000,
    "achievedConfirmed": 50000,
    "pipelineNegotiating": 140000,
    "remaining": 1750000,
    "byRep": [
      {"rep": "阿部", "受注額": 50000, "商談中額": 0},
      {"rep": "金井", "受注額": 0, "商談中額": 50000}
    ]
  }
}
```

**副作用**
- `meiten-master.json` の該当店の `vol16.{status, amount, rep, updatedAt, targetIssue}` を更新
- `targets.json` の `issues.16.achievedConfirmed`（受注合計）と `pipelineNegotiating`（商談中合計）を再計算して保存
- `activity.jsonl` に `{ts, user, storeId, storeName, type:"deal", status, amount}` を追記

**集計ロジック**
```
achievedConfirmed  = vol16.status == "受注" の amount 合計
pipelineNegotiating = vol16.status == "商談中" の amount 合計
remaining = target(1,800,000) - achievedConfirmed
```

---

## POST /api/meiten/addinfo

情報追加（全社員が誰でも投稿できる「全員経営」入力）。

**リクエスト body**
```json
{
  "storeId": "MTN-063",
  "user": "宮内",
  "note": "SNSでリニューアル告知を発見。来月オープン予定。"
}
```

**レスポンス 200**
```json
{"ok": true}
```

**副作用**
- `meiten-master.json` の `store.notes[]` に `{ts, user, note}` を追加
- `store.reviewComment` にテキスト追記（UI検索用）
- `activity.jsonl` に `{ts, user, storeId, storeName, type:"addinfo", note}` を追記

---

## GET /api/meiten/summary?issue=15|16

号別の売上進捗サマリーを返す。`issue=15`（確定実績）と `issue=16`（営業中）で返り値が異なる。

**クエリパラメータ**
- `issue` : 号数（デフォルト `16`）。`15` または `16` を指定。

### issue=15（Vol.15 確定実績）

Vol.15 は受注確定済み。`vol15.status == "受注"` の店のみを集計する（商談中概念なし）。  
`rep` は「宮内（杉本）」のような複合表記を `（` 以降を除いた主担当に正規化して集計。

**レスポンス 200**
```json
{
  "ok": true,
  "issue": 15,
  "target": 1580000,
  "achievedConfirmed": 1614000,
  "remaining": -34000,
  "byRep": [
    {"rep": "石井", "受注額": 810000},
    {"rep": "阿部", "受注額": 336000}
  ]
}
```

**注意**
- `pipelineNegotiating` フィールドは15号では返さない（商談中概念なし）
- `remaining` が負の場合は目標超過（Vol.15 は ¥34,000 超過達成）

### issue=16（Vol.16 営業中）

`vol16.{status, amount, rep}` をリアルタイム集計。既存ロジックと同一。

**レスポンス 200**
```json
{
  "ok": true,
  "issue": 16,
  "target": 1800000,
  "achievedConfirmed": 50000,
  "pipelineNegotiating": 140000,
  "remaining": 1750000,
  "byRep": [
    {"rep": "阿部", "受注額": 50000, "商談中額": 0},
    {"rep": "金井", "受注額": 0, "商談中額": 50000}
  ]
}
```

**データソース**
- `meiten-master.json` の `vol15` / `vol16` フィールドをリアルタイム集計
- `targets.json` の `issues.{issue}.target` から目標値を取得

---

## POST /api/meiten/addstore

新規店舗を登録する。`meiten-master.json` に storeId を採番して1件追加する。  
既存 `/claim` と `/assign` の事前割当はこのエンドポイントで `rep` を渡すことで初期化される。

**リクエスト body**
```json
{
  "name": "テスト喫茶 さくら",
  "category": "飲食・カフェ",
  "ward": "台東区",
  "town": "浅草",
  "address": "台東区浅草1-1-99",
  "targetIssue": 16,
  "rep": "阿部",
  "note": "朝顔市に近い新店舗",
  "user": "石井"
}
```

| フィールド | 必須 | 説明 |
|-----------|------|------|
| name | ○ | 店名 |
| category | △ | 業種（例: 飲食・カフェ） |
| ward | △ | 区（例: 台東区） |
| town | △ | 町名 |
| address | △ | 住所 |
| targetIssue | △ | 対象号（`15` or `16`。省略時 `16`） |
| rep | △ | 初期担当者（省略時は「担当未定」） |
| note | △ | メモ |
| user | ○ | 操作者 |

**レスポンス 200**
```json
{
  "ok": true,
  "storeId": "MTN-203"
}
```

**重複時（同名店が既に存在する場合）**
```json
{
  "ok": true,
  "storeId": "MTN-203",
  "warning": "同名の店舗が既に存在します: MTN-xxx"
}
```
409 は返さず `warning` で通知する（名前が同じでも別店の場合があるため）。

**副作用**
- `meiten-master.json` に `label="L6"`, `status="新規登録"`, `claimedBy=rep|"担当未定"` で新レコード追加
- `storeId` は既存最大番号 +1 で自動採番（例: 既存最大が MTN-202 → MTN-203）
- `googleMapsUrl` は `name + address` から自動生成
- `vol{targetIssue}` フィールドを `status="未着手"` で初期化
- `activity.jsonl` に `type:"addstore"` を追記

---

## POST /api/meiten/assign

担当者を変更（上書き）する。既存 `/claim`（409 ロック付き）と異なり、事前割当の変更用途で  
既に誰かが担当していても強制上書きする。

**リクエスト body**
```json
{
  "storeId": "MTN-063",
  "user": "石井",
  "newRep": "満田"
}
```

**レスポンス 200**
```json
{
  "ok": true,
  "storeId": "MTN-063",
  "from": "阿部",
  "to": "満田"
}
```

**副作用**
- `meiten-master.json` の `claimedBy` を `newRep` に上書き（`claimedAt` も更新）
- `activity.jsonl` に `{type:"assign", from:"旧担当", to:"新担当"}` を追記

**注意**
- `/claim` は `409 ロック`（既存担当への割込みを防ぐ）。`/assign` は `ロックなし上書き`（管理者・マネージャーの担当変更用途）
- UI は通常 `/assign` を使い、`/claim` は現場担当者の自発的な宣言に使う

---

## POST /api/meiten/invoice

Vol.15 受注店の `invoice` オブジェクトを部分更新する（送られたキーのみ上書き）。

**リクエスト body**
```json
{
  "storeId": "MTN-001",
  "user": "石井",
  "patch": {
    "issued": true,
    "deliveryMethod": "持参",
    "sendProgress": "済",
    "needsAccounting": false,
    "collectionMethod": "集金",
    "invoiceNo": "INV-2026-001"
  }
}
```

`patch` の許可キー（送られたキーだけ上書き。省略したキーは変更なし）：

| キー | 型 | 説明 |
|-----|---|------|
| issued | bool | 発行済？ |
| deliveryMethod | null / "持参" / "送付" | 請求書の届け方 |
| sendProgress | "未" / "済" | 送付進捗 |
| needsAccounting | bool | 経理依頼要？ |
| collectionMethod | "振込" / "集金" | 集金方法 |
| invoiceNo | string | 請求書番号 |

**レスポンス 200**
```json
{
  "ok": true,
  "invoice": {
    "issue": 15,
    "issued": true,
    "deliveryMethod": "持参",
    "sendProgress": "済",
    "needsAccounting": false,
    "collectionMethod": "集金",
    "mado": "",
    "amountIncl": "¥440,000",
    "invoiceNo": "INV-2026-001",
    "slot": "表4",
    "_updatedAt": "2026-06-06T18:00:00+09:00"
  }
}
```

**レスポンス 400（不正キー）**
```json
{
  "ok": false,
  "error": "patch に不正なキーが含まれています: hackField",
  "allowed": ["collectionMethod","deliveryMethod","invoiceNo","issued","needsAccounting","sendProgress"]
}
```

**副作用**
- `meiten-master.json` の該当店 `invoice` を部分更新（`invoice._updatedAt` も更新）
- `activity.jsonl` に `{type:"invoice", changes:{key:{from,to}}}` を追記（変更したキーのみ）

---

## POST /api/meiten/contact

店レベルの `contactPerson`（御社担当・窓口）を更新する。  
自社担当（`claimedBy`）の変更は `/api/meiten/assign` を使う。

**リクエスト body**
```json
{
  "storeId": "MTN-001",
  "user": "石井",
  "contactPerson": "田中部長"
}
```

**レスポンス 200**
```json
{"ok": true}
```

**副作用**
- `meiten-master.json` の `store.contactPerson` を更新
- `activity.jsonl` に `{type:"contact", from:"旧窓口", to:"新窓口"}` を追記

---

## GET /api/meiten/invoices?issue=15

Vol.15 受注店（`vol15.status=="受注"` かつ `invoice.issue==15`）の請求・集金管理一覧と集計を返す。

**クエリパラメータ**
- `issue` : 号数（デフォルト `15`）

**レスポンス 200**
```json
{
  "ok": true,
  "stores": [
    {
      "storeId": "MTN-001",
      "name": "日鉄興和不動産株式会社",
      "claimedBy": "石井",
      "contactPerson": "田中部長",
      "amountIncl": "¥440,000",
      "collectionMethod": "集金",
      "issued": true,
      "deliveryMethod": "持参",
      "sendProgress": "済",
      "needsAccounting": false,
      "invoiceNo": "INV-2026-001",
      "slot": "表4"
    }
  ],
  "summary": {
    "issue": 15,
    "totalStores": 46,
    "issuedCount": 1,
    "sentCount": 1,
    "accountingCount": 0,
    "furikomiCount": 45,
    "shukinCount": 1,
    "totalAmount": 1709400
  }
}
```

**集計フィールド説明**

| フィールド | 説明 |
|-----------|------|
| totalStores | 対象店舗数 |
| issuedCount | `issued==true` の件数 |
| sentCount | `sendProgress=="済"` の件数 |
| accountingCount | `needsAccounting==true` の件数 |
| furikomiCount | `collectionMethod=="振込"` の件数 |
| shukinCount | `collectionMethod=="集金"` の件数 |
| totalAmount | `amountIncl` の合計（¥・カンマ除去して合算） |

**データソース**
- `meiten-master.json` の `invoice` + `contactPerson` + `claimedBy` をリアルタイム読み込み

---

## GET /api/meiten/issue-status?issue=N

号（issue）の現在フェーズと目標・実績を返す。

**クエリパラメータ**
- `issue` : 号数（デフォルト `16`）

**レスポンス 200**
```json
{
  "ok": true,
  "issue": 16,
  "phase": "営業中",
  "label": "Vol.16 秋号",
  "target": 1800000,
  "achievedConfirmed": 0,
  "pipelineNegotiating": 266500
}
```

`phase` の値: `"営業中"` / `"受注締め"`

**データソース:** `targets.json` の `issues.{N}`。未登録号は `phase:"営業中"` のデフォルト値で返す。

---

## POST /api/meiten/issue-status

号のフェーズを切り替える。`受注締め` にすると UI が請求・集金管理を開く。

**リクエスト body**
```json
{
  "issue": 16,
  "phase": "受注締め",
  "user": "石井"
}
```

| フィールド | 型 | 必須 | 説明 |
|-----------|---|------|------|
| issue | int | ○ | 号数（15, 16, 17…） |
| phase | string | ○ | `"営業中"` または `"受注締め"` のみ |
| user | string | ○ | 操作者 |

**レスポンス 200**
```json
{
  "ok": true,
  "issue": 16,
  "phase": "受注締め",
  "label": "Vol.16 秋号",
  "target": 1800000,
  "achievedConfirmed": 0,
  "pipelineNegotiating": 266500
}
```

**レスポンス 400（不正な phase 値）**
```json
{
  "ok": false,
  "error": "phase は '営業中' または '受注締め' のみ"
}
```

**副作用**
- `targets.json` の `issues.{N}.phase` を更新（`_phaseUpdatedAt` も更新）
- `issues.{N}` が存在しない場合は自動作成（新号対応）
- `activity.jsonl` に `{ts, user, type:"issue-status", issue, from:"旧フェーズ", to:"新フェーズ"}` を追記

**フェーズ意味**

| phase | 意味 |
|-------|------|
| 営業中 | 受注前。店別の商談進捗を更新する営業フェーズ |
| 受注締め | 受注確定。フロントが請求・集金管理タブを開く |

---

## activity.jsonl の type 一覧（請求・集金管理追加分）

| type | フィールド |
|------|---------|
| invoice | ts, user, storeId, storeName, type, changes（変更キーの from/to） |
| contact | ts, user, storeId, storeName, type, from, to |
| issue-status | ts, user, type, issue, from, to |

---

## GET /api/meiten/feed?limit=50

activity.jsonl の直近アクティビティを返す（新しい順）。

**クエリパラメータ**
- `limit` : 件数（デフォルト `50`）

**レスポンス 200**
```json
{
  "ok": true,
  "count": 5,
  "feed": [
    {
      "ts": "2026-06-06T17:00:05+09:00",
      "user": "阿部",
      "storeId": "MTN-063",
      "storeName": "シモジマ",
      "type": "claim"
    },
    ...
  ]
}
```

**フィールド一覧（type別）**

| type | フィールド |
|------|---------|
| claim | ts, user, storeId, storeName, type |
| action | ts, user, storeId, storeName, type, memo, nextAction |
| deal | ts, user, storeId, storeName, type, status, amount |
| addinfo | ts, user, storeId, storeName, type, note |
| addstore | ts, user, storeId, storeName, type, targetIssue, rep, note |
| assign | ts, user, storeId, storeName, type, from, to |

---

## GET /api/meiten/nba-params

NBA エンジンの現在の調整パラメータを返す。UI の「パラメータ調整」タブが初期値として読む。

**レスポンス 200**
```json
{
  "ok": true,
  "params": {
    "W_WORLD": 0.30,
    "W_TEGATA": 0.20,
    "W_HOTEL": 0.15,
    "W_SYMBOL": 0.20,
    "W_FUNNEL": 0.15,
    "HOURLY_RATE": 2000,
    "L6_ACTIVE_THRESHOLD": 20,
    "L4_COUNT_AS_REVENUE": false,
    "SYMBOL_KEYWORDS": ["朝顔", "入谷", "老舗", "..."]
  },
  "defaults": { ...同形式... },
  "summary": {"P0": 3, "P1": 63, "P2": 46, "P3": 90},
  "generated_at": "2026-06-06T20:42:27"
}
```

**データソース:** `data/nba_params.json`（無ければデフォルト値）+ `data/nba_output.json`（summary/generated_at）

---

## POST /api/meiten/nba-params

NBA エンジンのパラメータを保存し、エンジンを再実行して全202店を再スコアする。

**リクエスト body**
```json
{
  "W_WORLD": 0.30,
  "W_TEGATA": 0.20,
  "W_HOTEL": 0.15,
  "W_SYMBOL": 0.20,
  "W_FUNNEL": 0.15,
  "HOURLY_RATE": 2000,
  "L6_ACTIVE_THRESHOLD": 20,
  "L4_COUNT_AS_REVENUE": false,
  "SYMBOL_KEYWORDS": ["朝顔", "入谷", "老舗"]
}
```

| フィールド | 型 | 説明 |
|---|---|---|
| W_WORLD | float 0〜1 | 世界観適合度の重み |
| W_TEGATA | float 0〜1 | 手形アプリ適性の重み |
| W_HOTEL | float 0〜1 | ホテル送客親和の重み |
| W_SYMBOL | float 0〜1 | 象徴性（老舗/誌名連動）の重み |
| W_FUNNEL | float 0〜1 | ファネル乗数の重み |
| HOURLY_RATE | int | 人件費単価（¥/h）。C_action に影響。 |
| L6_ACTIVE_THRESHOLD | int | best100Rank の上位N社をP1に入れる閾値 |
| L4_COUNT_AS_REVENUE | bool | グループ内を外部収益にカウントするか |
| SYMBOL_KEYWORDS | string[] | 象徴性スコアを加点するキーワード |

全フィールドは省略可（省略時はファイルの既存値を使用）。重みは 0〜1 の範囲外で警告が出るが動作は続行。

**レスポンス 200**
```json
{
  "ok": true,
  "saved": { ...保存したパラメータ... },
  "summary": {"P0": 3, "P1": 61, "P2": 48, "P3": 90},
  "top": [
    {"storeId": "MTN-005", "name": "...", "priority": "P0", "priorityScore": 8, "actionName": "年契更新・継続確認"},
    ...（上位10件）
  ],
  "param_warnings": [],
  "engine_stdout": "[NBA Engine] 202店処理完了。P0=3 P1=61..."
}
```

**副作用**
- `data/nba_params.json` を上書き
- `nba_engine.py` を実行して `data/nba_output.json` / `data/nba_factors.json` を再生成

---

## GET /api/meiten/nba-factors

全202店のサブ因子（重み適用前の正規化スコア 0〜1）+ EV内訳を返す。
フロントの重みスライダーで即プレビュー再計算する用途。

**フロント側の再計算式:**
```js
// sub_factors={worldview,tegata,hotel,symbol,funnel} を使って
const raw = W_WORLD*f.worldview + W_TEGATA*f.tegata + W_HOTEL*f.hotel
           + W_SYMBOL*f.symbol + W_FUNNEL*f.funnel;
const s_strategic = 0.5 + raw * 1.5;  // ∈ [0.5, 2.0]
const ev = p_convert * v_expected * s_strategic - c_action;
```

**レスポンス 200**
```json
{
  "ok": true,
  "_meta": {
    "total_stores": 202,
    "current_weights": {"W_WORLD": 0.30, ...},
    "note": "sub_factors は重み適用前の正規化スコア（0〜1）。"
  },
  "stores": [
    {
      "storeId": "MTN-048",
      "name": "笹乃雪",
      "label": "L3",
      "priority": "P1",
      "priorityScore": 4,
      "actionType": "A05",
      "ev": 34115,
      "ev_breakdown": {
        "p_convert": 0.33, "v_expected": 60000,
        "s_strategic": 1.925, "c_action": 4000
      },
      "sub_factors": {
        "worldview": 1.0, "tegata": 1.0, "hotel": 1.0,
        "symbol": 0.9, "funnel": 0.8
      },
      "vol_status": ""
    },
    ...（全202店）
  ]
}
```

**データソース:** `data/nba_factors.json`（nba_engine.py 実行ごとに更新）

---

## データ正本の場所

| ファイル | 用途 |
|--------|------|
| `meiten-business/data/meiten-master.json` | 店マスター（202店・claimedBy/vol16/invoice/contactPersonを書き換える） |
| `meiten-business/data/activity.jsonl` | 全アクションのフィード（追記専用） |
| `meiten-business/data/targets.json` | Vol.16目標と集計値 |
| `meiten-business/data/nba_params.json` | NBA エンジン調整パラメータ（重み・人件費・閾値） |
| `meiten-business/data/nba_output.json` | NBA スコア結果（サブ因子入り・全202店） |
| `meiten-business/data/nba_factors.json` | サブ因子専用（フロント重みスライダー用） |

---

## 書込の安全性（アトミック化・2026-06-06実装）

全書込 API（claim / action / deal / addinfo / addstore / assign / invoice / contact / nba-params / issue-status）は以下の仕組みで排他・整合性を保証する。

### グローバルロック

```python
_meiten_lock = threading.Lock()  # プロセス内でひとつ
```

`ThreadingHTTPServer` は各リクエストを別スレッドで処理する。全書込 API を `with _meiten_lock:` で直列化し、同時書込による破損を防ぐ。

### アトミックファイル置換

```python
def _atomic_write(path: str, content: bytes):
    # 1. 同一ディレクトリに一時ファイルを作成
    fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".tmp_")
    # 2. バイト列を書き込む
    with os.fdopen(fd, "wb") as fh:
        fh.write(content)
    # 3. 原子的に置換（同一ファイルシステム上の rename = POSIX atomic）
    os.replace(tmp, path)
```

書込途中でプロセスが落ちても、元ファイルは壊れない（tmp が残るだけ）。

### 1世代バックアップ

書込前に `{file}.bak` へ `shutil.copy2` でコピー。読めない場合は `.bak` から手動復元する。

### JSON破損検知

`_load_master()` は読み込んだ後 `json.loads()` でパースし、`JSONDecodeError` なら即 `RuntimeError` を raise して書込をブロックする。

---

## サーバー

```
http://localhost:8788          # メインPC
http://100.76.239.118:8788     # Tailnet経由（社員スマホ）
```

*サーバー実装：`C:\Users\daito\company\ops\hq-server.py`*
