# データの流れ — 画面6〜10（2026-09-17）

対象は ★D-Secログ（F-06） / D-Secアラート（F-07） / 施設ロボットマスタ（F-08） /
スケジュール（F-09） / システム管理（F-10）★ の5画面です。
残り5画面（ダッシュボード / 清掃詳細ログ / アラート / レポート / 清掃面積）は
★`docs/データの流れ_画面1-5_20260917.md` の担当★で、本書には含みません。

---

## 0. この文書の読み方

### 0.1 何のための文書か

「この画面の**この列**は、**DBのどの列**から来て、**どの関数**を通って、**どんな型**になって、
**どう表示される**のか」を、★実物の値つきで★1本の縦線として追えるようにしたもの。
抽象的な設計説明ではなく、★`file:line` と実際の値★だけで構成している。

### 0.2 1本の縦線の書式

各列は次の6段で書く。段が無い場合（変換が無い等）はその段を省略する。

```
【DBの列】 テーブル.カラム
           型: 〈Prisma型 / @db 型〉（prisma/schema.prisma:NNN）
           意味: 〈DDL上の意味・値域〉
   ↓ 取得
【取得】   lib/db/index.ts:NNN（prismaSource）/ lib/fixtures/xxx.ts（fixtureSource）
   ↓ 変換
【変換】   lib/format/... または lib/db/...:NNN
           関数シグネチャを実際に写す
   ↓ 型
【型】     XxxItem.field: 型（lib/types/index.ts:NNN）
   ↓ 絞込
【絞込】   lib/xxx/query.ts:NNN（純粋関数）/ app/api/xxx/route.ts:NNN
   ↓ 表示
【画面】   components/features/xxx/XxxScreen.tsx:NNN   → 画面に出る文字列
【値の例】 〈出所つきの実値〉
```

### 0.3 ★値の出所の凡例★（`CLAUDE.md` §6-6）

本書に出る具体的な値には、必ず次の3つのどれかの印を付けます（★画面1〜5 の文書と同じ印★）。
★印の無い数字は本書には書きません★。

| 印 | 意味 | 出所 |
|---|---|---|
| ★シード由来★ | ★当方が作ったサンプルデータの値★。★DBの値ではない★ | `lib/fixtures/*.ts` / `scripts/seed-local-db.ts` |
| ★構造由来★ | ★型・DDL・API契約から読み取れる値★（データではなく器） | `prisma/schema.prisma` / `d-watch-api.yaml` / `lib/config/index.ts` |
| ★記録済み実測★ | ★`docs/decisions.md` / `docs/handoff/` に日付つきで記録済みの実測値★ | 決定番号・ファイル名と日付を併記する |

★「実測」と書くときは出所を必ず併記する★（`CLAUDE.md` §6-6）。
出所を書けない数字は「実測」と呼びません。
★先方投入の値は本書に1件もありません★ — 次項のとおり、そもそも先方由来の行が確認されていないためです。

### 0.4 ★★本番DBに入っているデータは「当方のダミー」である★★（D-148 / D-145）

- ★`133.125.238.144` / `clean_watch` は**本番DBそのもの**★（D-148 項1。`docs/decisions.md:12007-12009`）。
  ★検証用の別DBではない★。
- ★そこに入っている17テーブル・1,787行は**すべて当方由来**★
  （`docs/handoff/テストDBのデータ出所_20260917.md:76-98`。2026-09-17）。
  ★**先方由来の行は1行も見つかっていない**★（同 `:98`）。
- ★★本番DBには接続しない★★（D-148 項5。ユーザー指示 2026-09-17）。
  本文書の値はすべて ★シード由来★ / ★構造由来★ / ★記録済み実測★ のいずれかであり、
  ★本文書の作成にあたって本番DBへの接続は一切していない★。
- ★ダミー1,787行は本番前に TeamWorks が削除する予定★（D-148 項6 追記）。
  ★削除されたら本文書の ★記録済み実測★ の行数は全部無効になる★。

### 0.5 共通の登場人物（5画面すべてに効く）

| 役割 | 実体 | 備考 |
|---|---|---|
| データソース切替 | `lib/db/index.ts:2476` — `export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;` | 本書の5画面が使うのは `fixtureSource`（`:481-519`）/ `prismaSource`（`:1427-1702`） |
| ページング既定値 | `PAGINATION = { defaultPerPage: 50, maxPerPage: 200 }`（`lib/config/index.ts:325-327`）★構造由来★ | 画面は `PAGINATION.defaultPerPage` を送る |
| 一覧の共通形 | `ListResponse<T> = { items, meta }`（`lib/types/index.ts:84-87`）/ `SummaryListResponse<TItem,TSummary> = { summary, items, meta }`（`:89-93`） | `Meta = { total, page, per_page }`（`:20-24`） |
| 分類の絞り込み | `group_id` / `customer_id` → `parseMasterIdFilters()`（`lib/api/master-id.ts:42-66`）→ `resolveFacilityIdsByMasters()`（`lib/facilities/resolve-by-master.ts:88-93`）→ `filterRowsByFacilityIds()`（`lib/facilities/filter-by-ids.ts:48-55`） | ★`null`=絞らない / `[]`=0件★ |
| 日時の JST 補正 | `jstIsoFromDb(date: Date): string`（`lib/format/date.ts:95-97`）= `toJstIso(fromDbJst(date))` | ★DATETIME は JST の壁時計値★。次項 |
| 日時の表示整形 | `formatMd(iso)`（`lib/format/date.ts:8-12`）/ `formatHm(iso)`（`:15-17`） | 文字列の切り出しのみ。TZ 変換しない |

#### ★DATETIME は JST の壁時計値★（事故りやすい・5画面共通）

```
DB:  control_reports.reported_at = 2026-09-11 02:00:00   ← ★日本時間の壁時計値★（TeamWorks 第8回 A2）
  ↓ @prisma/adapter-mariadb が `new Date(`${value}Z`)` で ★UTC とみなす★
JS:  Date(2026-09-11T02:00:00Z)                          ← ★真の瞬間より9時間先★
  ↓ fromDbJst(date)  … lib/format/date.ts:67-69
JS:  Date(2026-09-10T17:00:00Z)                          ← 真の瞬間
  ↓ toJstIso(date)   … lib/format/date.ts:37-46
API: "2026-09-11T02:00:00+09:00"                         ← ★DBの生値と桁が一致するのが正解★
  ↓ formatMd + formatHm
画面: "9/11 02:00"
```

★この2段（`fromDbJst` → `toJstIso`）を1つの式に潰さないこと★（`lib/format/date.ts:88-89`）。
★補正しないと深夜2時の清掃が翌朝でも「未来」と判定される★（同 `:59-62`）。

---

# 第6章 D-Secログ（F-06）

**画面**: `app/(dashboard)/ds-logs/page.tsx:1-5` → `components/features/ds-logs/DsLogScreen.tsx`
**API**: `GET /api/ds-devices`（`app/api/ds-devices/route.ts:31-119`）/
`PATCH /api/ds-devices/{serial_number}/note`（`app/api/ds-devices/[serial_number]/note/route.ts:44-133`）
**クエリ層**: `lib/ds-devices/query.ts`
**データ層**: `lib/db/index.ts:1427-1475`（prisma）/ `lib/fixtures/ds-devices.ts`（fixtures）

## 6.1 追えるもの（数値カード3枚 / 表5列 / ツールバー5要素）

| 種別 | 名前 | 出所 |
|---|---|---|
| カード | 合計 / 正常 / 異常 | `summarizeDsDevices()` |
| 列 | シリアル / 施設名 / 接続状態 / 最終通信 / 備考 | `DsDevice` の5フィールド |
| ツールバー | ホールディングス / 屋号 / 検索入力 / 全て / 異常 / 正常 | クエリパラメータ |

## 6.2 縦線① 接続状態（★事故りやすい: 2値化★）

```
【DBの列】 controls.status
           型: Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:209）★構造由来★
           意味: ★0 = 正常 / 1 = 異常★（D-065 Q1-2。docs/decisions.md:483）
                 ★★3状態目（disconnected）は存在しない★★ — 「異常」＝「生死報告が届いていない」
                 なので、通信断と異常は同一（lib/types/index.ts:831-836）
   ↓ 取得
【取得】   lib/db/index.ts:1433-1436
           prisma.control.findMany({ where: { deleted_at: null }, include: { facility: true } })
           ★deleted_at IS NULL でのみ絞る。inactive では絞らない★（:1430-1432。D-086）
   ↓ 変換
【変換】   lib/db/ds-status.ts:20-26
           export function connectionStatusFromControlStatus(status: number): DsConnectionStatus
             status === 0 → "ok"
             status === 1 → "error"
             それ以外     → ★throw Error★（黙って握りつぶさない。:23-25）
   ↓ 型
【型】     DsDevice.connection_status: DsConnectionStatus
           = "ok" | "error"（lib/types/index.ts:837 / :846）★構造由来★
   ↓ 絞込
【絞込】   lib/ds-devices/query.ts:93-107
           filterDsDevicesByStatus(items: DsDevice[], status: DsDeviceStatusFilter): DsDevice[]
           DsDeviceStatusFilter = "all" | "error" | "ok"（lib/types/index.ts:840）
   ↓ 並替
【並替】   lib/ds-devices/query.ts:118-130  sortDsDevices(items: DsDevice[]): DsDevice[]
           CONNECTION_STATUS_ORDER = { error: 0, ok: 1 }（:109-112）→ ★異常が先★
           同順位は serial_number 昇順（D-055）
   ↓ 表示
【画面】   components/features/ds-logs/DsLogScreen.tsx:30-34
           function connectionStatusLabel(status: DsConnectionStatus): string
             status === "ok" ? "正常" : "異常"
           :36-39 connectionStatusColor(c, status) → status==="ok" ? c.ok : c.ng
           :426-431 <Pill text={t(...)} color={...} />
【値の例】 ★構造由来★ 0 → "ok" → 「正常」（緑 #22c55e / light #16a34a。lib/theme.ts:12 / :31）
           ★構造由来★ 1 → "error" → 「異常」（赤 #ef4444 / light #dc2626。lib/theme.ts:16 / :35）
           ★シード由来★ DS-001 は "error"（lib/fixtures/ds-devices.ts:30）、DS-002 は "ok"（:22）
           ★記録済み実測★ `controls` は11行（docs/handoff/テストDBのデータ出所_20260917.md:91。2026-09-17。
                     ★当方のシード由来★ — `password` が全行 `"seed-local-placeholder-password"`）
```

★★危ない★★: `connectionStatusFromControlStatus()` は ★0/1 以外で例外を投げる★
（`lib/db/ds-status.ts:23-25`）。★実データで `status = 2` が来たら一覧全体が 500 になる★。
これは意図した設計（黙って握りつぶさない）だが、★D-065 Q1-2 の「0/1 だけ」が
実データで崩れたときの被害は一覧の全滅★である。

## 6.3 縦線② 最終通信

```
【DBの列】 control_reports.reported_at
           型: DateTime @default(now()) @db.Timestamp(0)（prisma/schema.prisma:365）★構造由来★
   ↓ 取得
【取得】   lib/db/index.ts:1440-1444
           prisma.controlReport.groupBy({
             by: ["control_id"],
             where: { control_id: { in: controls.map(c => c.id) } },
             _max: { reported_at: true },
           })
           ★行は運ばない（制御盤ごとの最大値だけ取る）★（:1437-1439）
   ↓ 突合
【突合】   lib/db/index.ts:1448
           new Map(latest.map(g => [String(g.control_id), g._max.reported_at]))
           ★★キーは String() で揃える（★罠1★）★★ — BigInt をそのままキーにすると
           `1n` / `"1"` / `1` が別キーになり、★全制御盤が「心拍0件」に見えて例外になる★
           :1451-1458 心拍0件なら throw（「controls(id=N) に control_reports が1件もありません」）
   ↓ 変換
【変換】   lib/format/date.ts:95-97  jstIsoFromDb(date: Date): string
           ★TIMESTAMP だが DATETIME と同じ9時間ずれをすることを実測済み★
           （lib/db/index.ts:1465-1467。指示書Y 段階3）
   ↓ 型
【型】     DsDevice.last_reported_at: string（ISO8601 +09:00。lib/types/index.ts:847）
   ↓ 表示
【画面】   DsLogScreen.tsx:41-43
           function formatDateTime(iso: string): string { return `${formatMd(iso)} ${formatHm(iso)}`; }
           :432 <td className={styles.tdDate}>{formatDateTime(item.last_reported_at)}</td>
【値の例】 ★シード由来★ DS-001: at(0,"03:42") → 実行日の "YYYY-MM-DDT03:42:00+09:00" → 画面「9/17 03:42」
                   （lib/fixtures/ds-devices.ts:31 / 生成は lib/fixtures/_helpers.ts:33-35）
           ★記録済み実測★ `control_reports` は14行、`serial_number` が `DS-001`〜
                   ＝ ★当方の lib/fixtures/ds-devices.ts 由来★
                   （docs/handoff/テストDBのデータ出所_20260917.md:89。2026-09-17）
```

★★危ない★★: `_max: { reported_at: true }` を `orderBy: { id: "desc" }, take: 1` に
書き換えたくなるが、★`id` の大小順と `reported_at` の大小順が一致する保証はどこにも無い★
（`lib/db/index.ts:1421-1424`）。★確認が取れるまで `MAX(reported_at)` を使うこと★。

## 6.4 縦線③ ★備考（★メインDBに列が無い。DFA独自ストア★）★

```
【DBの列】 ★存在しない★（D-065 Q1-1。docs/decisions.md:482）
   ↓ 取得（Prisma側）
【取得】   lib/db/index.ts:1472  note: null   ← ★常に null を返す★（:1469-1471 に明記）
   ↓ 実値の取得
【ストア】 lib/settings/ds-device-notes.ts
           保存先: data/settings/ds-device-notes.json（:53-55 defaultDeviceNotesStorePath()）
           ★/data は .gitignore:50 で除外★＝ ★コミットされず git のバックアップ対象外★（:20-22）
           型: export type DeviceNotes = Record<string, string>（:44）
               ★空の備考はキーごと存在しない★（:40-43）
           export async function readDeviceNotes(): Promise<DeviceNotes>（:124-126）
   ↓ マージ
【マージ】 app/api/ds-devices/route.ts:81-84
           rows = rows.map(device => ({ ...device, note: notes[device.serial_number] ?? device.note }))
           ★独自ストアの値を優先する★（:71-77）— 逆にすると保存したそばから古い値に戻る
           ★フィルタ・ソート・サマリより前にマージする★（:78-80）
   ↓ 型
【型】     DsDevice.note: string | null（lib/types/index.ts:848）
   ↓ 表示
【画面】   DsLogScreen.tsx:86-132 NoteCell（インライン編集）
           :123 {device.note ?? "-"}   ← 未入力は "-"
           :99-115 編集中は <textarea defaultValue={device.note ?? ""} maxLength={DEVICE_NOTE_MAX_LENGTH}
                   autoFocus onBlur={e => onCommit(e.target.value)} />
           ★非制御（:101-103）★ / ★確定は onBlur だけ（:84）★
【値の例】 ★シード由来★ DS-001: "現地確認済み。制御盤の電源ユニット交換を手配中"
                   （lib/fixtures/ds-devices.ts:32）
           ★構造由来★ 上限 200文字（DEVICE_NOTE_MAX_LENGTH。lib/config/index.ts:504）
           ★記録済み実測★ `data/settings/ds-device-notes.json` の現在の中身は `{}`（空。2026-09-17 時点。
                   ★当方のローカル作業ディレクトリの実体★）
```

### 保存経路（★書き込みがメインDBから分離されていることの縦線★）

```
画面  DsLogScreen.tsx:258-296  commitNote(device: DsDevice, note: string): Promise<void>
        :263  if (note === (device.note ?? "")) return;      ← 変えていないなら叩かない
        :271-278 apiFetch<DsDeviceNoteResponse>(
                   `/api/ds-devices/${encodeURIComponent(serial)}/note`,
                   { method: "PATCH", headers: {...}, body: JSON.stringify({ note }) })
        ★DsDeviceNoteResponse は YAML にインライン定義で名前が無いため lib/types に置かず
          画面に持つ（:47-57）★  { serial_number: string; note: string | null }
   ↓
API   app/api/ds-devices/[serial_number]/note/route.ts:44-133
        :54   parsePathSerialNumber(serialNumberRaw)              ← ★① 形式★
        :68-74 typeof note !== "string" → 422
        :79-86 note.length > 200 → 422                            ← ★② 長さ（トリム前で数える。:76-78）★
        :98   await db.dsDeviceExists(serialNumber)                ← ★③ 実在★
        ★★検証順序（形式 → 長さ → 実在）を変えないこと★★（:53）
        :111-113 !exists → 404 not_found
        :117  saved = await saveDeviceNote(serialNumber, note)
        :132  return NextResponse.json({ serial_number: serialNumber, note: saved })
   ↓
ストア lib/settings/ds-device-notes.ts:137-155
        export async function saveDeviceNote(serial: string, note: string): Promise<string | null>
          trimmed === "" → delete notes[serial]; return null       ← ★空文字送信＝削除★
          それ以外       → notes[serial] = trimmed; return trimmed
   ↓
画面  DsLogScreen.tsx:281-283
        ★返ってきた値で1行だけ差し替える（送った文字列ではない。:279-280）★
        ★保存後に fetchDevices() を呼ばない（:254-256）★ — 表がローディングに入れ替わり
          編集していた行の位置を見失う
```

★★メインDBには一切書いていない★★ — 書き込み許可は
`robot_error_reports` / `control_error_histories` の4カラムだけ（`CLAUDE.md` §3 / §7）で、
デバイスの備考はそのいずれでもない（`lib/settings/ds-device-notes.ts:14-18`）。

### ★★危ない: 実在チェックの照合（D-4）★★

```
lib/db/index.ts:1509-1518
  async dsDeviceExists(serialNumber: string)
    prisma.control.findMany({ where: { deleted_at: null, serial_number: serialNumber },
                              select: { serial_number: true } })
    return rows.some(row => row.serial_number === serialNumber)   ← ★★JS の === で決める★★
```

★DBの既定の照合順序は大小文字を区別しない（ci）★（`:1491`）。SQL の一致をそのまま答えにすると:

```
ds-001 が DS-001 に当たって 404 → 200
  → 保存キーはリクエストの生文字列 "ds-001"（lib/settings/ds-device-notes.ts）
  → 一覧は notes["DS-001"] で引く
  → ★★保存されたのに一生表示されない孤児行が残る★★
```

★`findFirst` ではなく `findMany`★（`:1499-1500`）/ ★`take` は付けない★（`:1502`）。

## 6.5 縦線④ summary（3枚のカード）と meta.total の違い

```
【計算】   lib/ds-devices/query.ts:147-152
           export function summarizeDsDevices(items: DsDevice[]): DsDeviceSummary
           interface DsDeviceSummary { total: number; ok: number; error: number }（:132-137）
【呼ぶ順】 app/api/ds-devices/route.ts
           :92  rows = filterRowsByFacilityIds(rows, facilityIds)   ← ★分類は summary の前★
           :96  const summary = summarizeDsDevices(rows)            ← ★q / status の前★
           :97-100 filtered = filterDsDevicesByStatus(filterDsDevicesByQ(rows, q), status)
           :101 sorted = sortDsDevices(filtered)
           :108 const total = sorted.length                          ← ★meta.total★
【意味】   meta.total    = 一覧（q・status 後）の件数。★ページング計算用★
           summary.total = 分類で絞ったあとの全デバイス数。★q・status の影響を受けない★
           （:104-107。絞り込みが無いときだけ一致する）
【画面】   DsLogScreen.tsx:306-308  StatCard に summary.total / summary.ok / summary.error
           :176 const [total, setTotal] = useState(0) ← ★meta.total を入れる★（:168-175 に警告）
           :452-457 <Pagination total={total} perPage={PAGINATION.defaultPerPage} .../>
【値の例】 ★シード由来★ dsDevices は12件、うち "error" は DS-001/005/009/010/012 の ★5件★、
                   "ok" は ★7件★（lib/fixtures/ds-devices.ts:16-116 を数えた）
                   → 既定表示（status=all）で summary = { total: 12, ok: 7, error: 5 }、meta.total = 12
                   → status=error に切り替えると summary は不変で meta.total だけ 5 になる
```

★★危ない★★: `summary.total` をページャに渡すと ★`summary.total > meta.total` になり、
存在しないページのボタンが出る★（D-054 / `DsLogScreen.tsx:168-175`）。
部品側にも同じ警告がある（`components/ui/Pagination.tsx:33-39`）。

## 6.6 ツールバー → クエリ文字列

```
DsLogScreen.tsx:200-211
  const queryString = useMemo(() => {
    const params = new URLSearchParams();
    if (debouncedQ) params.set("q", debouncedQ);
    params.set("status", statusFilter);
    appendClassificationParams(params, classification.draft);   // lib/masters/options.ts:218-229
    params.set("per_page", String(PAGINATION.defaultPerPage));
    params.set("page", String(page));
    return params.toString();
  }, [debouncedQ, statusFilter, classification.draft, page]);
```

- ★debounce は 300ms。反映と同時に page を1へ戻す★（`:192-198`）
- ★フィルタ・セレクトを変えたらその場で `setPage(1)`★（`:332-334` / `:376-377` ほか）
- `appendClassificationParams()` は ★`>= 1` のときだけ `set` する★（`lib/masters/options.ts:222-227`）
  — ★`group_id=0` を漏らさないため★（0 は「所属なし」のセンチネル。D-011）

実際に出る URL の例 ★構造由来★:
`/api/ds-devices?q=%E5%B7%9D%E4%B8%8B&status=error&customer_id=2&per_page=50&page=1`

### 検索の照合規則（★フィールドごとに強さが違う★）

```
lib/ds-devices/query.ts:83-90
  export function filterDsDevicesByQ(items: DsDevice[], q: string): DsDevice[]
    includesFoldName(item.facility_name, q)    ← ★記号（中点・長音・空白・ハイフン類）も無視★
    || includesFoldPlain(item.serial_number, q) ← ★大文字小文字のみ★
  ★シリアルで記号を除くと桁の境界が消える（"DS-001" 形式）★（:79-81。D-2）
```

---

# 第7章 D-Secアラート（F-07）

**画面**: `app/(dashboard)/ds-alerts/page.tsx:1-5` → `components/features/ds-alerts/DsAlertsScreen.tsx`
**API**: `GET /api/ds-alerts`（`app/api/ds-alerts/route.ts:33-92`）
**クエリ層**: `lib/ds-alerts/query.ts`
**データ層**: `lib/db/index.ts:1532-1561`（prisma）/ `lib/fixtures/ds-alerts.ts`（fixtures）

★更新系（`PATCH /ds-alerts/{id}/status`）は作っていない★（`app/api/ds-alerts/route.ts:7-9`）。

## 7.1 追えるもの（数値カード3枚 / カード内7要素 / ツールバー6要素）

| 種別 | 名前 |
|---|---|
| カード | 未対応 / 対応中 / 対応済み |
| アラートカード内 | 種別バッジ / シリアル / 施設名 / 清掃エリア名 / 検知日時 / メモ / 対応状況バッジ |
| ツールバー | ホールディングス / 屋号 / 種別セレクト / 未対応・対応中 / 全件 / 対応済み |

## 7.2 縦線① 種別（★実データでは1値しか出ない★）

```
【DBの列】 ★存在しない★ — control_error_histories には種別の列が無い
           （テーブル定義: prisma/schema.prisma:378-396。列は id / facility_id / section_id /
            control_id / status / comment / occurred_at / responded_at / resolved_at のみ）★構造由来★
   ↓ 取得
【取得】   lib/db/index.ts:1537-1544
           prisma.controlErrorHistory.findMany({
             where: { control: { deleted_at: null } },     ← ★control_error_histories に deleted_at が無い★（:1535）
             include: { control: true, facility: true, section: true } })
   ↓ 変換
【変換】   lib/db/index.ts:1548  alert_type: "disconnected"   ← ★★ハードコード★★
           理由: control_error_histories は制御盤異常（生死報告なし）しか持たない（D-067 項1）
   ↓ 型
【型】     DsAlert.alert_type: DsAlertType = "security_off" | "disconnected"
           （lib/types/index.ts:851 / :861）★構造由来★
   ↓ 絞込
【絞込】   lib/ds-alerts/query.ts:87-93
           filterDsAlertsByType(items: DsAlert[], type: DsAlertTypeFilter): DsAlert[]
           DsAlertTypeFilter = "all" | "security_off" | "disconnected"（lib/types/index.ts:855）
   ↓ 表示
【画面】   DsAlertsScreen.tsx:31-34
           const TYPE_LABEL: Record<DsAlertType, string> =
             { security_off: "セキュリティ無効", disconnected: "通信断" }
           :36-38 function typeColor(c, type) { return type === "disconnected" ? c.wn : c.ng; }
           :259 style={{ borderLeftColor: typeColor(c, item.alert_type) }}   ← カード左端の線
           :264 <Pill text={t(TYPE_LABEL[item.alert_type])} color={typeColor(c, item.alert_type)} />
【値の例】 ★構造由来★ "disconnected" → 「通信断」/ ★アンバー★ #f59e0b（dark。lib/theme.ts:14）
                    / #d97706（light。lib/theme.ts:33）
           ★構造由来★ "security_off" → 「セキュリティ無効」/ 赤 #ef4444（lib/theme.ts:16）
           ★シード由来★ 12件中 security_off が6件・disconnected が6件（lib/fixtures/ds-alerts.ts:19-179）
           ★記録済み実測★ `control_error_histories` は5行（当方のシード由来。
                    `comment` が「回線業者に確認中」= `lib/fixtures/ds-alerts.ts:57`。
                    docs/handoff/テストDBのデータ出所_20260917.md:92。2026-09-17）
```

### ★★危ない: 通信断はアンバー／実データでは通信断しか出ない★★（D-143 §3 #5）

- ★色の判断は「アンバー維持（現状）」でユーザー確定★（`docs/decisions.md:11412`。2026-09-16）
- ★帰結★: ★実DBでは `disconnected` しか出ないため画面は全部アンバーになり、赤が1色も出ない★
  （`docs/decisions.md:11423`）
- ★D-Secログ側は「異常＝赤」なので、★2画面で見た目の基準が違ったままになる★★（同 `:11424`）
- `security_off` が出ない理由は `lib/db/index.ts:1519-1531`:
  ★`security_statuses` / `security_status_histories` に `id` / `device_serial` /
  `resolution_status` / `responded_at` / `resolved_at` / `memo` に対応する列が無い★（D-090 項3-9）。
  ★`security_statuses.reported_at` は `@updatedAt` なので `detected_at` も取れない★（D-090 項3-10）。
  ★これは意図した挙動であり実装漏れではない★（`:1529`）。
- ★帰結2★: `/dashboard` のセキュリティバナーが出なくなる（`:1527-1529`）。

## 7.3 縦線② 対応状況（★3値を維持する★。D-079 項2）

```
【DBの列】 control_error_histories.status
           型: Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:383）★構造由来★
           意味: ★0 = 未対応 / 1 = 対応中 / 2 = 対応済★（D-065 Q1-3。docs/decisions.md:484）
           ★★書き込み許可4カラムの1つ★★（D-042。CLAUDE.md §3）
   ↓ 変換
【変換】   lib/db/ds-status.ts:36-49
           export function resolutionStatusFromControlErrorStatus(status: number): DsAlertStatus
             0 → "pending" / 1 → "in_progress" / 2 → "done" / それ以外 → ★throw★
           逆変換（seed 用）: controlErrorStatusFromResolutionStatus()（:52-60）
   ↓ 型
【型】     DsAlert.resolution_status: DsAlertStatus = "pending" | "in_progress" | "done"
           （lib/types/index.ts:852 / :872）★構造由来★
   ↓ 絞込
【絞込】   lib/ds-alerts/query.ts:96-110
           filterDsAlertsByStatus(items: DsAlert[], status: DsAlertStatusFilter): DsAlert[]
             "open" → resolution_status !== "done"   ← ★open = 未対応 + 対応中★
             "done" → === "done"
             "all"  → 全部
           DsAlertStatusFilter = "open" | "all" | "done"（lib/types/index.ts:857）
           ★画面の既定は "open"★（DsAlertsScreen.tsx:63）
   ↓ 表示
【画面】   DsAlertsScreen.tsx:277-291  3分岐で <span className={styles.statusBadge} ...>
             "done"        → 「対応済み」（styles.statusDone）
             "in_progress" → 「対応中」（styles.statusInProgress）
             "pending"     → 「未対応」（styles.statusPending）
【値の例】 ★シード由来★ 12件の内訳: pending 6件（id 1,2,5,7,10,11）/ in_progress 4件（3,6,8,12）/
                   done 2件（4,9）（lib/fixtures/ds-alerts.ts:19-179）
                   → summary = { pending: 6, in_progress: 4, done: 2 }
                   → 既定（status=open）の meta.total = 10
```

★★D-079 項2 の「`resolution_status` の3値は2状態化の対象外」★★（`docs/decisions.md:2133` / `:3549-3551`）—
`DsConnectionStatus` は D-065 で3→2状態に縮めたが（D-076）、★こちらは3値のまま残す★。
理由は ★`control_error_histories.status`（0/1/2）に直結しているから★（`docs/decisions.md:3551`）。
★2つの「status」を同じ話だと思って一緒に潰さないこと★。

## 7.4 縦線③ その他の列

| 画面の要素 | DBの列 | 変換 | 型 | 値の例 |
|---|---|---|---|---|
| シリアル | `controls.serial_number`（`prisma/schema.prisma:202`。`@db.VarChar(100)`）★構造由来★ | `lib/db/index.ts:1549` `row.control.serial_number` | `DsAlert.device_serial: string`（`lib/types/index.ts:862`） | ★シード由来★ `"DS-001"`（`lib/fixtures/ds-alerts.ts:23`） |
| 施設名 | `facilities.facility_name`（`:129`。`@db.VarChar(400)`）★構造由来★ | `lib/db/index.ts:1551` | `facility_name: string`（`:864`） | ★シード由来★ `"メガバリューマート中央本店"`（`lib/fixtures/ds-alerts.ts:25`） |
| 清掃エリア名 | `sections.section_name`（`:171`。`@db.VarChar(100)`）★構造由来★ | `lib/db/index.ts:1552` | `section_name: string`（`:870`） | ★シード由来★ `"1F売り場"`（`lib/fixtures/ds-alerts.ts:26`）。★fixtures 側は暫定ダミー★（`lib/types/index.ts:865-868`） |
| 検知日時 | `control_error_histories.occurred_at`（`:385`。`@db.DateTime(0)`）★構造由来★ | `jstIsoFromDb()`（`lib/db/index.ts:1553`） | `detected_at: string`（`:871`） | ★シード由来★ `at(0,"03:42")` → 画面「9/17 03:42」 |
| メモ | `control_error_histories.comment`（`:384`。`@db.Text`、nullable）★構造由来★ | `lib/db/index.ts:1559` そのまま | `memo: string \| null`（`:875`） | ★シード由来★ `"回線業者に確認中"`（`lib/fixtures/ds-alerts.ts:57`）。★null なら行ごと出さない★（`DsAlertsScreen.tsx:274`） |
| （画面に無い） | `responded_at` / `resolved_at`（`:386` / `:387`。nullable）★構造由来★ | `row.x ? jstIsoFromDb(row.x) : null`（`lib/db/index.ts:1556-1557`） | `string \| null`（`:873-874`） | ★型とAPIにはあるが F-07 の画面には出ていない★ |

## 7.5 並べ替えと summary

```
lib/ds-alerts/query.ts:113-120
  export function sortDsAlerts(items: DsAlert[]): DsAlert[]
    detected_at の降順（文字列比較）。同値なら id 昇順（D-052 と同じ規則）
    ★ISO8601 の固定長文字列なので辞書順＝時刻順★

lib/ds-alerts/query.ts:136-145
  export function summarizeDsAlerts(items: DsAlert[]): DsAlertSummary
  interface DsAlertSummary { pending: number; in_progress: number; done: number }（:122-126）

app/api/ds-alerts/route.ts
  :64 const scoped = filterRowsByFacilityIds(rows, facilityIds)   ← ★分類は summary の前★
  :69 const summary = summarizeDsAlerts(scoped)                   ← ★type / status の前★
  :70-73 filtered = filterDsAlertsByStatus(filterDsAlertsByType(scoped, type), status)
  :81 const total = sorted.length
```

★★危ない★★: summary をフィルタ後に計算すると ★既定の `status=open` のせいで
「対応済み」が常に0★になる（`app/api/ds-alerts/route.ts:66-68`。F-03 で実際に起きた事故。D-054）。

---

# 第8章 施設ロボットマスタ（F-08）

**画面**: `app/(dashboard)/facilities/page.tsx:1-5` → `components/features/facilities/FacilitiesScreen.tsx`
**API**: `GET /api/facilities`（`app/api/facilities/route.ts:32-81`）★GET のみ★
**クエリ層**: `lib/facilities/query.ts`
**データ層**: `lib/db/index.ts:1611-1662`（prisma）/ `lib/fixtures/facilities.ts`（fixtures）

★登録・更新ボタンは置かない★（`FacilitiesScreen.tsx:18-22`）—
モックにはあるが `onClick` が無い飾りで、実装すると「押せるのに何も起きないUI」になる（D-053）。
★summary は無い（`items` と `meta` だけ）★（`app/api/facilities/route.ts:8-9`）。

## 8.1 追えるもの（表10列 + 検索入力1）

施設名 / 都道府県 / 住所 / 電話番号 / 責任者名 / 担当者名 / メール / ★監視★ / ★D-Sec★ /
ロボット（メーカー・機種・シリアル + 未対応バッジ）
（`FacilitiesScreen.tsx:126-135` が見出し、`:141-185` が本体）

## 8.2 縦線① ★★契約フラグ（監視 / D-Sec）★★ — ★この文書で最も事故りやすい★

```
【DBの列】 facilities.contract
           型: String @default("0000000000") @db.VarChar(10)（prisma/schema.prisma:139）★構造由来★
           意味: ★★桁位置がサービスに対応する★★（D-080。CLAUDE.md §4.6）
                 ★1桁目（index 0）= D-Security / 2桁目（index 1）= D-Watch★
                 契約済み（有効）なサービスの桁を "1" にする
                 ★3桁目以降は将来のサービス用の予備と読める（定義書に明記なし。P1では読まない）★
   ↓ 桁位置の定義
【設定】   lib/config/index.ts:633-636
           export const CONTRACT_DIGITS = { dSecurity: 0, dWatch: 1 } as const
           ★桁位置の数字をこのファイル以外に直書きしないこと★（:631）
   ↓ 変換（★唯一の消費者★）
【変換】   lib/format/contract.ts:26-31
           export function parseContract(contract: string): ContractFlags
           interface ContractFlags { monitoring_enabled: boolean; d_security_enabled: boolean }（:13-16）
             d_security_enabled: contract[CONTRACT_DIGITS.dSecurity] === "1"
             monitoring_enabled: contract[CONTRACT_DIGITS.dWatch]   === "1"
           ★文字の完全一致だけで判定する（:8）★
           ★桁が足りない文字列・空文字・10桁超でも例外を投げない★（:23-24）—
             存在しない桁は `undefined === "1"` が false になり自然に「契約なし」扱い
           逆変換（seed 用）: buildContract(flags): string（:36-41）
   ↓ 呼び出し
【取得】   lib/db/index.ts:1632
           const { monitoring_enabled, d_security_enabled } = parseContract(row.contract);
   ↓ 型
【型】     Facility.monitoring_enabled: boolean（lib/types/index.ts:955。宣言は :948-954）
           Facility.d_security_enabled: boolean（:960。宣言は :956-959）
   ↓ 表示
【画面】   FacilitiesScreen.tsx:148-154   監視列
             {facility.monitoring_enabled ? <span className={styles.markOn}>○</span>
                                          : <span className={styles.markOff}>ー</span>}
           :155-161  D-Sec列（markOnSec / markOff）
【値の例】 ★構造由来★ "0000000000" → { monitoring: false, dSecurity: false } → 「ー / ー」
           ★構造由来★ "1000000000" → { monitoring: false, dSecurity: true  } → 「ー / ○」
           ★構造由来★ "0100000000" → { monitoring: true,  dSecurity: false } → 「○ / ー」
           ★構造由来★ "1100000000" → { monitoring: true,  dSecurity: true  } → 「○ / ○」
           ★シード由来★ 施設1「メガバリューマート中央本店」= 両方 true（lib/fixtures/facilities.ts:53-54）
           ★シード由来★ 施設11「バリューマート川下店」= D-Securityのみ（:253-254）
           ★シード由来★ 施設12「フレッシュタウン湖畔店」= 未設定（:276-277）
           ★記録済み実測★ `facilities` は12行で、★`contract` を含む全列が lib/fixtures/facilities.ts と一致★
                    ＝ ★当方のシード由来★（docs/handoff/テストDBのデータ出所_20260917.md:90。2026-09-17）
```

### ★★危ない: 数値として比較してはいけない★★

```ts
// ❌ 絶対にやってはいけない
Number("0000000000") === 0        // ★例外を出さずに 0 になる★
facility.contract === 3           // ★絶対に真にならない★
```

★帰結★: ★全施設が「契約なし」になりメニューが全部消えるが、例外もテスト失敗も出ない★
（`CLAUDE.md` §4.6 / `lib/format/contract.ts:4-6` / `lib/types/index.ts:952-953`）。

★もう1つ★: `customers.contract` は ★削除依頼済みで参照禁止★（D-010）。
`prisma/schema.prisma` の `model Customer`（`:94-120`）に ★そもそも列が無い★
（`lib/db/index.ts:1583-1584` に明記）。

★もう1つ★: `clean_schedules` にも `contract` 列がある（`prisma/schema.prisma:563`。
`@default("0000000000") @db.VarChar(10)`）★構造由来★ が、★アプリは一切読んでいない★
（`lib/db/index.ts:1678-1701` の `schedules()` に `contract` が出てこない）。
★`facilities.contract` のみが正★（`CLAUDE.md` §4.6）なので現状は正しいが、
★同じ名前・同じ型の列が2つあることは覚えておくこと★。

## 8.3 縦線② 住所（2列 → 1文字列）

```
【DBの列】 facilities.addr1  型: String @db.VarChar(800)（prisma/schema.prisma:133）★構造由来★
           facilities.addr2  型: String? @db.VarChar(800)（:134。★nullable★）★構造由来★
   ↓ 変換
【変換】   lib/format/address.ts:7-10
           export function formatAddress(addr1: string, addr2: string | null): string
             if (!addr2) return addr1;
             return `${addr1}${addr2}`;        ← ★区切り文字を付けない★（:4-5）
   ↓ 呼び出し
【取得】   lib/db/index.ts:1648  address: formatAddress(row.addr1, row.addr2)
   ↓ 型
【型】     Facility.address: string（lib/types/index.ts:943。★null にならない★）
   ↓ 絞込
【絞込】   lib/facilities/query.ts:108  includesFoldPlain(item.address, q)
           ★住所は大文字小文字のみ折りたたむ（記号は残す）★（:93-96）—
           ★ハイフンは番地の区切りなので、除くと "23" が "1-2-3" に誤ヒットする★
   ↓ 表示
【画面】   FacilitiesScreen.tsx:143  <td className={styles.tdMuted}>{facility.address}</td>
【値の例】 ★シード由来★ "東京都○○区1-2-3"（lib/fixtures/facilities.ts:48）
           ★構造由来★ addr1="東京都○○区1-2-3" / addr2=null → "東京都○○区1-2-3"
           ★構造由来★ addr1="東京都○○区1-2-3" / addr2="○○ビル5F" → "東京都○○区1-2-3○○ビル5F"
```

## 8.4 縦線③ ロボット（メーカー / 機種 / シリアル / 未対応バッジ）

```
【DBの列】 robots.serial_number   型: String @db.VarChar(20)（prisma/schema.prisma:231）★構造由来★
           devices.maker_id       型: Int @db.UnsignedTinyInt（:82。★0〜255★）★構造由来★
             ★makers テーブルは提供されない★（D-057 A-1。:79-81）
           devices.model_name     型: String @db.VarChar(100)（:83）★構造由来★
   ↓ 取得
【取得】   lib/db/index.ts:1617-1630
           prisma.facility.findMany({ where: { deleted_at: null },
             include: { robots: { where: { deleted_at: null }, include: { device: true } },
                        customer: { include: { group: true } } } })
           ★customer / group は deleted_at で絞らない★（:1626-1627）—
             絞ると論理削除された屋号に属する施設が静かに落ちる
   ↓ 変換
【変換】   lib/db/index.ts:1655-1660
           robots: row.robots.map(robot => ({
             id: Number(robot.id),
             maker: MAKER_NAMES[robot.device.maker_id] ?? String(robot.device.maker_id),  ← :1657
             model: robot.device.model_name,
             serial_number: robot.serial_number,
           }))
           MAKER_NAMES: Record<number, string> = { 1: "PUDU", 2: "Keenon" }（lib/config/index.ts:597-600）
           ★★未知の番号は String(maker_id) をそのまま返す。"不明" 等に潰さない★★（:593-595。D-077 と同じ思想）
   ↓ 型
【型】     RobotSummary { id: number; maker: string; model: string; serial_number: string }
           （lib/types/index.ts:917-922）
           Facility.robots: RobotSummary[]（:961）
   ↓ 表示
【画面】   FacilitiesScreen.tsx:162-185
           <Pill text={robot.maker} color={c.pr} />                ← :165（青 #3b82f6。lib/theme.ts:10）
           <span className={styles.robotModel}>{robot.model}</span> ← :166
           {!isSupportedModel(robot.maker, robot.model) && <Pill text={t("未対応")} color={c.wn} />} ← :179-181
           <span className={styles.robotSerial}>{robot.serial_number}</span> ← :182
   ↓ 未対応判定
【判定】   lib/format/robot-model.ts:36-45
           export function isSupportedModel(maker: string, model: string): boolean
             normalize(v) = v.trim().toUpperCase()（:28-30）
             ROBOT_MODELS.some(e => e.supported && normalize(e.maker)===M && normalize(e.model)===Mo)
           ROBOT_MODELS（lib/config/index.ts:620-625）★構造由来★:
             { maker:"PUDU",   model:"CC1", supported: true  }
             { maker:"PUDU",   model:"MT1", supported: false }
             { maker:"Keenon", model:"C30", supported: false }
             { maker:"Keenon", model:"C40", supported: false }
【値の例】 ★シード由来★ 施設1: maker "PUDU" / model "CC1" / serial "8111R5628060011"
                   （lib/fixtures/facilities.ts:56）→ 未対応バッジ ★出ない★
           ★構造由来★ maker_id=1 / model_name="MT1" → 「PUDU」「MT1」+ ★「未対応」バッジ★
           ★構造由来★ maker_id=9（MAKER_NAMES に無い）→ ★"9" がそのまま Pill に出る★ + 未対応バッジ
           ★シード由来★ 施設7 は ★3台★（id 107 / 108 / 109。lib/fixtures/facilities.ts:134-136）
           ★シード由来★ 施設12 は ★0台★（robots: []。:278）
           ★記録済み実測★ `robots` は14行、`devices` は1行（maker_id=1 / model_name="CC1"）
                    ＝ ★当方由来★（docs/handoff/テストDBのデータ出所_20260917.md:87 / :96。2026-09-17）
```

### ★★危ない① 「未対応」の対訳が衝突している★★

`lib/i18n.ts:161` に `未対応: "Unsupported"` がある。
★D-Secアラート（F-07）の「未対応」（＝まだ対応していない）★ も同じキーを引くので、
★英語表示にすると `Unsupported`（非対応）と出続ける★。
★ユーザー判断は「そのまま（直さない）」★（D-143 §3 #1。`docs/decisions.md:11408`。
理由は「英語を配布するか未定」）。★英語を配布すると決めた時点で必ず直す必要がある★（同 `:11417-11418`）。

### ★危ない② フロントで判定していることの説明★

`isSupportedModel()` は ★`CLAUDE.md` §9.6（判定結果はAPIから受け取る）の例外ではない★ —
DFA側の静的な対応表を引くだけで、APIレスポンスに依存する判定ではない
（`FacilitiesScreen.tsx:172-177` に理由が書いてある）。

## 8.5 縦線④ 任意項目（null の扱い）

```
【DBの列】 facilities.phone         String? @db.VarChar(16)（prisma/schema.prisma:135）★構造由来★
           facilities.manager_name  String? @db.VarChar(100)（:137）★構造由来★
           facilities.charger_name  String? @db.VarChar(100)（:138）★構造由来★
           facilities.email         String? @db.VarChar(100)（:136）★構造由来★
   ↓ 型
【型】     Facility.phone / manager_name / charger_name / email: string | null
           （lib/types/index.ts:944-947）
   ↓ 表示
【画面】   FacilitiesScreen.tsx:29-31
           function orDash(value: string | null): string { return value ?? "-"; }
           :144-147 orDash(facility.phone) / (manager_name) / (charger_name) / (email)
           ★空セルにすると列がずれて見える★（:24-25）
【値の例】 ★シード由来★ 施設10「フレッシュタウン大和店」は phone / manager_name / charger_name / email が
                   ★全部 null★（lib/fixtures/facilities.ts:228-231）→ 画面は「- - - -」
           ★シード由来★ 施設12 は charger_name のみ null（:274）
```

★申し送り★: `facilities.email` は `@db.VarChar(100)` だが
`facility_contacts.email` は `varchar(255)` のままで食い違っている
（`prisma/schema.prisma:60-61` のコメント。2026-09-09 現行DDL）★構造由来★。

## 8.6 縦線⑤ 所属（customer_id / group_id / group_name）— ★画面に列は無いが型にはある★

```
【DBの列】 facilities.customer_id  BigInt @db.UnsignedBigInt（prisma/schema.prisma:124。NOT NULL）★構造由来★
           customers.group_id      BigInt @default(0) @db.UnsignedBigInt（:96。★NOT NULL★）★構造由来★
                                   ★0 = ホールディングスなしのセンチネル★（D-011）
   ↓ 変換
【変換】   lib/format/group-id.ts:77-79
           export function groupIdFromDb(groupId: number): number | null
             return groupId === GROUP_ID_NONE ? null : groupId;
           GROUP_ID_NONE = 0（lib/config/index.ts:651）★構造由来★
           ★★Number() で BigInt を落としてから渡すこと★★（:73-75）—
             ★`0n === 0` は false★ なので BigInt のままだと静かに外れる
   ↓ 呼び出し
【取得】   lib/db/index.ts:1636  const group_id = groupIdFromDb(Number(row.customer.group_id));
           :1646 group_name: group_id === null ? null : (row.customer.group?.group_name ?? null)
                 ★「所属なし」のときは名前も null★（:1643-1645）
   ↓ 型
【型】     Facility.customer_id: number（lib/types/index.ts:931。★null にならない★）
           Facility.group_id: number | null（:939）
           Facility.group_name: string | null（:941）
   ↓ 使われ方
【用途】   ★F-08 の表には出ていない★（FacilitiesScreen.tsx:126-135 に列が無い）。
           ★分類の絞り込み（他画面のセレクト）と /customers・/groups の選択肢のために持っている★
【値の例】 ★シード由来★ 施設1: customer_id=1 / group_id=1 / group_name="バリューホールディングス"
                   （lib/fixtures/facilities.ts:43-46）
           ★シード由来★ 施設12: customer_id=4 / ★group_id=null / group_name=null★（:265-269）
           ★記録済み実測★ 本番DBの `customers` は1行（`customer_name = "テスト顧客001"`）/
                    `groups` は1行（`group_name = "テストグループ001"`）＝
                    ★旧版の当方シード★（docs/handoff/テストDBのデータ出所_20260917.md:94-95。2026-09-17）
```

★★危ない★★: ★`=== 0` をルート・fixtures・DataSource に散らさないこと★ —
1箇所直し忘れると ★「グループ0」という存在しないホールディングスが画面に出る★
（`lib/format/group-id.ts:54-57`。★例外もテスト失敗も出ない★）。

## 8.7 検索と並べ替え

```
lib/facilities/query.ts:102-116  filterFacilitiesByQ(items: Facility[], q: string): Facility[]
  ★OR は q の内部だけ★:
    includesFoldName(facility_name, q)      ← 記号も無視
    || includesFoldName(prefecture, q)      ← 記号も無視
    || includesFoldPlain(address, q)        ← 大文字小文字のみ
    || robots.some(r => includesFoldPlain(r.serial_number|maker|model, q))
  ★robots が空配列でも落ちない★（:98-100。some() は空配列に false）

lib/facilities/query.ts:141-162  filterFacilitiesByFields(items, query): Facility[]
  facility_name / serial / address は ★AND★（:121-122）
  ★この3つはどの画面からも送られていない（API直叩き専用）★（:137-139）

lib/facilities/query.ts:174-176  sortFacilities(items): Facility[]  → ★id 昇順★
  ★安定化のためだけの規則で優先度ではない★（:167-172）

lib/facilities/query.ts:190-201  paginateFacilities(items, page, perPage): ListResponse<Facility>
  ★meta.total は「フィルタ後・ページング前」の件数★（:181-186）
```

★★危ない★★: 分類の突き合わせは ★`facility_id` ではなく `id`★
（`app/api/facilities/route.ts:62-67` / `lib/facilities/filter-by-ids.ts:70-77`）—
★このAPIが返すのは「施設の行そのもの」だから★。

---

# 第9章 スケジュール（F-09）

**画面**: `app/(dashboard)/schedules/page.tsx:1-5` → `components/features/schedules/SchedulesScreen.tsx`
**API**: `GET /api/schedules`（`app/api/schedules/route.ts:35-89`）★GET のみ★
**クエリ層**: `lib/schedules/query.ts`
**データ層**: `lib/db/index.ts:1664-1702`（prisma）/ `lib/fixtures/schedules.ts`（fixtures）
**整形**: `lib/format/schedule.ts`

★完全な参照専用★（`SchedulesScreen.tsx:13-18`）。★summary は無い★（`app/api/schedules/route.ts:7-9`）。

## 9.1 追えるもの（表5列 + 検索入力1）

シリアル / 施設名 / ★曜日★ / ★時間★ / ★有効★（`SchedulesScreen.tsx:128-132` / `:143-157`）

## 9.2 縦線① ★★曜日（7桁ビット列 → ラベル）★★

```
【DBの列】 clean_schedules.weeks
           型: String @db.VarChar(7)（prisma/schema.prisma:556）★構造由来★
           意味: ★「日月火水木金土」の7桁。清掃する曜日が "1"★
                 （lib/fixtures/schedules.ts:6-10 / lib/format/schedule.ts:4-5）
           ★★起点は日曜★★ — この起点をフロントと2箇所に持たないこと（同 :5）
   ↓ 取得
【取得】   lib/db/index.ts:1694-1695
           weekdays: row.weeks,                    ← ★7桁をそのまま API にも載せる★
           weekdays_label: weekdaysLabel(row.weeks),
           ★カラム名が違う（DB は weeks / API は weekdays）★（lib/schedules/query.ts:21-22）
   ↓ 変換
【変換】   lib/format/schedule.ts:17-28
           export function weekdaysLabel(weeks: string): string
             const WEEKDAY_CHARS = ["日","月","火","水","木","金","土"] as const（:8）
             switch (weeks) {
               case "1111111": return "毎日";
               case "0111110": return "平日のみ";
               case "1000001": return "土日のみ";
             }
             const days = WEEKDAY_CHARS.filter((_, i) => weeks[i] === "1");
             return days.length > 0 ? days.join("") : "なし";
           ★桁が足りない文字列でも例外を投げない★（:15）
           ★該当0なら "なし"（空文字を返すと不具合と区別できない）★（:14）
   ↓ 型
【型】     Schedule.weekdays: string（7桁。lib/types/index.ts:969-970）
           Schedule.weekdays_label: string（:971）
   ↓ 表示
【画面】   SchedulesScreen.tsx:145-146
           {/* サーバー生成のラベルをそのまま出す。7桁の weekdays は見ない */}
           <td className={styles.td}>{item.weekdays_label}</td>
           ★weekdays（7桁）を画面で解釈しないこと★（:28-31）
【値の例】 ★構造由来★ "1111111" → 「毎日」（特例）
           ★構造由来★ "0111110" → 「平日のみ」（特例）
           ★構造由来★ "1000001" → 「土日のみ」（特例）
           ★構造由来★ "0101010" → 「月水金」（一般。特例に当たらない）
           ★構造由来★ "1010101" → 「日火木土」（一般）
           ★構造由来★ "0000000" → 「なし」
           ★構造由来★ "010"     → 「月」（桁不足でも落ちない）
           ★シード由来★ id23 = "0101010" → "月水金"（lib/fixtures/schedules.ts:88-89）
           ★シード由来★ id25 = "0111110" → "平日のみ"（:113-114）
           ★シード由来★ id30 = "1010101" → "日火木土"（:177-178）
```

### ★★`weekdaysLabel()` は7桁ビット列 128通りを扱う★★

- ★2^7 = 128通りの入力がありうる★。★特例は3通りだけ★（`"1111111"` / `"0111110"` / `"1000001"`）。
- ★残り125通りは `filter` + `join` の一般処理★で、★「日火木土」のような読みにくいラベルが出る★。
- ★桁不足・桁超過・"1"以外の文字も例外にならず、単に選ばれないだけ★（`:15`）。
  例: `"abcdefg"` → 「なし」、`"2222222"` → 「なし」。
- ★特例3通りをここ以外に書かないこと★ — 起点（日曜）の解釈が2箇所に散ると片方だけ直る。

## 9.3 縦線② ★★時間（HHMM 文字列。時が24〜29を取る）★★

```
【DBの列】 clean_schedules.start_time  型: String @db.VarChar(4)（prisma/schema.prisma:558）★構造由来★
             ★HHMM 形式。0000〜2359★（TeamWorks 回答 B-5。:557）
           clean_schedules.end_time    型: String @db.VarChar(4)（:561）★構造由来★
             ★★時が 24〜29 を取りうる★★（:559-560。例: "2830" は翌日4:30）
             ★日跨ぎで翌6:00 まで設定可能にするための表現★
             ★HH:MM として素朴にパースすると壊れる★
   ↓ 変換
【変換】   lib/format/schedule.ts:35-42
           export function formatStartTime(hhmm: string): string
             hour = Number.parseInt(hhmm.slice(0,2), 10); minute = hhmm.slice(2,4);
             if (!(hour >= 0 && hour <= 23)) throw new RangeError(...)   ← ★0〜23 のみ★
             return `${pad2(hour)}:${minute}`;

           lib/format/schedule.ts:51-61
           export function formatEndTime(hhmm: string): string
             if (hour >= 24 && hour <= 29) return `翌${pad2(hour - 24)}:${minute}`;   ← ★24を引く★
             if (hour >= 0  && hour <= 23) return `${pad2(hour)}:${minute}`;
             throw new RangeError(...)                                    ← ★30時以上は例外★
   ↓ 呼び出し
【取得】   lib/db/index.ts:1696  start_time: formatStartTime(row.start_time)
           lib/db/index.ts:1699  end_time:   formatEndTime(row.end_time)
           ★整形は db.schedules()（データ層）が済ませてから Schedule を返す約束★
             （lib/schedules/query.ts:28-29。★クエリ層は一切関与しない★）
   ↓ 型
【型】     Schedule.start_time: string / end_time: string
           （lib/types/index.ts:972-973。★整形済み文字列。D-060★）
   ↓ 表示
【画面】   SchedulesScreen.tsx:147-150
           {/* 整形済みの文字列を並べるだけ（日跨ぎは「23:30 - 00:30」等） */}
           <td className={styles.td}>{item.start_time} - {item.end_time}</td>
           ★end_time をパースしないこと★（:33-36）
【値の例】 ★構造由来★ start "0200" → "02:00" / end "0300" → "03:00"  → 画面「02:00 - 03:00」
           ★構造由来★ end "2430" → ★"翌00:30"★  → 画面「23:30 - 翌00:30」
           ★構造由来★ end "2830" → ★"翌04:30"★
           ★構造由来★ end "3000" → ★RangeError（値域外なので静かに変な表示にせず止める）★
           ★構造由来★ start "2400" → ★RangeError（start は 0000〜2359 のみ）★
           ★シード由来★ id27（施設5・日跨ぎ）は fixtures 側が ★"00:30"★（lib/fixtures/schedules.ts:141）
                   ★★DB 版は "翌00:30"★★ — ★この1件だけ意図的に不一致で、DB版が正しい★
                   （docs/decisions.md:2888-2892。D-086 項10。2026-09-08）
```

### ★★危ない: fixtures と DB で値が違う唯一の箇所★★

```
fixtures: end_time = "00:30"     （lib/fixtures/schedules.ts:141。★D-060 より前の書き方★）
シード  : end_time = "2430" を DB に書く（scripts/seed-local-db.ts。docs/decisions.md:2890）
API(DB) : end_time = "翌00:30"
```

★fixtures の `"00:30"` をそのまま DB に書くと「開始より前に終わる」矛盾したデータになる★
（`docs/decisions.md:2888-2890`）。★`USE_FIXTURES` を切り替えると id27 の表示だけが変わる★。

## 9.4 縦線③ ★★有効（論理が反転している）★★

```
【DBの列】 clean_schedules.inactive
           型: Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:564）★構造由来★
           意味: ★★0 = 有効★★（inactive なので反転している）
   ↓ 変換
【取得】   lib/db/index.ts:1700
           active: row.inactive === 0,
           ★★`Boolean(row.inactive)` と書かないこと★★（:1697-1698 / lib/schedules/query.ts:15-19）
           ★それでは全件反転する（有効なスケジュールが「無効」として並ぶ）★
   ↓ 型
【型】     Schedule.active: boolean（lib/types/index.ts:974）
   ↓ 絞込
【絞込】   ★active では絞り込まない★（lib/schedules/query.ts:130-133 / app/api/schedules/route.ts:67）
           ★一覧は有効・無効の両方を返す★ — API 側で無効行を落とすと
           「登録したはずのスケジュールが消えた」ように見える。YAML にも active パラメータは無い
   ↓ 表示
【画面】   SchedulesScreen.tsx:138-142  行全体のクラス（無効は styles.rowInactive を追加）
           :151-157  <td className={... item.active ? styles.activeOn : styles.activeOff}>
                       {item.active ? "●" : "○"}
【値の例】 ★構造由来★ inactive=0 → active=true  → 「●」
           ★構造由来★ inactive=1 → active=false → 「○」+ 行が薄く（rowInactive）
           ★シード由来★ id24（施設6）だけ active=false（lib/fixtures/schedules.ts:104）。他の12件は true
           ★記録済み実測★ `clean_schedules` は21行（id 21〜32 が fixtures、9001〜9009 がシードの増量分）
                    ＝ ★当方由来★（docs/handoff/テストDBのデータ出所_20260917.md:84。2026-09-17）
```

★もう1つの反転の罠★: `deleted_at` と `inactive` は別物。
★`deleted_at IS NULL` でのみ絞り、`inactive` では絞らない★（`lib/db/index.ts:1668`。D-086 項9。
★これは仕様が未確定な箇所★ — `docs/decisions.md:2883-2887`）。

## 9.5 縦線④ シリアル（★1清掃エリア = 1ロボットの前提★）

```
【DBの列】 robots.serial_number（prisma/schema.prisma:231。@db.VarChar(20)）★構造由来★
           ★clean_schedules は section_id を持つだけで serial_number は持たない★（:555）
   ↓ 取得
【取得】   lib/db/index.ts:1667-1677
           prisma.cleanSchedule.findMany({ where: { deleted_at: null },
             include: { facility: true,
                        section: { include: { robots: { where: { deleted_at: null } } } } } })
   ↓ 変換
【変換】   lib/db/index.ts:1683-1691
           const robot = row.section.robots[0];       ← ★★[0] 決め打ち★★
           if (!robot) throw new Error(
             `[db] schedules: clean_schedules(id=${row.id}) の section(id=${row.section_id}) に
              ロボットが見つかりません。P1は1清掃エリア=1ロボットの前提です。`);
           serial_number: robot.serial_number
   ↓ 型
【型】     Schedule.serial_number: string（lib/types/index.ts:966）
   ↓ 絞込
【絞込】   lib/schedules/query.ts:118-125  includesFoldPlain(item.serial_number, q)
           ★serial は横断検索しない★（:135-137）— 1レコード1シリアルだから
   ↓ 表示
【画面】   SchedulesScreen.tsx:143  <td className={styles.tdSerial}>{item.serial_number}</td>
【値の例】 ★シード由来★ id21 = "8111R5628060011"（lib/fixtures/schedules.ts:60）
           ★構造由来★ section にロボットが0台 → ★例外 → 一覧全体が 500★
```

### ★★危ない: 施設12 の扱いが fixtures と DB で食い違う★★

| | 施設12 のロボット | 施設12 のスケジュール |
|---|---|---|
| `lib/fixtures/facilities.ts:278` | ★`robots: []`（0台）★ | — |
| `lib/fixtures/schedules.ts:210-219` | — | ★id33 が存在する（serial `8111R5628060166`）★ |
| シード（DB） | — | ★id33 を構造的に除外している★（`docs/decisions.md:2880-2882`。D-086 項8。実測でスキップは1件） |

★fixtures 単体では矛盾している★（0台の施設にスケジュールがある）が、
★fixtures 経路は `db.schedules()` が fixtures 配列をそのまま返すだけ★（`lib/db/index.ts:518`）
なので ★`robots[0]` の例外を踏まない★。
★Prisma 経路では id33 が DB に無いので、やはり例外を踏まない★。
★どちらの経路でも動くが、理由が違う★。★施設12 にロボットを1台足すと fixtures 側だけ挙動が変わる★。

## 9.6 並べ替えとページング

```
lib/schedules/query.ts:173-175  sortSchedules(items): Schedule[]  → ★id 昇順★
  ★曜日や時刻で並べない★（:167-171）— 「翌04:30」のような整形済み文字列では意図した順序にならない

lib/schedules/query.ts:190-197  paginateSchedules(items, page, perPage): Schedule[]
  ★ルートで slice を直書きせず関数に出してある★（:180-186）— テストが本番と同じ関数を叩けるように

app/api/schedules/route.ts:80-86
  const total = sorted.length;                          ← meta.total
  const items = paginateSchedules(sorted, page, perPage);
  body: ListResponse<Schedule> = { items, meta: { total, page, per_page: perPage } }
```

---

# 第10章 システム管理（F-10）

**画面**: `app/(dashboard)/system/page.tsx:1-5` → `components/features/system/SystemScreen.tsx`
**API**: `GET/PATCH /api/settings/advice`（`app/api/settings/advice/route.ts`）/
`GET/PATCH /api/settings/display-limits`（`app/api/settings/display-limits/route.ts`）
**ストア**: `lib/settings/advice.ts` / `lib/settings/display-limits.ts` / `lib/settings/json-store.ts`

★★この画面は**メインDBを1行も読まない**★★ — `db.*` の呼び出しが1つも無い。
値の出所は ★`lib/config/`（既定値）★ と ★`data/settings/*.json`（DFA独自ストア）★ だけ。

## 10.1 追えるもの（5セクション / 編集可1件 / 編集不可バッジ4種 / 入力1 / ボタン2）

| セクション | 出所 | 編集 |
|---|---|---|
| 事前チェック設定 | `PRECHECK_MINUTES_BEFORE`（`lib/config/index.ts:214`） | ★不可★ |
| 完了率の判定閾値 | `GRADE_THRESHOLDS`（`:18-25`） | ★不可★ |
| アドバイス エスカレーション閾値 | `ESCALATION_THRESHOLDS`（`:27-32`） | ★不可★ |
| ★連続日数の表示上限★ | `GET /api/settings/display-limits` | ★★可（この画面で唯一）★★ |
| アドバイス文言 | `GET /api/settings/advice` | 文言のみ可（画面には編集UIなし・表示のみ） |

## 10.2 縦線① 事前チェック設定（★設定値 → 画面。DBもAPIも通らない★）

```
【設定】   lib/config/index.ts:214
           export const PRECHECK_MINUTES_BEFORE = [60, 30] as const   ★構造由来★
           ★1清掃につき2回で固定と確定している★（ユーザー回答 2026-09-11。:209-212）
   ↓ 直接 import
【画面】   SystemScreen.tsx:39-43  import { ..., PRECHECK_MINUTES_BEFORE } from "@/lib/config"
           :270-279
             {PRECHECK_MINUTES_BEFORE.map((minutes, i) => (
               <dt>{t("第{n}回チェック").replace("{n}", String(i + 1))}</dt>
               <dd>{t("{n}分前").replace("{n}", String(minutes))}</dd>
             ))}
           :264 <span className={styles.lockBadge}>{t("編集不可")}</span>
【値の例】 ★構造由来★ [60, 30] → 「第1回チェック / 60分前」「第2回チェック / 30分前」
```

★ここだけ API を通さない理由★: 判定に使う閾値で、★編集できない★から
（`SystemScreen.tsx:6-9` / `:11-14`）。★動かないトグル・入力欄・保存ボタンは置かない★。

## 10.3 縦線② 完了率の判定閾値（★境界の向きが文言に効く★）

```
【設定】   lib/config/index.ts:18-25
           export const GRADE_THRESHOLDS = { excellent: 90, good: 70, poor: 10 } as const  ★構造由来★
           ★★境界は「上のランクに入る」★★（D-139。2026-09-16）
           ★× の境界だけは「未満」のまま★（10.0 は △）
   ↓ 直接 import
【画面】   SystemScreen.tsx:90-98
           const GRADE_RULES: { grade: Grade; condition: string }[] = [
             { grade: "◎", condition: `完了率 ${GRADE_THRESHOLDS.excellent}% 以上` },
             { grade: "○", condition: `完了率 ${GRADE_THRESHOLDS.good}% 以上` },
             { grade: "△", condition: `完了率 ${GRADE_THRESHOLDS.poor}% 以上` },
             { grade: "×", condition: `上記未満（完了率 ${GRADE_THRESHOLDS.poor}% 未満）、または未実施` },
           ];
           Grade = "◎" | "○" | "△" | "×"（lib/types/index.ts:17）
           :292-299 <GradeBadge grade={rule.grade} /> + 条件文
           :300-304 注記「判定は丸める前の生値で行います（表示は小数第1位を四捨五入した整数）。
                          境界値ちょうどは上位の判定になります。」
【値の例】 ★構造由来★ 「完了率 90% 以上」「完了率 70% 以上」「完了率 10% 以上」
                    「上記未満（完了率 10% 未満）、または未実施」
```

★★危ない★★: ★`lib/judge/grade.ts` の比較（`>=`）と文言を食い違わせないこと★
（`SystemScreen.tsx:87-88`）。★旧規則（「超」/ 下位の判定）の文言に戻さないこと★（`:27`。D-020b は破棄）。

## 10.4 縦線③ エスカレーション閾値

```
【設定】   lib/config/index.ts:27-32
           export const ESCALATION_THRESHOLDS = { level2Days: 2, level3Days: 3 } as const  ★構造由来★
   ↓ 直接 import
【画面】   SystemScreen.tsx:100-103
           const ESCALATION_RULES: { level: AdviceLevel; days: number }[] = [
             { level: 2, days: ESCALATION_THRESHOLDS.level2Days },
             { level: 3, days: ESCALATION_THRESHOLDS.level3Days },
           ];
           :317-328  <span className={`${styles.levelBadge} ${LEVEL_CLASS[rule.level]}`}>Lv{rule.level}</span>
                     <dd>{t("{n}日以上連続").replace("{n}", String(rule.days))}</dd>
           LEVEL_CLASS（:72-76）: 1→levelInfo / 2→levelWarn / 3→levelCritical
           :330-334 注記「『連続』はカレンダー日ではなく、清掃スケジュールが存在する日の連続で
                          数えます。清掃予定のない日は連続を途切れさせません。」
【値の例】 ★構造由来★ 「Lv2 / 2日以上連続」「Lv3 / 3日以上連続」
```

## 10.5 縦線④ ★★連続日数の表示上限（この画面で唯一編集できる設定）★★

### 読み取りの縦線

```
【既定値】 lib/config/index.ts:133-140
           export const CONSECUTIVE_DAYS_DISPLAY = {
             defaultMax: 30,
             minMax: ESCALATION_THRESHOLDS.level3Days,   ← ★= 3。L3 の日数を下回らせない★
             maxMax: 365,                                 ← ★実データ保持2年の半分（:126-129）★
           } as const                                     ★構造由来★
           ★単位は「スケジュール存在日」。カレンダー日ではない★（:131。D-026）
   ↓ 保存済みの上書き
【ストア】 lib/settings/display-limits.ts
           保存先: data/settings/display-limits.json（:49-51 defaultDisplayLimitStorePath()）
           型: interface DisplayLimitOverrides { consecutive_days_max?: number }（:35-37）
               ★編集されたキーだけが入る★（:34）
   ↓ 検証（★読み込み時にも通す★）
【検証】   lib/settings/display-limits.ts:147-174
           export function validateConsecutiveDaysMax(value: unknown): ValidationOutcome
           type ValidationOutcome = { ok: true; value: number } | { ok: false; reason: string }（:130-132）
             typeof value !== "number"   → "数値で指定してください"
             !Number.isFinite(value)     → "有限の数値で指定してください"   ← ★isInteger より先に見る（:151-153）★
             !Number.isInteger(value)    → "整数で指定してください"
             value < minMax(3)           → "3以上で指定してください"
             value > maxMax(365)         → "365以下で指定してください"
           ★Number.isInteger(1e9) は true★ なので巨大値は範囲で弾く（:141-142）
           ★reason に入力値をエコーしない★（:144-145）
   ↓ 読み込み
【読込】   lib/settings/display-limits.ts:223-237
           export async function loadConsecutiveDaysMax(): Promise<number>
           ★次の4つすべてで既定値に倒れる（例外にしない）★（:213-221）:
             1. ファイルが無い / 2. JSON 破損（.bak へ退避して空）/
             3. キーが無い / 4. ★値が不正（文字列・小数・範囲外）★
           4 のときは settings.value_invalid を warn で1行残す（:183-202）
   ↓ 1件に組み立て
【組立】   lib/settings/display-limits.ts:245-256
           async function consecutiveDaysMaxSetting(): Promise<DisplayLimitSetting>
             { key: "consecutive_days_max", value, default: 30, min: 3, max: 365,
               customized: value !== CONSECUTIVE_DAYS_DISPLAY.defaultMax }
             ★不正な値で既定値に倒れたときは customized にしない★（:253-254）
   ↓ API
【API】    app/api/settings/display-limits/route.ts:97-102
           GET → NextResponse.json({ items: await loadDisplayLimitSettings() })
           ★force-dynamic は付けていない（Route Handlers は既定でキャッシュされない。:27-36）★
   ↓ 型
【型】     DisplayLimitSetting（lib/types/index.ts:413-425）
             key: DisplayLimitKey（= "consecutive_days_max"。:403）
             value: number / default: number / min: number / max: number / customized: boolean
   ↓ 表示
【画面】   SystemScreen.tsx:62  const CONSECUTIVE_DAYS_MAX_KEY = "consecutive_days_max";
           :149-169 fetchDisplayLimit() → body.items.find(i => i.key === KEY) ?? null
           :159 setLimitDraft(item === null ? "" : String(item.value))
           :359-376 <input type="number" value={limitDraft} min={limit.min} max={limit.max} step={1} />
                    ★min / max を数値で直書きしないこと★（:363-364）
           :341-343 {limit?.customized && <span className={styles.customizedBadge}>編集済み</span>}
【値の例】 ★構造由来★ 未保存 → { value: 30, default: 30, min: 3, max: 365, customized: false }
                    → 入力欄「30」、★「編集済み」バッジ無し★、★「既定値に戻す」ボタンも出ない★（:386-387）
           ★構造由来★ 60 を保存済み → { value: 60, ..., customized: true } → バッジ「編集済み」+ リセットボタン
           ★構造由来★ ファイルに "abc" が手で書かれていた → ★value=30 / customized=false に倒れ、
                    settings.value_invalid が warn に1行出る★
           ★構造由来★ data/settings/display-limits.json は ★現時点で存在しない★（未編集。2026-09-17）
```

### 保存の縦線（★書き込み経路がメインDBから分離されていること★）

```
画面  SystemScreen.tsx:216-236  saveLimit()
        :221-225  raw === "" → ★送らない★（"日数を入力してください"）
                  ★Number("") === 0 なので「未入力」を「0を指定した」にすり替えない★（:219-220）
        :226-232  value === limit.value → ★叩かない★
        :233-235  ★画面側では範囲を見ない★（検証の正はサーバ。<input min max> は開発者ツールで外せる）
      :185-214  patchLimit(payload: { value: number } | { reset: true })
        apiFetch<DisplayLimitSetting>("/api/settings/display-limits",
          { method: "PATCH", headers: {...}, body: JSON.stringify({ key: limit.key, ...payload }) })
        :200-201 ★レスポンスの値で state を差し替える（送った値ではない。:179-181）★
   ↓
API   app/api/settings/display-limits/route.ts:104-162
        :122-125 isDisplayLimitKey(body.key) が false → 422
        :133-136 body.reset === true → resetDisplayLimit(key) → settingFor(key)
                 ★reset を value より先に見る★（:132）
        :138-146 validateDisplayLimit(key, body.value) → !ok なら 422 { value: [reason] }
        :148-149 saveDisplayLimit(key, outcome.value) → settingFor(key)
        :150-160 例外 → 500（★err.message を本文に載せない。保存先の絶対パスが漏れる。:151★）
   ↓
ストア lib/settings/display-limits.ts:332-339
        const DISPLAY_LIMIT_HANDLERS: Record<DisplayLimitKey, DisplayLimitHandler> = {
          consecutive_days_max: { validate, save, reset, setting }
        }
        ★★キーを知っているのはこの表だけ★★（:312-330）
        :266-279 saveConsecutiveDaysMax(value) → ★保存前にも検証★（:261-264）
        :282-289 resetConsecutiveDaysMax() → ★キーごと消す（既定値をコピーして持たない）★
```

### ★★危ない: キーを2つ目に増やすとき★★

`Record<DisplayLimitKey, DisplayLimitHandler>` にしてあるのは
★「キーを足したのに処理を足していない」をコンパイルエラーにするため★（`:314-320`）。
以前の形（ルート側に `KNOWN_KEYS` を持ち、保存は `consecutive_days_max` 固定）では、
★2つ目のキーへの PATCH が `consecutive_days_max` を書き換える★（`:322-327`。★例外もテスト失敗も出ない★）。

★`isDisplayLimitKey()` は `hasOwnProperty` で見る★（`:348-353`）—
`key in obj` だと `"toString"` / `"constructor"` が「知っているキー」になる。

### ★表示上限は判定の閾値ではない★

★L1〜L3（`lib/judge/escalation.ts`）と R1〜R4（`lib/judge/advice.ts`）は
上限をかける前の生値で判定する★（`lib/settings/display-limits.ts:22-24`。`CLAUDE.md` §4.3 と同じ思想）。
★だからこれだけ編集を許した★（`SystemScreen.tsx:11-14`）。

## 10.6 縦線⑤ アドバイス文言

```
【既定値】 lib/config/advice-rules.ts
           type AdviceRuleId = "R1" | "R2" | "R3" | "R4" | "B1" | "B2" | "B3"（:16）★構造由来★
           interface AdviceRuleDefinition {
             id: AdviceRuleId; phase: "A" | "B"; level: 1|2|3; icon: string;
             title: string; text: string; actions: string[]; condition: string; }（:19-31 付近）
           DEFAULT_ADVICE_RULES（:35-）
   ↓ 上書き
【ストア】 lib/settings/advice.ts
           保存先: data/settings/advice.json（:44-46）
           型: export type AdviceOverrides =
                 Partial<Record<AdviceRuleId, Partial<EditableAdviceFields>>>（:33-35）
               ★編集された項目だけが入る★（:32）
   ↓ 合成
【合成】   lib/settings/advice.ts:110-123
           export async function loadAdviceRules(): Promise<Record<AdviceRuleId, AdviceRuleDefinition>>
             merged[id] = ov ? { ...base, ...ov } : base;
           ★未編集のルールは lib/config の既定値がそのまま使われる★（:23-26）—
             既定値を後から改善したときに編集していないルールへ自動で反映される
   ↓ API
【API】    app/api/settings/advice/route.ts:35-56
           GET → { items: AdviceSetting[] }
           :48-52 customized の計算:
             r.title !== DEFAULT.title || r.text !== DEFAULT.text
             || JSON.stringify(r.actions) !== JSON.stringify(DEFAULT.actions)
   ↓ 型
【型】     AdviceSetting（lib/types/index.ts:381-394）
             rule: AdviceRuleId / phase: "A"|"B" / level: 1|2|3 / icon: string /
             title: string / text: string / actions: string[] / condition: string / customized: boolean
           ★編集できるのは title / text / actions のみ★（:379）
   ↓ 表示
【画面】   SystemScreen.tsx:112-127 fetchAdvice() → apiFetch<AdviceListResponse>("/api/settings/advice")
           AdviceListResponse { items: AdviceSetting[] }（:47-49。★YAML に名前が無いので画面に持つ★）
           :66-70  LEVEL_LABEL = { 1:"注意", 2:"警告", 3:"重大" }
           :78-81  PHASE_LABEL = { A:"Phase A（完了率・連続日数）", B:"Phase B（状態・エラー種別）" }
           :438-473 カードの構成:
             :441-443 icon / :444 rule / :445-447 levelBadge / :448 phaseBadge /
             :449-451 customized バッジ / :453 title / :454 text /
             :456-463 actions の <ul> / :465-471 condition + ★編集不可バッジ★
【値の例】 ★構造由来★ R1: icon "⚠" / level 1 /「注意」/ Phase A /
                    title "出発失敗（R1）"（lib/config/advice-rules.ts:40）/
                    text "事前チェックのアラート内容を確認してください。セキュリティ未解除/
                          ロボット異常/充電不足のいずれかが原因です。"（:41）/
                    actions 3件（:42-46）/ condition "出発失敗（初回）"（:47）/ customized false
           ★構造由来★ R2: condition "完了率が10%未満"（:61）/ R3: level 2 / icon "🔶"（:66-67）/
                    R4: level 3 / icon "🔴"（:80-81）
           ★構造由来★ data/settings/advice.json は ★現時点で存在しない★（未編集。2026-09-17）
                    → ★全7件の customized が false★
```

★★危ない★★: `lib/config/advice-rules.ts` を画面から直接 import すると
★既定値しか出せず、運用設定で編集済みの文言が反映されない★（`SystemScreen.tsx:30-32`）。
★必ず `GET /api/settings/advice` を通すこと★。

★★注意★★: 画面に ★文言の編集UIは無い★（`SystemScreen.tsx:420-476` は表示のみ）。
`PATCH /api/settings/advice`（`app/api/settings/advice/route.ts:58-`）は実装済みだが、
★呼び出す画面がまだ無い★。

## 10.7 ★3つのJSONストアの関係★

| ストア | ファイル | 型 | 使う画面 |
|---|---|---|---|
| D-Sデバイス備考 | `data/settings/ds-device-notes.json` | `Record<string, string>`（`lib/settings/ds-device-notes.ts:44`） | ★F-06★ |
| アドバイス文言 | `data/settings/advice.json` | `AdviceOverrides`（`lib/settings/advice.ts:33-35`） | ★F-10★ |
| 表示上限 | `data/settings/display-limits.json` | `DisplayLimitOverrides`（`lib/settings/display-limits.ts:35-37`） | ★F-10★ |

共通の土台は `lib/settings/json-store.ts`（`createJsonStore<T>({ filePath, empty, onWarn })`）:
★アトミック差し替え（rename）★ / ★破損時は上書きせず `.bak` へ退避して空を返す★ /
★`Object.create(null)` を `empty` に使ってプロトタイプ汚染を防ぐ★。

★3つとも「全件読み込み → 1件差し替え → 書き戻し」★
（`ds-device-notes.ts:142-153` / `advice.ts:133-136` / `display-limits.ts:273-276`）—
★だから上限が要る★（`lib/config/index.ts:499-503` / `:509-512`）。

★`/data` は `.gitignore:50` で除外★＝ ★コミットされず git のバックアップ対象外★。
★唯一の実体がサーバ上のファイルなので、破損時に上書きしないことが前提になる★
（`lib/settings/ds-device-notes.ts:20-22`）。

★ロックは入れていない★（同 `:24-28`）— 施設20件弱・実質1人の運用のため。
★同時保存は後勝ち★。★利用者が増えたら `json-store.ts` 側にロックを入れること★。

---

# 付録A. ★★追っていて見つけた矛盾・危ない箇所★★

| # | 内容 | `file:line` | 深刻度 |
|---|---|---|---|
| A-1 | ★`lib/fixtures/ds-devices.ts` の施設12 の名前が施設マスタと違う★ — ds-devices は「メガバリューマート緑野第二店」、facilities は「フレッシュタウン湖畔店」 | `lib/fixtures/ds-devices.ts:111` / `lib/fixtures/facilities.ts:264` | ★既知★（D-090 項1。`docs/decisions.md:3429`）。fixtures 内の表示だけの不整合 |
| A-2 | ★`lib/fixtures/ds-alerts.ts` の施設12 も同じ食い違い★ | `lib/fixtures/ds-alerts.ts:145` | ★既知★（同上） |
| A-3 | ★fixtures の施設12 はロボット0台なのに、スケジュール id33 が存在する★ | `lib/fixtures/facilities.ts:278` vs `lib/fixtures/schedules.ts:210-219` | ★★潜在★★ — fixtures 経路は `robots[0]` を通らないので今は落ちないが、Prisma 経路と同じ構造にしたら `lib/db/index.ts:1684-1688` の例外を踏む |
| A-4 | ★id27 の `end_time` が fixtures（`"00:30"`）と DB（`"翌00:30"`）で違う★ | `lib/fixtures/schedules.ts:141` / `docs/decisions.md:2888-2892` | ★意図的★（D-086 項10。DB版が正）だが ★`USE_FIXTURES` を切り替えると表示が変わる唯一の箇所★ |
| A-5 | ★`clean_schedules.contract` という第2の契約列がある★ — 型も既定値も `facilities.contract` と同一 | `prisma/schema.prisma:563` | ★★危険★★ — アプリは読んでいない（正しい）が、★名前と型が同じなので取り違えやすい★。`CLAUDE.md` §4.6 は `customers.contract` にしか言及していない |
| A-6 | ★「未対応」の対訳が2つの意味で衝突★ — F-07 の「まだ対応していない」と F-08 の「非対応機種」が同じ i18n キー | `lib/i18n.ts:161`（`未対応: "Unsupported"`）/ `DsAlertsScreen.tsx:289` / `FacilitiesScreen.tsx:180` | ★判断済み（直さない）★（D-143 §3 #1）。★英語を配布する時点で必ず壊れる★ |
| A-7 | ★D-Secログとアラートで「異常」の色基準が違う★ — ログの異常＝赤、アラートの通信断＝アンバー。実データでは通信断しか出ないので ★アラート画面に赤が1色も出ない★ | `DsLogScreen.tsx:36-39` vs `DsAlertsScreen.tsx:36-38` / `docs/decisions.md:11423-11424` | ★判断済み（アンバー維持）★（D-143 §3 #5） |
| A-8 | ★`controls.status` が 0/1 以外だと一覧全体が 500★ | `lib/db/ds-status.ts:23-25` → `lib/db/index.ts:1464` | ★意図的★（握りつぶさない）だが ★被害範囲は F-06 の全滅★ |
| A-9 | ★心拍（`control_reports`）が0件の制御盤が1台でもあると F-06 の一覧全体が 500★ | `lib/db/index.ts:1451-1458` | ★意図的★。★備考保存の経路だけは `dsDeviceExists()` に分離済み★（指示書AH 段階2） |
| A-10 | ★`section` にロボットが0台のスケジュールが1件でもあると F-09 の一覧全体が 500★ | `lib/db/index.ts:1684-1688` | ★意図的★（P1 は1清掃エリア=1ロボット前提）。★シードが施設12 を構造的に除外することで回避している★ |
| A-11 | ★`formatEndTime()` は30時以上で例外を投げる★ — DB の値域は24〜29 なので、★`"3000"` が1件でも入ると F-09 が 500★ | `lib/format/schedule.ts:60` | ★意図的★。★ただし値域の根拠は TeamWorks 回答 B-5 のみ★ |
| A-12 | ★`facilities.email` は `VarChar(100)` だが `facility_contacts.email` は `varchar(255)` のまま★ | `prisma/schema.prisma:60-61` / `:136` | ★申し送り★（2026-09-09 現行DDL） |
| A-13 | ★`PATCH /api/settings/advice` は実装済みだが呼び出す画面が無い★ | `app/api/settings/advice/route.ts:58-` vs `SystemScreen.tsx:420-476`（表示のみ） | ★意図的★（`SystemScreen.tsx:6-9`。編集できるのは表示上限1つだけ） |
| A-14 | ★`DsAlertsScreen` の `<Pagination>` は `items.length === 0` のときもDOMに置かれる★（部品側が `null` を返して回避） | `DsAlertsScreen.tsx:301-306` / `components/ui/Pagination.tsx:61-63` | ★問題なし★（部品側で担保）。★ただし F-06 / F-08 / F-09 は `items.length > 0` の内側に置いており、配置が揃っていない★ |
| A-15 | ★`DsDeviceNoteResponse` と `AdviceListResponse` / `DisplayLimitListResponse` が `lib/types` に無い★ — YAML にインライン定義で名前が無いため | `DsLogScreen.tsx:54-57` / `SystemScreen.tsx:47-53` | ★意図的★（`CLAUDE.md` §9.2「型は YAML のスキーマ名と揃える」の裏返し）。★YAML に名前を付けたら移すこと★ |

# 付録B. ★値の出所の内訳★

本文書に載せた具体値の出所は次の3種類だけ。★本番DBへの接続は一切行っていない★（D-148 項5）。

| 出所 | 件数の目安 | 内訳 |
|---|---|---|
| ★構造由来★ | 最多 | `prisma/schema.prisma`（列の型・既定値・値域）/ `lib/config/index.ts`（閾値・上限・対応表）/ `lib/types/index.ts`（union の値）/ `lib/format/*`（変換の入出力） |
| ★シード由来★ | 多数 | `lib/fixtures/ds-devices.ts`（12件）/ `ds-alerts.ts`（12件）/ `facilities.ts`（12件）/ `schedules.ts`（13件） ★すべて当方のサンプルデータ★ |
| ★記録済み実測★ | 9件 | `docs/handoff/テストDBのデータ出所_20260917.md:84`（`clean_schedules` 21行）/ `:87`（`robots` 14行）/ `:89`（`control_reports` 14行）/ `:90`（`facilities` 12行）/ `:91`（`controls` 11行）/ `:92`（`control_error_histories` 5行）/ `:94-96`（`customers` / `groups` / `devices` 各1行）。★いずれも2026-09-17 の記録で、出所は「当方のシード由来」★ |
| ★先方投入★ | ★0件★ | ★先方由来の行は1行も見つかっていない★（同 `:98`） |

# 付録C. ★追いきれなかったもの★

| # | 何 | 理由 |
|---|---|---|
| C-1 | ★`security_off` 側の D-Secアラートの取得元★ | ★DBに対応する列が存在しない★（`lib/db/index.ts:1519-1531`。D-090 項3-9 / 3-10）。★第3歩で扱う★と決まっており、現時点で追える縦線が無い |
| C-2 | ★`DsAlert.section_name` の実DBでの妥当性★ | Prisma 側は `sections.section_name` を読んでいる（`lib/db/index.ts:1552`）が、★fixtures 側は「暫定のダミー値」と明記されている★（`lib/types/index.ts:865-868` / `lib/fixtures/ds-alerts.ts:15-17`）。★どちらが仕様として正しいかの記録が見つからなかった★ |
| C-3 | ★`responded_at` / `resolved_at` が画面に出る経路★ | 型（`lib/types/index.ts:873-874`）と Prisma（`lib/db/index.ts:1556-1557`）にはあるが、★F-07 の画面に描画箇所が無い★。★更新系（`PATCH /ds-alerts/{id}/status`）が未実装★（`app/api/ds-alerts/route.ts:7-9`）なので、表示だけ先に作る判断がされた形跡も見つからなかった |
| C-4 | ★`d-watch-api.yaml` の該当スキーマ定義の行番号★ | 本文書は TS の型（`lib/types/index.ts`）を正として追った。★YAML 側の行番号は本文書では検証していない★（YAML は読み取り専用で、行番号の実測に時間を要するため） |
| C-5 | ★本番DBでの実際の `contract` の値の分布★ | ★本番DBに接続しないため★（D-148 項5）。★記録上は「`contract` を含む全列が `lib/fixtures/facilities.ts` と一致」★（`docs/handoff/テストDBのデータ出所_20260917.md:90`）とあるので、★4パターンすべてが入っていると推測されるが、推測なので値としては載せていない★ |
| C-6 | ★`json-store.ts` の内部実装（アトミック差し替え・`.bak` 退避）の行番号★ | 3ストアの共通土台として言及したが、★本文書の主題（画面の縦線）から外れるため行単位では追っていない★ |
