# データの流れ — 画面1〜5（2026-09-17）

対象は ★ダッシュボード（F-01） / 清掃詳細ログ（F-02） / アラート（F-03） / レポート（F-04） /
清掃面積（D-141）★ の5画面です。
残り5画面（D-Secログ / D-Secアラート / 施設ロボットマスタ / スケジュール / システム管理）は
★別の文書の担当★で、本書には含みません。

---

## 0. この文書の読み方

### 0.1 何のための文書か

「この列の数字はどこから来て、途中でどの関数を通り、どんな形で画面に出るのか」を
★実物の値と型つき★で1本の縦線として追えるようにしたものです。

各列は次の順で並べます。

```
【DBの列】 テーブル.カラム
           型 / NULL可否（prisma/schema.prisma:NNN）
   ↓ 読む
【取得】   読み出している場所（file:line）と、そこに書かれている式
   ↓ 型
【型】     API の型（lib/types/index.ts:NNN）
   ↓ API   エンドポイントとレスポンスキー（d-watch-api.yaml:NNN）
   ↓ 整形
【整形】   表示整形の純粋関数（file:line）とシグネチャ
   ↓ 表示
【画面】   実際に描いている行（file:line）
【値の例】 出所つきの実物の値
```

### 0.2 ★値の出所の凡例★（`CLAUDE.md` §6-6）

本書に出る具体的な値には、必ず次の3つのどれかの印を付けます。
★印の無い数字は本書には書きません★。

| 印 | 意味 | 出所 |
|---|---|---|
| ★シード由来★ | ★当方が作ったサンプルデータの値★ | `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.md` に日付つきで記録済みの実測値★ | 決定番号と日付を併記する |

★「実測」と書くときは出所を必ず併記する★（`CLAUDE.md` §6-6）。
出所を書けない数字は「実測」と呼びません。

### 0.3 ★★本番DBに入っているデータは当方のダミーです★★

- ★`133.125.238.144` / `clean_watch` は **本番DBそのもの**★（★D-148★。2026-09-17 ユーザー確認）。
  ★検証用の別DBではありません★。過去の文書が「テストDB」「テスト環境」と呼んでいるのは誤りです。
- ★★そこに現在入っている行は、1行残らず当方の `scripts/seed-local-db.ts` 由来★★（★D-145★。
  2026-09-17）。★先方由来なのは器（スキーマ・索引・型・サーバー変数・権限）だけ★です。
  `customers` / `groups` には当方の★旧版シードの文字列（`テスト顧客001` / `テストグループ001`）★
  まで残っていました。
- ★★したがって、本書は本番DBに接続していません★★。値はすべて ★fixtures（シード由来）★ か
  ★型・DDL（構造由来）★ か ★既に記録済みの実測値★ から採っています。
- ★本番DBの行数・分布・サマリバッチの実挙動を、本書の数字から推し量らないこと★
  （D-111 項7 / D-145 / D-148）。

### 0.4 ★共通の骨格★（5画面すべてに効く）

```
ブラウザ（components/features/…）
   ↓ apiFetch()（lib/api/client.ts）
自アプリの API ルート（app/api/…/route.ts）
   ↓ db.xxx()
データ層（lib/db/index.ts の `db`）
   ↓ USE_FIXTURES で分岐（lib/db/index.ts:2476）
   ├─ fixtureSource（lib/db/index.ts:426〜）… lib/fixtures/*.ts を読む
   └─ prismaSource（lib/db/index.ts:655〜）… Prisma で MariaDB を読む
```

- `lib/db/index.ts:2476` — `export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;`
- ★判定（`lib/judge/`）は切替の外側★ではなく ★両方の実装が同じ関数を通る★形になっています
  （`CLAUDE.md` §9.5 / D-044 / D-090 項16）。fixtures でも本番同等の判定が走ります。
- ★画面は判定をやり直さない★（`CLAUDE.md` §9.6）。◎○△×・L1〜L3・並び順は API が返した値を
  そのまま描きます。★画面がやるのは「丸め」と「日本語の組み立て」だけ★です。

### 0.5 ★事故りやすい変換の境目（本書で繰り返し出てくる4つ）★

| # | 事故 | 何が起きるか | 本書で出る場所 |
|---|---|---|---|
| 1 | ★`??` を `\|\|` にする★ | `0` が `null` に化ける（`clean_error` の「正常」、面積の 0） | 1章・2章・5章 |
| 2 | ★丸めてから判定する★ | 89.5 が ◎ になる／90.4 の ◎ が消える | 2章・4章 |
| 3 | ★`null` と `[]` を混同する★ | 「0件ヒット」が「全件表示」に化ける（Prisma の `in: undefined`） | 全章 |
| 4 | ★DBの DATETIME を「真の瞬間」と取り違える★ | 9時間ずれて境界の行が消える | 全章 |

★どれも例外もテスト失敗も出ません★。数字が静かに変わるだけです。

---

# 第1章 ダッシュボード（F-01）

- 画面: `app/(dashboard)/dashboard/page.tsx` → `components/features/dashboard/DashboardScreen.tsx`
- API: ★2本を並列に叩く★（`DashboardScreen.tsx:83-86`）
  - `GET /api/dashboard`（`app/api/dashboard/route.ts` / `d-watch-api.yaml:214`）
  - `GET /api/clean-logs?result=problem&date_from=当日&date_to=当日&per_page=5`
    （`DashboardScreen.tsx:77-82`）
- ★コンパクト版清掃詳細ログは `/dashboard` には含まれません★（`d-watch-api.yaml:218-220`）。
  ★`/clean-logs` を再利用する★ので、5列の中身は ★第2章と同じ縦線★ です。

## 1.1 `GET /api/dashboard` のクエリパラメータ（`d-watch-api.yaml:221-227`）

| キー | 型 | 既定 | 備考 |
|---|---|---|---|
| `date` | `string (date)` | ★当日（＝昨夜分）★ | `app/api/dashboard/route.ts:55` |
| `group_id` | `integer` | なし | `parseMasterIdFilters()`（`app/api/dashboard/route.ts:61`） |
| `customer_id` | `integer` | なし | 同上。`group_id` と同時指定は AND |

★★`route.ts:55` は `dateRaw || todayJst()` であって `??` ではありません★★ —
★`?date=`（値が空）だと `""` は `null` でも `undefined` でもないので `??` では既定に倒れず★、
`dbDateRange("", "")` で `start_at` の条件ごと消えて ★テーブル全件読みに戻ります★。
★ここだけは `||` が正解★です（事故#1 の向きが逆になる唯一の箇所）。

## 1.2 数値カード「成功 / 失敗 / 未実施 / 成功率」

```
【DBの列】 clean_status_histories.clean_error
           型: Int @default(0) @db.UnsignedTinyInt（NOT NULL）（prisma/schema.prisma:487）
           意味: 0=正常 / 1=異常(清掃未実施) / 2=異常(セキュリティ有効) /
                 3=異常(D-S制御盤異常) / 4=異常(清掃ロボット異常)（lib/config/index.ts:263-274）
           ★unsigned tinyint なのでコード表に無い 5〜255 も入りうる★
   ↓ 読む
【取得】   lib/db/index.ts:896   const history = historyByTimetableId.get(key(row.id));
           lib/db/index.ts:909   departed: !notExecutedFromHistory(history),
           lib/db/index.ts:918   completion_rate: completionRateFromHistory(history),
   ↓ 判定
【判定】   lib/db/clean-log-mapping.ts:152-157
             notExecutedFromHistory(history: CleanResultLike | undefined): boolean
             → 履歴の行が無い（undefined）  … true（＝出発失敗）
             → clean_error === 1           … true
             → 0 / 2 / 3 / 4 / 5以上        … false（＝出発した扱い）
           lib/db/clean-log-mapping.ts:184-192
             completionRateFromHistory(h: (CleanResultLike & {clean_ratio:number}) | undefined): number | null
             → 行なし / clean_error===1 … null
             → それ以外                 … clean_ratio の生値（丸めない）
           lib/judge/grade.ts:29-35  judgeGrade(rate: number | null): Grade
   ↓ 集計
【集計】   lib/dashboard/aggregate.ts:31-59
             computeSummary(logs: CleanLogItem[]): DashboardResponse["summary"]
             - !departed                 → not_executed++
             - grade が ◎ or ○           → success++
             - grade が △ or ×           → failed++
             - success_rate = 分母0なら null、でなければ success / 分母 * 100（★丸めない★）
   ↓ 型
【型】     DashboardResponse["summary"]（lib/types/index.ts:1081-1087）
             { success: number; failed: number; not_executed: number; success_rate: number | null }
   ↓ API
【API】    GET /dashboard の summary（d-watch-api.yaml:236-247）
   ↓ 整形
【整形】   lib/judge/grade.ts:43-46  displayRate(rate: number | null): string
             null → "-" / それ以外 → `${Math.round(rate)}%`
   ↓ 画面
【画面】   components/features/dashboard/DashboardScreen.tsx:231-257（StatCard 4枚）
             成功   : String(data.summary.success)  sub=`/ ${totalLogsToday}件`
             失敗   : String(data.summary.failed)   クリックで /detail-log へ
             未実施 : String(data.summary.not_executed)
             成功率 : displayRate(data.summary.success_rate)  sub=`目標: 95%`
           components/ui/StatCard.tsx:24-34
【値の例】 ★シード由来★（lib/fixtures/clean-logs.ts:94-107 の当日10件から）
             当日の boundaryCases は 90.1/90.0/89.5/70.1/70.0/10.1/10.0/9.9/0/null。
             判定は ◎◎○○○△△×××（未実施1件）。
             → success=5 / failed=4 / not_executed=1 / success_rate = 5/10*100 = 50 → 表示「50%」
           ★構造由来★: 目標値 95 は SUCCESS_RATE_TARGET（lib/config/index.ts:491）
```

★★`failed` が「出発した△×」に限られている理由★★（`lib/dashboard/aggregate.ts:28-30`）—
未出発の行を `not_executed` と `failed` の両方に数えると ★二重計上★ になるためです。
したがって ★`success + failed + not_executed` が当日の行数★ になり、
`DashboardScreen.tsx:205-207` の `totalLogsToday` がそれを計算しています。

★★「/ N件」の N は `schedule_progress.planned` とは別概念★★（D-049。`DashboardScreen.tsx:201-204`）。

## 1.3 スケジュール進捗カード

```
【DBの列】 clean_schedules.weeks   型: String @db.VarChar(7)（prisma/schema.prisma:556）
           clean_schedules.inactive 型: Int @default(0) @db.UnsignedTinyInt（:564）
   ↓ 読む
【取得】   app/api/dashboard/route.ts:93   db.schedules()（★引数を取らない契約★）
           app/api/dashboard/route.ts:124  filterRowsByFacilityIds(schedules, facilityIds)
   ↓ 型
【型】     Schedule.weekdays: string（7桁。日月火水木金土、清掃する曜日=1）
           Schedule.active: boolean（lib/types/index.ts:964-975）
   ↓ 集計
【集計】   lib/dashboard/aggregate.ts:84-103
             computeScheduleProgress(logs: CleanLogItem[], schedules: Schedule[], date: string)
               : DashboardResponse["schedule_progress"]
             planned   = schedules.filter(s => s.active && s.weekdays[dow] === "1").length
             completed = logs.filter(r => r.departed).length   ★「完了」ではなく「出発」★
             rate      = planned === 0 ? null : completed / planned * 100
           ★dow は lib/dashboard/aggregate.ts:67-72 の weekdayIndex() が Date.UTC で出す★
             （`new Date("YYYY-MM-DD").getDay()` は実行環境のTZで答えが変わるので使わない）
   ↓ 型
【型】     DashboardResponse["schedule_progress"]（lib/types/index.ts:1088-1095）
             { planned: number; completed: number; rate: number | null }
   ↓ 画面
【画面】   components/features/dashboard/ScheduleProgressCard.tsx:21-29
             rate === null → 「本日の清掃予定はありません」（★0% のバーを出さない★。D-049）
             それ以外      → `{completed} / {planned} 完了` + <ProgressBar percent={rate} />
           components/ui/ProgressBar.tsx:17-27
             percent が NaN / Infinity なら 0 にクランプ、0〜100 にクランプ
【値の例】 ★シード由来★: fixtures の `lib/fixtures/schedules.ts` の active な行のうち、
           当日の曜日ビットが "1" の件数が planned になる。
           ★構造由来★: weekdays は7文字固定（prisma/schema.prisma:556 の varchar(7)）。
```

★★`planned` をスケジュール由来にしている理由★★（`lib/dashboard/aggregate.ts:77-79` / D-049）—
清掃ログの行数を使うと、収集処理が止まって当日の行が0件のときに
★「本当に定休日」と「収集処理の障害」が区別できなくなる★。
食い違いは `app/api/dashboard/route.ts:138-147` が `logEvent("warn", { event: "data.anomaly" })` で
記録します（★原因の断定は書かない★。`docs/logging-design.md` §7）。

## 1.4 セキュリティバナー

```
【DBの列】 （D-Sアラート由来。★実DB上の `section_name` の取得元が未確定★ —
            lib/types/index.ts:865-870 の注記のとおり fixtures では暫定のダミー値）
   ↓ 取得
【取得】   app/api/dashboard/route.ts:94  db.dsAlerts()
           app/api/dashboard/route.ts:125 filterRowsByFacilityIds(dsAlerts, facilityIds)
   ↓ 集計
【集計】   lib/dashboard/aggregate.ts:113-123
             computeSecurityBanner(alerts: DsAlert[]): DashboardResponse["security_banner"]
             条件: alert_type === "security_off" && resolution_status !== "done"
             返すのは facility_name / section_name / since（= detected_at）の3つだけ
             ★detail / memo は載せない★（自由記述は §4 の記録禁止フィールド）
   ↓ 型
【型】     DashboardResponse["security_banner"]（lib/types/index.ts:1108-1112）
             Array<{ facility_name: string; section_name: string; since: string }>
   ↓ 整形
【整形】   lib/format/date.ts:15-17  formatHm(iso: string): string  → iso.slice(11, 16)
             ★Date に通さず文字列を切る★（ISO8601 +09:00 なので実行環境のTZに依存しない）
   ↓ 画面
【画面】   components/features/dashboard/SecurityBanner.tsx:22
             「⚠ セキュリティ無効: {facility_name} {section_name}（{formatHm(since)}以降）」
           ★0件なら null を返してバナーごと消す★（SecurityBanner.tsx:16）
           ヘッダの件数 Pill: DashboardScreen.tsx:217-219 → `D-Sec {n}件`
【値の例】 ★シード由来★: lib/fixtures/ds-alerts.ts の security_off 行。
           ★`2026-09-17T01:20:00+09:00` → formatHm → `"01:20"`★（構造由来の変換規則）
```

## 1.5 要レスキュー店舗カード（D-137 / 指示書AI）

★★モックに対応する画面がありません★★（`RescueRequiredCard.tsx:12-21`）。
ユーザー指示（2026-09-15）に基づく `CLAUDE.md` §5 の例外です。

```
【DBの列】 clean_timetables.start_at         DateTime @db.DateTime(0)（prisma/schema.prisma:589）
           clean_status_histories.clean_error Int UnsignedTinyInt（:487）
           clean_status_histories.clean_ratio Float（double(4,1)）（:497）
           clean_status_histories.clean_area  Float（double(12,6)）（:492）
           clean_status_histories.task_area   Float（double(12,6)）（:491）
   ↓ 読む
【取得】   app/api/dashboard/route.ts:101  db.rescueRequired({ date, facility_ids: facilityIds })
           prismaSource: lib/db/index.ts:1732〜 → loadConsecutiveInputs()（lib/db/consecutive-source.ts）
           fixtures  : lib/db/index.ts:542〜 → rescueInputsFromCleanLogs()（lib/db/rescue-mapping.ts:47-64）
   ↓ 中間型
【型】     ConsecutiveInput（lib/db/clean-log-mapping.ts:292-319）
             { id: number; facility_id: number; scheduled_at: string;
               completion_rate: number | null; clean_area: number; task_area: number }
           ★clean_area / task_area は履歴が無ければ 0★（★null にしない★。
             面積は「足し合わせる量」なので 0 を足すのが正しい。
             lib/db/consecutive-source.ts:199-201 / lib/db/rescue-mapping.ts:60-61）
           ★★`??` を `||` にしないこと★★ — 面積は 0 が正当な値（事故#1）
   ↓ 判定
【判定】   lib/db/rescue-mapping.ts:98-191  buildRescueRequired(inputs, facilityNames, date)
             1) date 以下で切る（:105。★date ちょうどで絞らない★ — R-1 は3日、R-3 は前回日が要る）
             2) 連続日数は computeConsecutiveProblemDays() の値を使う（:110。★数え直さない★。D-109）
             3) 施設ごとに judgeRescue() を呼ぶ（:140）
           lib/judge/rescue.ts:241-268  judgeRescue(input: RescueInput): RescueJudgement
             R-1 not_executed_streak : countNotExecutedStreak(days) >= RESCUE_THRESHOLDS.notExecutedDays（=3）
                                        （lib/judge/rescue.ts:177-184 / lib/config/index.ts:69-72）
             R-2 escalation_l3       : judgeEscalationLevel(consecutive) === "L3"（:193-195）
             R-3 completion_drop     : (前回 − 当日) ÷ 前回 >= COMPLETION_DROP.dropRatio（=0.2）
                                        （:219-228 / lib/config/index.ts:48-51）
           lib/judge/rescue.ts:117-125  facilityDayRate(rows): number | null
             = completionRate(Σclean_area, Σtask_area) ← ★面積加重。単純平均ではない★
             ★分母0は null★（lib/judge/kpi.ts:20-23）
   ↓ 型
【型】     RescueRequiredItem（lib/types/index.ts:1047-1072）
             { facility_id: number; facility_name: string; evaluated_on: string;
               reasons: RescueReason[]; not_executed_days: number;
               consecutive_problem_days: number; completion_rate: number | null;
               previous_completion_rate: number | null; drop_ratio: number | null }
           RescueReason = "not_executed_streak" | "escalation_l3" | "completion_drop"
             （★正は lib/judge/rescue.ts:34。lib/types は型ごと借りている（:11）★）
   ↓ API
【API】    GET /dashboard の rescue_required（★件数で切らない＝全件返す★。
           app/api/dashboard/route.ts:155-157 / lib/db/rescue-mapping.ts:81-89）
   ↓ 画面
【画面】   components/features/dashboard/RescueRequiredCard.tsx
             :91  rescueVisibility(items, expanded)（lib/dashboard/rescue-visibility.ts:37-47）
                  canToggle = items.length > RESCUE_INITIAL_VISIBLE（=5。lib/config/index.ts:102）
                  ★★「超える」ときだけ true。ちょうど5件なら出さない★★（>= にしない）
             :99  件数表示は ★items.length（絞る前の総数）★
             :112 facility_name
             :116-123 reasons を全部出す（★1つに丸めない★）
             :52-73 REASON_LABEL — ★日本語を作るのはこの層★（D-111）
                  not_executed_streak → t("{n}日連続で未実施").replace("{n}", not_executed_days)
                  escalation_l3       → t("連続問題レベル3（{n}日連続）")
                  completion_drop     → t("完了率が前回比{n}%低下")
                                        n = Math.round(drop_ratio * 100)（★表示のためだけに丸める★）
                                        drop_ratio === null なら「完了率が前回比で急落」
             :131 判定日 evaluated_on（★リクエストの date とは限らない★）
             :135-139 完了率 displayRate(completion_rate) ＋（前回 displayRate(previous_completion_rate)）
【値の例】 ★構造由来★: 未実施3日連続の閾値 3（RESCUE_THRESHOLDS.notExecutedDays）
           ★構造由来★: 急落の閾値 0.2（COMPLETION_DROP.dropRatio）→ 「20%低下」
           ★シード由来★: 前回 30% → 当日 18% は
             drop_ratio = (30-18)/30 = 0.4 → ラベルは「完了率が前回比40%低下」
             （★差で見ると「12ポイント」だが、ユーザーは比を選んだ★。lib/config/index.ts:37-44）
           ★記録済み実測★: ★件数上限を撤廃した理由は「実データで7件中2件が黙って消えていた」★
             （指示書AJ §1 / lib/config/index.ts:80-86。★ただしその「実データ」は当方のシード由来★。
              D-145 の訂正対象に含まれるため、★件数そのものを本番の規模の根拠に使わないこと★）
```

★★母集団の罠★★（`app/api/dashboard/route.ts:66-72` / 指示書AK §7-2）—
★数値カード・スケジュール進捗・セキュリティバナー・要レスキューの4つに ★同じ `facilityIds`★ を
渡すこと★。★どれか1つを渡し忘れると「この屋号の昨夜の成績」と「要レスキュー」が別集団の話になる★。
★例外もテスト失敗も出ない★ので `app/api/__tests__/master-filter-dashboard-reports.test.ts` で
固定してあります。

★★要レスキューだけ母集団が違う（意図）★★ — ★当日に清掃予定が無い施設も含む★
（`lib/types/index.ts:1113-1123` / `DashboardScreen.tsx:271-272`）ので、
`summary` の件数と一致しなくて構いません。

## 1.6 コンパクト版清掃詳細ログ（5列）

`DashboardScreen.tsx:284-294` が `CleanLogTable` に `compact` を渡します。

| 出す列 | 落とす列 |
|---|---|
| 施設名 / 予定 / 出発 / 完了率 / 判定 | 機種・シリアル・日付・1h前ST・30分前ST・清掃結果 |

`components/features/detail-log/CleanLogTable.tsx:24-38` にこの分担が書かれています。
★列の中身の縦線は第2章と同一★です（同じコンポーネントなので分岐は `compact` だけ）。
`colSpan` は `compact ? 5 : 11`（`CleanLogTable.tsx:168` / `:221`）。

★compact でも行は展開する★（`CleanLogTable.tsx:30-34` / D-143 §3 #6）。
展開時に `GET /api/clean-logs/{id}/advice` を叩きます（`DashboardScreen.tsx:124-126`）。

---

# 第2章 清掃詳細ログ（F-02）

- 画面: `app/(dashboard)/detail-log/page.tsx` → `components/features/detail-log/DetailLogScreen.tsx`
- API: `GET /api/clean-logs`（`app/api/clean-logs/route.ts` / `d-watch-api.yaml:339`）
- 付随: `GET /api/clean-logs/{id}/advice`（`:468`）/ `GET /api/clean-logs/{id}/areas`（`:524`）/
  `GET /api/clean-logs/export`（`:399`）

## 2.1 クエリパラメータの実物（`d-watch-api.yaml:343-385`）

| キー | 型 / enum | 既定 | 画面での出どころ |
|---|---|---|---|
| `date_from` | `string (date)` | 省略時は直近1日 | `DetailLogScreen.tsx:122`（初期値 `todayJst()`。`:60`） |
| `date_to` | `string (date)` | 同上 | `:123`（初期値 `todayJst()`。`:61`） |
| `group_id` | `integer` | — | `appendClassificationParams()`（`:130`） |
| `customer_id` | `integer` | — | 同上 |
| `facility_name` | `string`（部分一致・★記号と大小文字を無視★） | — | `:125`（300ms debounce。`:98-104`） |
| `model` | `string`（部分一致・大小文字のみ無視） | — | `:126` |
| `serial` | `string`（部分一致・大小文字のみ無視） | — | `:127` |
| `result` | `problem \| critical \| all` | `all` | `:124`（★画面の初期値は `problem`★。`:62`） |
| `page` | `integer` | 1 | `:132` |
| `per_page` | `integer` | 50（`PAGINATION.defaultPerPage`。`lib/config/index.ts:326`） | `:131` |

★日付範囲の上限は 731日★（`MAX_DATE_RANGE_DAYS`。`lib/config/index.ts:360`。★構造由来★）。
★730 ではなく 731 なのは、2年のあいだにうるう日が入りうるため★。
超過は 422（`lib/clean-logs/query.ts:107-117`）。
★上限がかかるのは `date_from` と `date_to` の **両方** が指定されたときだけ★。

422 の実物（`lib/clean-logs/query.ts:110-115`）:

```json
{ "error": {
    "code": "validation_failed",
    "message": "date_from〜date_toは731日以内で指定してください",
    "details": { "date_to": ["指定された範囲は900日です"] } } }
```

## 2.2 ルートの処理順（`app/api/clean-logs/route.ts`）

```
1. parseCleanLogQuery(searchParams)           :50   ← 422 の入口（lib/clean-logs/query.ts:80-150）
2. loadConsecutiveDaysMax()                    :61   ← 表示上限（読めなければ既定 30 で続行）
3. 施設名 → 施設ID / 分類 → 施設ID を並列解決   :78-80
   intersectFacilityIds(nameIds, masterIds)    :81   ★AND。片方を捨てない★
4. db.cleanLogs({ id: null, date_from, date_to, facility_ids })  :83-89
5. filterCleanLogs(rows, query)                :98   ★規則の「正」はここ。SQL はその写し★
6. ページ切り出し → capCleanLogs()             :105  ★上限は最後にかける★
7. { items, meta: { total, page, per_page } }  :107-110
```

★★`facility_ids` の `null` と `[]` は別物★★（★事故#3★）—
`null` = 絞らない / `[]` = 照合したが1件も当たらなかった（＝0件）。
`app/api/clean-logs/route.ts:75-77` と `lib/db/index.ts:729-736` の両方に注意書きがあります。
Prisma の `in: undefined` は ★条件が消えて `WHERE 1=1`★ になります。

## 2.3 列ごとの縦線

### (a) 施設名

```
【DBの列】 facilities.facility_name  String @db.VarChar(400)（prisma/schema.prisma:129）
   ↓ 読む
【取得】   lib/db/index.ts:739-749  include: { facility: true }
           lib/db/index.ts:901      facility_name: row.facility.facility_name
   ↓ 型
【型】     CleanLogItem.facility_name: string（lib/types/index.ts:205）
   ↓ 画面
【画面】   components/features/detail-log/CleanLogTable.tsx:185  <td>{r.facility_name}</td>
【値の例】 ★シード由来★ "メガバリューマート三ツ橋中央ショッピングセンター店"
           （lib/fixtures/clean-logs.ts:104。★長い施設名の表示崩れ確認用★）
```

★★`cleanLogs()` は `facility.deleted_at` で絞っていません★★（`lib/db/index.ts:673-678`）。
★論理削除された施設のログも返るのが現状の挙動★なので、
★施設名→IDの解決（`loadFacilityNameIndex()`）も `deleted_at` で絞りません★（★罠7★）。
絞ると ★その施設名で検索したときにログが静かに消えます★。

### (b) 機種

```
【DBの列】 devices.maker_id    Int @db.UnsignedTinyInt（prisma/schema.prisma:82）
           devices.model_name  String @db.VarChar(100)（:83）
   ↓ 読む
【取得】   lib/db/index.ts:866  const robot = row.section.robots[0];
           lib/db/index.ts:902  model_name: formatModelName(robot.device.maker_id, robot.device.model_name)
   ↓ 変換
【変換】   lib/db/clean-log-mapping.ts:27-29
             formatModelName(makerId: number, modelName: string): string
             → `${MAKER_NAMES[makerId] ?? String(makerId)} ${modelName}`
           MAKER_NAMES = { 1: "PUDU", 2: "Keenon" }（lib/config/index.ts:597-600）
           ★★未知の maker_id は String(maker_id) をそのまま出す★★
             （「不明」等に潰さない。対応表に足す必要があることに気づけるようにするため）
   ↓ 画面
【画面】   components/features/detail-log/CleanLogTable.tsx:186（compact では出さない）
【値の例】 ★構造由来★ maker_id=1, model_name="CC1" → "PUDU CC1"
           ★構造由来★ maker_id=9（表に無い）, model_name="X1" → ★"9 X1"★
           ★シード由来★ fixtures の既定値は "PUDU CC1"（lib/fixtures/clean-logs.ts:50）
```

★P1 は 1清掃エリア = 1ロボット★（`CLAUDE.md` §8）。★0台なら空文字を返さず例外★
（`lib/db/index.ts:867-871`。fail-loud）。

### (c) シリアル

```
【DBの列】 robots.serial_number  String @db.VarChar(20)（prisma/schema.prisma:231）
   ↓ 読む
【取得】   lib/db/index.ts:903  serial_number: robot.serial_number
           ★robots は where: { deleted_at: null } で絞っている★（lib/db/index.ts:744）
   ↓ 画面
【画面】   CleanLogTable.tsx:187
【値の例】 ★シード由来★ "8111R5628060011"（lib/fixtures/clean-logs.ts:95）
```

★検索の照合規則★（`lib/clean-logs/query.ts:163-178`）:
- ★施設名だけ `includesFoldName()`★ — ★記号（中点・長音・空白・ハイフン類）と大小文字を無視★
- ★機種・シリアルは `includesFoldPlain()`★ — ★大小文字のみ★。★記号は除かない★
  （除くと `PUDU-CC1-0001` が `10001` でヒットし、★桁の境界が消える★。指示書AG D-2）

### (d) 日付 / 予定（`scheduled_at`）

★★ここが事故#4 の本丸です★★。

```
【DBの列】 clean_timetables.start_at  DateTime @db.DateTime(0)（prisma/schema.prisma:589）
           ★中身は「日本時間の壁時計値」★（TeamWorks 第8回 A2。2026-09-11 受領）
   ↓ 読む
【取得】   lib/db/index.ts:906  scheduled_at: jstIsoFromDb(row.start_at)
   ↓ 変換
【変換】   lib/format/date.ts:95-97   jstIsoFromDb(date: Date): string  = toJstIso(fromDbJst(date))
           lib/format/date.ts:67-69   fromDbJst(date: Date): Date
             = new Date(date.getTime() - DB_DATETIME_OFFSET_MINUTES * 60_000)
             DB_DATETIME_OFFSET_MINUTES = 9 * 60（lib/config/index.ts:193）
           lib/format/date.ts:37-46   toJstIso(date: Date): string  → "YYYY-MM-DDTHH:mm:ss+09:00"
           ★★2段を1つの式に潰さないこと★★（lib/format/date.ts:88-90）—
             潰すと「DBのタイムゾーン」という設定値が式から消え、lib/config を変えても効かなくなる
           ★性質★: 戻り値の桁は ★DBの生値そのもの★。
             DB が `2026-09-11 02:00:00` なら `"2026-09-11T02:00:00+09:00"`
   ↓ 型
【型】     CleanLogItem.scheduled_at: string（ISO8601 +09:00）（lib/types/index.ts:209）
   ↓ 整形
【整形】   lib/format/date.ts:8-12   formatMd(iso): string  → iso.slice(5,7)/iso.slice(8,10) を数値化
                                     "2026-08-27T02:00:00+09:00" → "8/27"
           lib/format/date.ts:15-17  formatHm(iso): string  → iso.slice(11,16) → "02:00"
   ↓ 画面
【画面】   CleanLogTable.tsx:188  日付   {formatMd(r.scheduled_at)}
           CleanLogTable.tsx:189  予定   {formatHm(r.scheduled_at)}
【値の例】 ★シード由来★ at(0,"02:00") → その日の "…T02:00:00+09:00" → 日付 "9/17" / 予定 "02:00"
```

★★なぜ補正が要るのか★★（`lib/format/date.ts:51-62`）—
Prisma の MariaDB アダプタは ``new Date(`${value}Z`)`` で ★UTC とみなして★ `Date` にします
（設定で変更できない）。補正しないと `Date` は ★真の瞬間より9時間先★ を指し、
`isFutureSchedule()` の比較が狂って ★深夜2時の清掃が翌朝の確認時刻でも「まだ未来」と判定されて
画面から消えます★（「翌朝確認型」の運用を直撃する）。

★★逆向き（クエリの条件）も同じ★★:

```
lib/db/date-range.ts:50-67
  dbDateRange(from: string | null, to: string | null): { gte?: Date; lt?: Date }
    range.gte = toDbJst(parseDate(from))
    range.lt  = toDbJst(parseDate(to) + 1日)   ★to は「その日を含む」ので翌日 00:00 未満★
  ★lte: その日 23:59:59 にしないこと★（DATETIME の桁が増えたとき 23:59:59.5 が静かに落ちる）

  parseDate("2026-09-08")          = 2026-09-07T15:00:00.000Z（真の瞬間）
  toDbJst(parseDate("2026-09-08")) = 2026-09-08T00:00:00.000Z（DBの壁時計値として渡せる形）
```

★★ローカルDBではこの間違いが1件も表に出ません★★（`lib/db/date-range.ts:19-24`）—
★記録済み実測（指示書AC C-6。★当方のシード由来★）★: `clean_timetables` 1,044行の `start_at` は
★0時:210 / 1時:516 / 2時:288 / 3時:30 で、15時以降が0件★。
9時間ずらしても日付が変わらない時間帯にしかデータが無いため、★補正を忘れても件数が1件も変わりません★。
だから `lib/db/__tests__/date-range.test.ts` に「壁時計 20:00 の清掃」の合成データがあります。
★そのテストを消さないこと★。

### (e) 1h前ST / 30分前ST

```
【DBの列】 robot_error_reports.error_type  String @db.VarChar(100)（prisma/schema.prisma:328）
           robot_error_reports.errored_at  DateTime? @db.DateTime(0)（:332。★nullable★）
           clean_timetables.precheck       Int @default(0) @db.UnsignedTinyInt（:596）
             0 = 本清掃 / それ以外は「本清掃開始の何分前か」の分数そのもの（D-112）
   ↓ 読む
【取得】   lib/db/index.ts:777-785   事前チェック行を parent_id で引く（select は id/parent_id/precheck の3列）
           lib/db/index.ts:799-805   そのエラー行を clean_timetable_id で引く
           lib/db/index.ts:879-888   precheck の生値 → エラー配列 の Map に詰め替えて
                                      statusColumnsFromPrecheckRows() に渡す
   ↓ 変換
【変換】   lib/db/precheck-mapping.ts:125-132
             statusColumnsFromPrecheckRows(m: ReadonlyMap<number, readonly RobotErrorLike[]>): [string, string]
             = PRECHECK_MINUTES_BEFORE.map(minutes => robotStatusFromErrors(m.get(minutes) ?? []))
             PRECHECK_MINUTES_BEFORE = [60, 30]（lib/config/index.ts:214。★構造由来★）
           lib/db/clean-log-mapping.ts:54-60
             robotStatusFromErrors(errors: readonly RobotErrorLike[]): string
             - 0件 → ROBOT_STATUS_STANDBY = "Standby."（lib/config/index.ts:322。★末尾のピリオド込みが原文★）
             - 1件以上 → errored_at が最新の1件の error_type を ★生値のまま★
   ↓ 型
【型】     CleanLogItem.status_1h / status_30m: string（lib/types/index.ts:215-217）
           ★実DB上の取得元・値の形式は未確定★（D-077 項3）。日本語6値は fixtures 由来の暫定値
   ↓ 画面
【画面】   CleanLogTable.tsx:192 / :197
             <Pill text={r.status_1h} color={statusColor(theme, r.status_1h)} />
           lib/theme-status.ts:24-29  statusColor(theme: ThemeName, status: string): string
             Object.hasOwn(ROBOT_STATUS_TONES, status) でないと c.txD（グレー）にフォールバック
           ROBOT_STATUS_TONES（lib/config/index.ts:676-683）
             待機中:"ok" / 充電中:"pr" / ハンドルアップ:"ng" / 緊急停止:"ng" /
             オフライン:"ng" / セキュリティ有効:"ac"
【値の例】 ★シード由来★ "待機中" → 緑（ok）。"オフライン" → 赤（ng）
           ★構造由来★ "EmergencyStop"（実データで来うる英語識別子）→ ★表に無いのでグレー★
           ★構造由来★ "Standby."（エラー0件の代替値）→ ★あえて表に足していない★のでグレー
```

★★D-077 の帰結★★（`CLAUDE.md` §4.8）— ★`error_type` も `robots.status` も変換しません★。
画面には ★英語識別子がそのまま出る★のが意図した挙動で、
★`lib/theme-status.ts` の色分けは日本語6文字列の完全一致なので、生値では全部グレーになります★。
★それも含めて現在の期待挙動★です（`lib/config/index.ts:658-674` / `lib/theme-status.ts:9-13`）。

★`Object.hasOwn()` を先に通す理由★（`lib/theme-status.ts:14-22`）—
`constructor` / `toString` / `__proto__` のような `Object.prototype` のメンバー名が `status` として
来ると、添字アクセスが ★`undefined` ではなくプロトタイプ側の関数を拾い★、
`Pill` が `background: undefined22` を出して ★グレーにすらフォールバックしません★。

### (f) 出発（✓ / ✗）

```
【DBの列】 clean_status_histories の行の有無 ＋ clean_error（prisma/schema.prisma:487）
   ↓ 判定
【判定】   lib/db/index.ts:909  departed: !notExecutedFromHistory(history)
           lib/db/clean-log-mapping.ts:152-157
             notExecutedFromHistory(history: CleanResultLike | undefined): boolean
               if (!hasCleanResult(history)) return true;             ← ★行が無い＝出発失敗★
               return history.clean_error === CLEAN_ERROR.notExecuted; ← ★=== 1 のみ★
           ★★`!== 0` なら異常、と書かないこと★★（指示書U 落とし穴3）—
             `=== 1` の形だから ★コード表に無い 5以上は自動的に「未実施ではない」側に落ちる★
   ↓ 型
【型】     CleanLogItem.departed: boolean（lib/types/index.ts:222）
   ↓ 画面
【画面】   CleanLogTable.tsx:200-202  {r.departed ? "✓" : "✗"}（色は departedOk / departedNg）
【値の例】 ★構造由来★ clean_error=0 → departed=true（✓）
           ★構造由来★ clean_error=1 → departed=false（✗）
           ★構造由来★ clean_error=2/3/4 → ★departed=true（✓）★（未実施とは違う扱い。ユーザー回答 2026-09-10）
           ★構造由来★ 履歴の行が無い → ★departed=false（✗）★（D-107 設計3-C。fail-loud）
           ★シード由来★ fixtures は departed が入力で clean_error が導出（lib/fixtures/clean-logs.ts:66）
             → ★fixtures に clean_error === null の行は存在しない★（null は DB 経由でだけ出る）
```

★★「履歴の行が無い」を出発失敗にするのは推測ではなく決定★★
（`lib/db/clean-log-mapping.ts:116-128` / D-107 設計3-C）—
★過去なのに履歴の行が無いのはデータ収集処理の障害★なので、
★行ごと結果から消す（fail-silent）と運用担当は「何かが足りない」ことにすら気づけません★。
★帰結として過剰検知になる（結果がまだ書かれていないだけの清掃も出発失敗として出る）★のは
★意図して選んだ側★です。

★★未来の予定を外す `isFutureSchedule()` は必須★★（`lib/db/clean-log-mapping.ts:263-265`）—
`clean_timetables` には ★1週間分の予定がまとめて登録される★ので、
外すと ★最大7日先までの予定が全部「出発失敗」になります★。
★記録済み実測（★当方のシード由来★）★: 3日後の予定を1行入れると `launch_failed` が
13 → 14 件に増えた（`lib/db/clean-log-mapping.ts:236`）。
★同時刻（`start_at === now`）は「来ていない」に入れない★（`>` であって `>=` ではない）。

### (g) 清掃結果（★D-146 項2 / 指示書AO で追加された列★）

```
【DBの列】 clean_status_histories.clean_error
           型: Int @default(0) @db.UnsignedTinyInt（NOT NULL）（prisma/schema.prisma:487）
           意味: 0=正常 / 1=異常(清掃未実施) / 2=異常(セキュリティ有効) /
                 3=異常(D-S制御盤異常) / 4=異常(清掃ロボット異常)
                 （lib/config/index.ts:263-274。原文は
                  docs/handoff/analysis/table-definitions-02-clean-error.md:213）
   ↓ 読む
【取得】   lib/db/index.ts:913   clean_error: history?.clean_error ?? null
           ★★`??` を `||` にすると 0（正常）が null に化ける★★（★事故#1★。
             tsc も lint も捕まえない。lib/db/index.ts:911-912）
   ↓ 型
【型】     CleanLogItem.clean_error: number | null（lib/types/index.ts:242）
           ★null = 「履歴の行が無い（清掃結果が記録されていない）」★
   ↓ 整形
【整形】   lib/format/clean-result.ts:34-39
             formatCleanResult(cleanError: number | null): string
               if (cleanError === null) return CLEAN_RESULT_NOT_DEPARTED_LABEL;  // "出発失敗"
               return cleanErrorLabel(cleanError);
           lib/format/clean-result.ts:35 に ★「`??` を `||` にしないこと」★ の注記
           lib/config/index.ts:302-304
             cleanErrorLabel(code: number): string  → CLEAN_ERROR_LABELS[code] ?? String(code)
           CLEAN_ERROR_LABELS（lib/config/index.ts:286-292。★定義書の原文ママ。括弧は半角★）
   ↓ 画面
【画面】   CleanLogTable.tsx:208  <td>{formatCleanResult(r.clean_error)}</td>
           ★セルの値は t() に通さない★（CleanLogTable.tsx:203-207）—
             原文ママのラベルに対訳を当てると何が原文だったか分からなくなる。
             コード表に無い値は String(code) なのでそもそも訳せない（D-077）
【値の例】 ★構造由来★ null → "出発失敗"
           ★構造由来★ 0    → "正常"
           ★構造由来★ 1    → "異常(清掃未実施)"   ★「出発失敗」とは別表示★
           ★構造由来★ 2    → "異常(セキュリティ有効)"
           ★構造由来★ 5    → ★"5"★（★「その他」「不明」に潰さない★。D-089 項3 / D-104 項2）
           ★シード由来★ fixtures は 0 か 1 しか作らない（lib/fixtures/clean-logs.ts:66）
```

### (h) 完了率

```
【DBの列】 clean_status_histories.clean_ratio
           型: Float @default(0.0)（★実DDL は double(4,1)★。D-094）（prisma/schema.prisma:497）
           ★取得元で 100 に丸められるため上限は 100★（TeamWorks 回答 B-3。:494-496）
   ↓ 読む
【取得】   lib/db/index.ts:918  completion_rate: completionRateFromHistory(history)
   ↓ 変換
【変換】   lib/db/clean-log-mapping.ts:184-192
             completionRateFromHistory(h: (CleanResultLike & { clean_ratio: number }) | undefined): number | null
               行が無い                 → null
               notExecutedFromHistory() → null
               それ以外                 → h.clean_ratio（★丸めない生値★）
           ★★`history ? history.clean_ratio : null` に戻さないこと★★（lib/db/index.ts:915-917）—
             未実施行の clean_ratio は既定値の 0 なので、★null が 0（＝「0%で実施した」）に化ける★。
             ★判定はどちらも × なので画面もテストも赤くならない★（D-107 落とし穴1）
   ↓ 型
【型】     CleanLogItem.completion_rate: number | null（lib/types/index.ts:247）
   ↓ 整形
【整形】   lib/judge/grade.ts:43-46  displayRate(rate: number | null): string
             null → "-" / それ以外 → `${Math.round(rate)}%`
   ↓ 画面
【画面】   CleanLogTable.tsx:209  <td>{displayRate(r.completion_rate)}</td>
【値の例】 ★シード由来★（lib/fixtures/clean-logs.ts:94-107）
             90.1 → "90%" / 89.5 → "90%" / 10.0 → "10%" / 9.9 → "10%" / 0 → "0%" / null → "-"
```

### (i) 判定（◎○△×）

```
【判定】   lib/db/index.ts:947  grade: judgeGrade(draft.completion_rate)
           lib/judge/grade.ts:29-35
             judgeGrade(rate: number | null): Grade
               rate === null                          → "×"
               rate >= GRADE_THRESHOLDS.excellent(90)  → "◎"
               rate >= GRADE_THRESHOLDS.good(70)       → "○"
               rate <  GRADE_THRESHOLDS.poor(10)       → "×"
               それ以外                                 → "△"
           GRADE_THRESHOLDS（lib/config/index.ts:18-25。★構造由来★）
             excellent: 90 / good: 70 / poor: 10
           ★★境界はすべて「上のランクに入る」★★（D-139。2026-09-16）— 90.0 は ◎、70.0 は ○。
           ★★× の境界だけ「未満」のまま★★ — 10.0 は △。
   ↓ 型
【型】     CleanLogItem.grade: Grade = "◎" | "○" | "△" | "×"（lib/types/index.ts:17 / :249）
   ↓ 画面
【画面】   CleanLogTable.tsx:212  <GradeBadge grade={r.grade} />
           components/ui/GradeBadge.tsx:9-15
             ◎ / ○ → tone "ok" / △ → "wn" / × → "ng"
           凡例（「判定 ?」ボタン）: CleanLogTable.tsx:135-164
             ★閾値を直書きせず GRADE_THRESHOLDS から出す★（:140 / :143 / :149 / :153）
             ★「10-69%」のような表記にしないこと★（:145-147）— 69.5 がどこにも該当しない読み方になる
```

★★期待値表（`CLAUDE.md` §9.4 / `lib/fixtures/clean-logs.ts:82-92`。★シード由来★）★★

| 完了率 | 判定 | 表示（整数丸め） | 注意 |
|---|---|---|---|
| 90.1 | ◎ | 90% | ★丸めると90だが◎★ |
| 90.0 | ◎ | 90% | ★D-139 で ○ から変わった★ |
| 89.5 | ○ | 90% | ★表示は90%だが判定は○★（★事故#2★） |
| 70.1 | ○ | 70% | |
| 70.0 | ○ | 70% | ★D-139 で △ から変わった★ |
| 10.1 | △ | 10% | |
| 10.0 | △ | 10% | ★「10%未満」が×なので △★ |
| 9.9 | × | 10% | |
| 0 | × | 0% | ★「0%で実施した」★ |
| null | × | - | ★未実施★ |

★★`isProblemCompletion()` と `judgeGrade()` は結果が常に一致する★★
（`lib/db/clean-log-mapping.ts:287-289` / `lib/judge/grade.ts:54-59`）—
入力が違うだけの同じ規則（`rate === null || rate < GRADE_THRESHOLDS.good`）なので、
★片方の比較演算子だけを動かさないこと★。★D-139 以前は `<=` が正しかった★という履歴があります。

### (j) 連続問題日数とエスカレーション（★画面の表には出ないが判定に効く★）

```
【取得】   lib/db/index.ts:933-939
             loadConsecutiveInputs(prisma, now, { date_from, date_to })
               → computeConsecutiveProblemDays(...)  : Map<number, number>
   ↓ 遡り幅
【遡り】   lib/db/consecutive-source.ts:132-137
             consecutiveLookbackCalendarDays(maxDays: number): number
             = (maxDays + CONSECUTIVE_LOOKBACK.plusDays) * CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay
             = (30 + 1) * 7 = ★217 カレンダー日★（★構造由来★。lib/config/index.ts:167-172）
           lib/db/consecutive-source.ts:147-156  consecutiveLookbackFrom(dateFrom, maxDays)
           ★★表示期間そのもので絞らないこと★★（★指示書AC C-2 の地雷★）—
             「9/8× 9/9× 9/10×」（3日連続 = L3）を「9/10〜9/10」で絞って渡すと
             ★1日 = L1 に化ける★。★例外もテスト失敗も出ない★
           ★★`plusDays` を 0 にしないこと★★（lib/config/index.ts:157-161）—
             上限ちょうどまでしか読まないと `capped` が永久に false になり、
             ★「30日以上」が二度と画面に出なくなる★
   ↓ 畳み込み
【畳込】   lib/db/clean-log-mapping.ts:340-352
             foldProblemDaysByDate(rows): ScheduleDayResult[]
             ★同一日に問題ありの行が1つでもあれば その日は「問題あり」（OR。A案。D-109）★
             ★施設に複数台あると同じ日に複数行が生まれるので、畳まないと「N日連続」が水増しされる★
   ↓ 数える
【数え】   lib/judge/escalation.ts:32-39  countConsecutiveProblemDays(days: ScheduleDayResult[]): number
             ★直近から遡って数える。最新日が問題なしなら 0★
   ↓ 判定
【判定】   lib/judge/escalation.ts:52-59  judgeEscalationLevel(consecutiveDays: number): EscalationLevel | null
             0以下 → null / >= 3 → "L3" / >= 2 → "L2" / それ以外 → "L1"
             ESCALATION_THRESHOLDS = { level2Days: 2, level3Days: 3 }（lib/config/index.ts:27-32）
   ↓ 表示上限
【上限】   app/api/clean-logs/route.ts:105  capCleanLogs(items, maxDays)
           lib/clean-logs/query.ts:208-220 → lib/format/consecutive-days.ts:32-38
             capConsecutiveDays(rawDays, maxDays = 30): { days, capped }
             ★rawDays > maxDays のときだけ capped: true★（★上限ちょうどは false★）
           ★★escalation_level は触らない★★（lib/clean-logs/query.ts:196-197）—
             L1〜L3 は ★上限をかける前の生値★で判定済み（指示書AF 禁止事項9）
   ↓ 型
【型】     CleanLogItem.consecutive_problem_days: number（lib/types/index.ts:254）
           CleanLogItem.consecutive_problem_days_capped?: boolean（:262。★任意項目★）
           CleanLogItem.escalation_level: EscalationLevel | null（:264）
【値の例】 ★シード由来★ 施設4（月水金スケジュール）は5日前・3日前・1日前の3回とも失敗
             → ★カレンダー上は5日だがスケジュール存在日の連続は3日 → L3★
             （lib/fixtures/clean-logs.ts:148-153）
           ★構造由来★ 表示上限 30（CONSECUTIVE_DAYS_DISPLAY.defaultMax。lib/config/index.ts:135）
             下限 3（= level3Days）/ 上限 365（:137-139）
```

## 2.4 アドバイス（行を開いたとき）— `GET /clean-logs/{id}/advice`

```
【取得】   app/api/clean-logs/[id]/advice/route.ts:58-63
             db.cleanLogs({ id: logId, date_from: null, date_to: null, facility_ids: null })
           ★★`facility_ids: []` を渡さないこと★★ — `[]` は「0件ヒット」なので★全部 404★（罠1）
   ↓ 判定（★入力は生値★。:78-80 の注記）
【判定】   app/api/clean-logs/[id]/advice/route.ts:81-87
             matchAdviceRules({ departed, completionRate, consecutiveProblemDays, status1h, status30m })
           lib/judge/advice.ts:50-94  matchAdviceRules(input: AdviceInput): AdviceRuleId[]
             R1: !departed && consecutiveProblemDays <= 1
             R2: completionRate !== null && completionRate < GRADE_THRESHOLDS.poor(10)
             R3: consecutiveProblemDays >= ESCALATION_THRESHOLDS.level2Days(2)
             R4: consecutiveProblemDays >= ESCALATION_THRESHOLDS.level3Days(3)
             B1: completionRate !== null && judgeGrade(completionRate) === "△"
             B2: status が B2_HANDLE_UP_STATUSES に一致（lib/config/robot-status-match.ts）
             B3: status が B3_OFFLINE_STATUSES に一致（★現在このマッチ配列は空なので発火しない★）
             ★複数該当したら全部返す★
   ↓ 文言
【文言】   loadAdviceRules()（lib/settings/advice.ts）→ 既定は lib/config/advice-rules.ts
           app/api/clean-logs/[id]/advice/route.ts:114-124 で AdviceItem に詰め替える
   ↓ 表示上限
【上限】   app/api/clean-logs/[id]/advice/route.ts:154  capConsecutiveDays(生値, maxDays)
           ★★ここだけが表示上限をかけてよい場所★★（:143-146）—
             判定も judge.* の logEvent も生値のまま
   ↓ 型
【型】     CleanLogAdviceResponse（lib/types/index.ts:366-375）
             { escalation_level: EscalationLevel | null;
               consecutive_problem_days: number;
               consecutive_problem_days_capped: boolean;
               max_level: number;
               advices: AdviceItem[] }
           AdviceItem（:269-277）= { rule; level: 1|2|3; icon; title; estimated_cause; recommended_actions: string[] }
   ↓ 画面
【画面】   CleanLogTable.tsx:219-266
             :41-61  adviceHead(maxLevel) → 3:「🔴 レベル3 — DFA介入を検討」/
                                            2:「🔶 レベル2 — DFA運用担当がアクション」/
                                            それ以外:「⚠ レベル1 — 現場確認が必要」
             :238-241 {a.icon} {a.title} / {a.estimated_cause}
             :243-247 recommended_actions を <li> で並べる
             :251-260 「エリア別詳細を見る →」ボタン
           ★行が開けるのは grade が △ か × の行だけ★（CleanLogTable.tsx:174 / :183）
           ★取得に失敗しても行は開く★（DetailLogScreen.tsx:227-242）— 一覧が持っている値で埋める
```

## 2.5 エリアモーダル — `GET /clean-logs/{id}/areas`

★★`{id}` は「エリアを特定するキー」ではなく、施設と日付を特定するための入口★★
（指示書Z 決定1。`app/api/clean-logs/[id]/areas/route.ts:103-105`）。
★同じ施設・同じ日の**全エリア**を出す★ので、`clean_log_id` で引くと他のエリアが消えます。

```
【DBの列】 sections.section_name       String @db.VarChar(100)（prisma/schema.prisma:171）
           clean_status_histories.clean_ratio Float（:497）
           clean_status_histories.clean_area  Float（:492）
           clean_status_histories.task_area   Float（:491）
   ↓ 2段構え
【取得】   1段目 app/api/clean-logs/[id]/areas/route.ts:78-83
             db.cleanLogs({ id: logId, date_from: null, date_to: null, facility_ids: null })
             → 404 の判定と、facility_id / JST 日付の特定だけに使う
           :99  const date = log.scheduled_at.slice(0, 10)
             ★★areaCompletions() の date と同じ作り方★★（片方だけ生の Date から作ると
               9時間ずれて突き合わせが外れる。D-115）
           2段目 :106  db.areaCompletions({ facility_id: log.facility_id, date })
           prismaSource: lib/db/index.ts:1080-1096
             completion_rate: completionRateFromHistory(history)   ← ★clean_ratio の生値★
             clean_area: history?.clean_area ?? 0                  ← ★`||` にしない（事故#1）★
             task_area:  history?.task_area  ?? 0
             date: jstIsoFromDb(row.start_at).slice(0, 10)
   ↓ 中間型
【型】     AreaCompletionRow（lib/types/index.ts:325-339）
             { clean_log_id; facility_id; date; section_id; section_name;
               completion_rate: number | null; clean_area: number; task_area: number }
   ↓ 絞り込み・集計
【集計】   app/api/clean-logs/[id]/areas/route.ts:116-119（selected）/ :120（body）
           lib/clean-logs/areas.ts:55-62  selectAreaRowsForLog(rows, { facility_id, date })
           lib/clean-logs/areas.ts:110-139 buildAreaCompletions(rows, filter): AreaCompletionsResponse
             items          = rows.map(...) に judgeGrade() を当てて compareByCompletion でソート
             problem_count  = isProblemGrade(grade) の件数（★filter で絞る前★）
             overall        = completionRate(Σclean_area, Σtask_area)  ★面積ベース★
             total_count    = items.length（★filter で絞る前★）
             items（返す分）= filter === "problem" なら △× のみ
           並び順（lib/clean-logs/areas.ts:70-82 compareByCompletion）
             ★完了率の昇順。未実施(null)は先頭。同値は section_id の昇順★
             （★悪いものから見る画面なので、いちばん悪い「未実施」を先頭に置く★。決定10）
   ↓ 型
【型】     AreaCompletionsResponse（lib/types/index.ts:303-317）
             { overall_completion_rate: number | null; overall_grade: Grade;
               total_count: number; problem_count: number; items: AreaCompletionItem[] }
           AreaCompletionItem（:286-300）= { section_id; section_name; completion_rate: number | null; grade }
   ↓ 画面
【画面】   components/features/detail-log/AreaModal.tsx
             :70-71  ヘッダ「全体 {displayRate(overall_completion_rate)} {overall_grade}」
             :104-111 「問題エリア（{problem_count} / {total_count}エリア）」/「全{total_count}エリア（完了率昇順）」
             :113-120 0件の文言を ★total_count === 0 かどうかで分ける★
                      → 「エリアのデータがありません」/「問題エリアはありません（全エリア◎○）」
             :131    section_name（★生値。当方で整形しない★）
             :135-138 <ProgressBar percent={item.completion_rate ?? 0} tone={barTone(item.grade)} />
                      ★未実施（null）は 0 幅★
             :142    displayRate(item.completion_rate)  ★未実施は "-"。0% と書かない★
             :144    <GradeBadge grade={item.grade} />
【値の例】 ★シード由来★（lib/fixtures/areas.ts:67-）
             施設1・当日: task_area = 1000、行の completion_rate = 90.1
             → clean_area = 1000 × 90.1 ÷ 100 = 901
             → overall = 901 / 1000 × 100 = 90.1 → 表示「90%」/ overall_grade「◎」
           ★構造由来★ section_name はシードと同じ形「清掃エリア（ロボット11）」
             （lib/fixtures/areas.ts:44）
```

★★行と全体で完了率の出し方が違う（意図した非対称）★★（指示書Z 決定7。
`lib/clean-logs/areas.ts:87-97` / `lib/types/index.ts:291-296`）

| | 出し方 |
|---|---|
| 各行の `completion_rate` | ★`clean_ratio` の生値★（面積から再計算しない） |
| `overall_completion_rate` | ★Σ`clean_area` ÷ Σ`task_area` × 100★（面積ベース。`CLAUDE.md` §4.2） |

★合計が行の平均と一致しないことがあります★。
行を面積から再計算すると `cleanLogs()` の完了率と食い違い、
全体を単純平均で出すと担当面積の違う台を等しく扱うことになるため、
★どちらを選んでも揃わない。揃わないことを明示する★という判断です。

## 2.6 CSV エクスポート

- `DetailLogScreen.tsx:463` — `<a href={`/api/clean-logs/export?${queryString}`} download>`
  （★`router.push` を使わない★。RSC のプリフェッチで ★エクスポートが2回走る★。`:442-445`）
- ★上限は `MAX_CSV_ROWS = 20_000`★（`lib/config/index.ts:385`。★構造由来★）。
  ★「上限」は含む★ — ちょうど 20,000 行は出せて、20,001 行から 422。
- 画面側の歯止め: `DetailLogScreen.tsx:162` — `const csvBlocked = total > MAX_CSV_ROWS;`
  ★`>` であってサーバ側と同じ向きにすること★（`:155-157`）。
  ★片方だけ `>=` にすると、押せるのに422が返る（またはその逆）★。
- 上限超過時は ★`<a>` ではなく `<button disabled>` に差し替える★（`:451-468`）。
  ★`<a>` に `disabled` 属性は無く、付けても押せてしまう★。
- 422 の `details` は ★数値の形★（`{ total: 25000, max: 20000 }`）。
  ★文言から数字を読み取らせないため★（`lib/types/index.ts:53-56`）。
- ★★`MAX_CSV_ROWS = 20_000` の根拠は暫定です★★（`lib/config/index.ts:373-377`）—
  ★記録済み実測（D-144 / D-145 の訂正対象）★: 「31日・502行で0.73秒」という ★1点からの外挿★で、
  ★その 502行・31日はいずれも当方の `scripts/seed-local-db.ts` が決めた値★でした。
  ★本番の回数は不明★です（D-145。2026-09-17）。

---

# 第3章 アラート（事前チェック）（F-03）

- 画面: `app/(dashboard)/alerts/page.tsx` → `components/features/alerts/AlertsScreen.tsx`
- API: `GET /api/prechecks`（`app/api/prechecks/route.ts` / `d-watch-api.yaml:595`）

## 3.1 ★この画面の一覧は「2種類の行」を1本に混ぜたもの★

| `severity` | 行の正体 | 期間の基準 |
|---|---|---|
| `"precheck"` | ★事前チェック行に紐づく `robot_error_reports` の行★ | ★親の本清掃行の `start_at`★ |
| `"launch_failed"` | ★本清掃が実施されなかったこと★（エラー行ではない） | ★自身の `start_at`★ |

★★2番目はエラー行由来ではありません★★（`lib/db/index.ts:1109-1113`）—
`launch_failed` は `cleanLogs()` の `departed === false` と ★同じ出来事★ なので、
★同じ判定関数 `notExecutedFromHistory()` を共有します★（D-103 項1）。
★「本清掃行を指すエラー行」を `launch_failed` に読み替えてはいけません★
（`lib/db/index.ts:1261-1263`。読み替えると二重計上になり、`departed` と別定義になる）。

★引く順★（`lib/db/index.ts:1156-1232`）:
★本清掃行（期間・施設で絞る） → その子の事前チェック行（`parent_id`） → そのエラー行
（`clean_timetable_id`）★。
★★エラー行を `errored_at` で絞らないこと★★ — `scheduled_at` に入るのは ★親の `start_at`★ なので、
`filterPrechecksByDate()`（`scheduled_at` で絞る）と食い違い、★境界の行が静かに消える／増える★。

## 3.2 クエリパラメータ（`d-watch-api.yaml:603-621`）

| キー | 型 / enum | 既定 | 画面 |
|---|---|---|---|
| `date_from` / `date_to` | `string (date)` | — | `AlertsScreen.tsx:87-88`（初期値は空文字。`:58-59`） |
| `group_id` / `customer_id` | `integer` | — | `:92` |
| `status` | `open \| unresolved \| in_progress \| resolved \| all` | `open` | `:89`（初期値 `open`。`:60`） |
| `page` / `per_page` | `integer` | 1 / 50 | `:93-94` |

★★空文字の正規化★★（`lib/prechecks/query.ts:57-66`）—
`?date_to=` のように値だけ空で送られると、★`USE_FIXTURES` の true/false で件数が変わります★:

- Prisma 版 … `dbDateRange("")` → `parseDate("")` が `null` → ★条件が付かず全件★
- fixtures 版 … `date_to !== null` が真・`"2026-09-10" > ""` も真 → ★全行除外で0件★

★例外もテスト失敗も出ない★ので、`searchParams.get("date_from") || null` で ★入口で潰して★います。

## 3.3 カード「未解消 / 解消済み / 合計」

```
【集計】   app/api/prechecks/route.ts:71  const dated = filterPrechecksByDate(rows, parsed.query)
           app/api/prechecks/route.ts:75  const summary = summarizePrechecks(dated)
           ★★summary は日付範囲で絞った後・対応状況フィルタの前★★（D-054。:72-75）—
             状況フィルタの後だと、既定表示（status=open）で ★解消済みカードが常に0★ になり
             サマリーの役目を果たさない
           lib/prechecks/query.ts:177-183
             summarizePrechecks(items: PrecheckItem[]): PrecheckSummary
               resolved   = resolution_status === "resolved" の件数
               total      = items.length
               unresolved = total - resolved   ★未解消 + 対応中★
   ↓ 型
【型】     PrecheckSummary（lib/prechecks/query.ts:170-175）= { unresolved; resolved; total }
   ↓ 画面
【画面】   components/features/alerts/AlertsScreen.tsx:142-144
             未解消（tone "ng"）/ 解消済み（"ok"）/ 合計（"ac"）
【値の例】 ★シード由来★ lib/fixtures/prechecks.ts の当日分から算出される
```

★★`meta.total` と `summary.total` は意味が違う★★（`app/api/prechecks/route.ts:81-84`）—

- `meta.total` = ★対応状況フィルタ後★の総件数（ページング計算用）
- `summary.total` = ★日付範囲で絞った全件★（対応状況フィルタの影響を受けない）

★`status=all` のときだけ一致します★。★`summary.total` をページングに流用しないこと★
（`AlertsScreen.tsx:74-80`）— ★存在しないページのボタンが出ます★。

## 3.4 一覧カードの各要素

### (a) 種別バッジ（`severity` + `precheck_minutes`）

```
【DBの列】 clean_timetables.precheck  Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:596）
           ★0 = 本清掃。それ以外は「本清掃開始の何分前か」の分数そのもの★
           （TeamWorks 第7回 A5-2 / A5-5。2026-09-11 受領。D-112）
   ↓ 変換
【変換】   lib/db/precheck-mapping.ts:92-95
             precheckMinutesFromCode(precheck: number): number | null
               precheck === CLEAN_TIMETABLE_PRECHECK.main(0) → null
               それ以外 → precheck（★生値そのまま★）
           ★★`1` は「1回目」ではなく「1分前」★★（A5-3）—
             ★以前の実装は 1 を60分前・2 を30分前と読んでおり、TeamWorks の指示と真逆だった★（指示書X）
           ★未知の値を「その他」に丸めない★（A5-5「0以外の予約語はありません」）
   ↓ 取得
【取得】   lib/db/index.ts:1264-1265  null（＝本清掃行）なら continue（★launch_failed に読み替えない★）
           lib/db/index.ts:1288       precheck_minutes: precheckMinutes
           lib/db/index.ts:1353       launch_failed 側は precheck_minutes: null（D-105）
   ↓ 型
【型】     PrecheckItem.severity: "precheck" | "launch_failed"（lib/types/index.ts:437 / :462）
           PrecheckItem.precheck_minutes: number | null（:470）
   ↓ 整形
【整形】   lib/prechecks/label.ts:32-39
             precheckSeverityLabel(t: Translate, item: Pick<PrecheckItem,"severity"|"precheck_minutes">): string
               severity === "launch_failed"  → t("出発失敗")
               precheck_minutes === null     → t("事前チェック異常")  ★黙って "null分前異常" を出さない★
               それ以外                       → t("{n}分前異常").replace("{n}", String(minutes))
   ↓ 画面
【画面】   AlertsScreen.tsx:274-277  <Pill text={precheckSeverityLabel(t, item)} color={severityColor(c, item.severity)} />
           AlertsScreen.tsx:32-34  severityColor: launch_failed → c.ng（赤）/ precheck → c.wn（警告色）
           AlertsScreen.tsx:269    カード左枠の色も同じ severityColor
【値の例】 ★構造由来★ precheck=60 → "60分前異常" / precheck=30 → "30分前異常"
           ★シード由来★ precheck_minutes=20 → ★"20分前異常"★
             （lib/fixtures/prechecks.ts の id:"9"。★設定値以外の分数を表現できるかの境界値★）
           ★構造由来★ severity="launch_failed" → "出発失敗"
```

★★`precheck` はコード表ではなく分数そのもの★★なので、
`status_1h` / `status_30m` の列割り当ては ★`PRECHECK_MINUTES_BEFORE = [60, 30]` との一致★ で決まります
（`lib/db/precheck-mapping.ts:104-109`）。
★旧コード値（1 / 2）の行しか無い場合は、どちらとも一致せず両方「待機」になります★（期待値表 #6）。
★`1` を60分前に読み替えたりしません★。

### (b) ロボット状態

```
【DBの列】 robot_error_reports.error_type  String @db.VarChar(100)（prisma/schema.prisma:328）
   ↓ 取得
【取得】   lib/db/index.ts:1298-1300
             robot_status: robotStatusFromErrors([{ error_type: report.error_type, errored_at: report.errored_at }])
           lib/db/index.ts:1360  launch_failed 側は robotStatusFromErrors([]) → "Standby."
             ★自分で文字列を書かず、既存の純粋関数に空配列を渡して得る★（D-098 / D-104）
   ↓ 型
【型】     PrecheckItem.robot_status: string（lib/types/index.ts:475）
   ↓ 画面
【画面】   AlertsScreen.tsx:278  <Pill text={item.robot_status} color={statusColor(theme, item.robot_status)} />
【値の例】 ★シード由来★ "ハンドルアップ" → 赤 / "充電中" → pr / "オフライン" → 赤
           ★構造由来★ "Standby."（launch_failed 側）→ ★表に無いのでグレー★
           ★構造由来★ 実データの英語識別子（例 "EmergencyStop"）→ ★グレー★
```

★fixtures が日本語のままなのは意図★（`lib/fixtures/prechecks.ts:29-38`）—
`lib/fixtures/clean-logs.ts` の `status_1h` / `status_30m` が日本語で、
`scripts/seed-local-db.ts` はその値を `error_type` に ★そのまま★ 入れているためです。
★`USE_FIXTURES` の true/false で `robot_status` の値が一部違うのは想定内★。

### (c) 検知日時

```
【DBの列】 robot_error_reports.errored_at  DateTime? @db.DateTime(0)（prisma/schema.prisma:332。★nullable★）
           robot_error_reports.created_at  DateTime @default(now()) @db.Timestamp(0)（:335。NOT NULL）
   ↓ 選択
【選択】   lib/db/precheck-mapping.ts:166-168
             detectedAtFromError(source: PrecheckDetectedAtSource): Date
             = source.errored_at ?? source.created_at
           ★`created_at` を優先しない理由★: 収集処理の遅延がそのまま「検知時刻」になり、
             実際にエラーが起きた時刻より後ろにずれる（:154-155）
   ↓ 変換
【変換】   lib/db/index.ts:1305  detected_at: jstIsoFromDb(detectedAtFromError(report))
           lib/db/index.ts:1366  launch_failed 側は detected_at = scheduled_at（★予定時刻そのもの★）
             ★「予定＋猶予30分」を当方で計算しない★（D-091 項1。LAUNCH_GRACE_MINUTES を読まない）
   ↓ 画面
【画面】   AlertsScreen.tsx:36-38  formatDateTime(iso) = `${formatMd(iso)} ${formatHm(iso)}`
           AlertsScreen.tsx:292   「検知: 9/17 01:00」
【値の例】 ★シード由来★ at(0,"01:00") → "9/17 01:00"
```

★★申し送り★★（`lib/db/precheck-mapping.ts:160-164`）—
★ローカルのシードは `errored_at` を必ず入れるため、`created_at` へのフォールバックが1度も
発火しておらず、`created_at`（TIMESTAMP）側の補正はローカルでは検証できていません★。
実DB接続時に `SELECT COUNT(*) FROM robot_error_reports WHERE errored_at IS NULL` を流すこと。

### (d) 対応状況

```
【DBの列】 robot_error_reports.status        Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:330）
           robot_error_reports.responded_at  DateTime? @db.DateTime(0)（:333）
           robot_error_reports.resolved_at   DateTime? @db.DateTime(0)（:334）
           robot_error_reports.comment       String? @db.Text（:331）
           ★この4カラムが「更新用アカウントで UPDATE 可」の4カラム★（CLAUDE.md §3）
           ★★ただし本番DBへは書き込まない（D-148。2026-09-17 ユーザー指示）★★
   ↓ 変換
【変換】   lib/db/precheck-mapping.ts:32-45
             resolutionStatusFromRobotErrorStatus(status: number): PrecheckStatus
               0 → "unresolved" / 1 → "in_progress" / 2 → "resolved"
               ★それ以外は例外を投げる★（:41-44。★静かに握りつぶさない★）
           ★同じ 0/1/2 でも D-Sec 側は pending / in_progress / done で ★変換先の文字列が別★★
             （lib/db/ds-status.ts。★同じ関数を使い回さない★）
   ↓ 取得
【取得】   lib/db/index.ts:1307-1310
             resolution_status / responded_at / resolved_at / note（= comment）
           lib/db/index.ts:1369-1374  launch_failed 側は
             resolution_status: "unresolved" 固定 / responded_at・resolved_at・note すべて null
             ★エラー行が無いので状態を持てない★（D-104）
   ↓ 型
【型】     PrecheckStatus = "unresolved" | "in_progress" | "resolved"（lib/types/index.ts:438）
   ↓ 絞り込み
【絞込】   lib/prechecks/query.ts:132-150  filterPrechecksByStatus(items, status)
             "open" → resolution_status !== "resolved"（D-054）
   ↓ 画面
【画面】   AlertsScreen.tsx:296-310
             resolved    → 「解消 ({formatDateTime(resolved_at)})」★resolved_at が null なら "-"★
             in_progress → 「対応中」
             unresolved  → 「未解消」
【値の例】 ★シード由来★ 未解消は responded_at / resolved_at ともに null、
             対応中は responded_at のみ、解消済みは両方あり（lib/fixtures/prechecks.ts:20-25）
```

### (e) バッテリー残量

```
【DBの列】 robots.battery  Int @default(0) @db.UnsignedTinyInt（prisma/schema.prisma:240）
           ★★現在値のみで履歴が無い★★
   ↓ 取得
【取得】   lib/db/index.ts:1314  battery_level: null   ★★実DB経由では常に null★★（D-090 項8 / D-105）
           lib/db/index.ts:1375  launch_failed 側も null
           ★現在値を代入すると、別時点の値を過去のアラートに貼ることになる★（:1311-1313）
   ↓ 型
【型】     PrecheckItem.battery_level: number | null（lib/types/index.ts:490）
   ↓ 画面
【画面】   AlertsScreen.tsx:286-290  item.battery_level !== null のときだけ「残量 {n}%」の行を出す
【値の例】 ★シード由来★ 78 / 12 / null（lib/fixtures/prechecks.ts）
           ★構造由来★ ★実DB経由は常に null → この行は実データでは出ない★
```

### (f) id と並び順

```
【DBの列】 robot_error_reports.error_id  String @unique @db.VarChar(50)（prisma/schema.prisma:312）
           clean_timetables.id           BigInt @id（:580）
   ↓ 取得
【取得】   lib/db/index.ts:1286  precheck 側:      id: report.error_id（★string のまま★）
             ★★`Number()` を通さない★★ — varchar なので NaN になっても例外が出ず静かに壊れる（落とし穴3）
           lib/db/index.ts:1350  launch_failed 側: id: String(Number(row.id))
             ★CleanLogItem.id と同じ値★なので、F-02 と F-03 に同じ清掃について同じ番号が出る
   ↓ 型
【型】     PrecheckItem.id: string（lib/types/index.ts:461）
   ↓ 並び順
【並び】   lib/prechecks/query.ts:160-168  sortPrechecks(items)
             detected_at の降順 → 同値なら id の ★文字列★ 昇順（D-052）
             ★★辞書順なので "10" < "9" になる★★（:155-158）—
               id は varchar(50) 由来で数値である保証がなく、数値としての昇順は定義できない。
               ★要件は「安定した順序であること」★
【値の例】 ★シード由来★ id は "1" 〜 "9" の文字列（lib/fixtures/prechecks.ts）
```

---

# 第4章 レポート（F-04）

- 画面: `app/(dashboard)/reports/page.tsx` → `components/features/reports/ReportsScreen.tsx`
- API: ★4本を `Promise.allSettled` で並列に叩く★（`ReportsScreen.tsx:190-205`）
  - `GET /api/reports/kpi`（`d-watch-api.yaml:664`）
  - `GET /api/reports/facility-performance`（`:705`）
  - `GET /api/reports/continuous-problems`（`:763`）
  - `GET /api/reports/error-breakdown`（`:803`）

★★`Promise.all` ではない理由★★（`ReportsScreen.tsx:185-189` / 指示書AL 段階2）—
★1本の失敗で4本とも消えていた★のを、★成功した分は出す★形にしました。
★特にKPIは「対象顧客が1社に定まらない」と422になる★が、★残り3本は施設単位なので出せます★。
★`USE_FIXTURES=true` は屋号が4件あるため、これが開発中に毎回起きます★。

## 4.1 期間の指定（★月跨ぎ週の地雷★）

```
【画面】   ReportsScreen.tsx:86  const initialPeriod = useMemo(() => weekOfMonth(todayJst()), [])
           ReportsScreen.tsx:152-165  baseQuery
             period / year / month（+ weekly のときだけ week）+ 分類
           ReportsScreen.tsx:245-260  navigatePeriod(delta) → shiftPeriod(...)
             ★monthly の shiftPeriod は week に 0 を返す（D-072）★ので、
               週次へ戻すときは clampWeek() でその月の最大週へ丸める（:253-255）
   ↓ 帰属ルール
【規則】   lib/format/period.ts:126  weekOfMonth(date): { year; month; week }
           ★★月跨ぎ週は「月境界で分割する」★★（D-071。2026-09-07 確定）
             - 週は日曜〜土曜。★ただし月をまたぐ週は1日で切り、それぞれの月に別々の週として数える★
             - ★週の長さは1〜7日で変わる。月の週番号は 1〜6★
             - 各月の第1週は必ず1日から始まり、最終週は必ず末日で終わる
           ★★帰属ルールを知っているのは weekOfMonth() だけ★★（CLAUDE.md §4.7）—
             ★画面側で年月週を自前計算しない★（ReportsScreen.tsx:85-86）
   ↓ 検証
【検証】   lib/reports/query.ts:138  parseReportQuery(searchParams)
             year は REPORT_YEAR.min(2000) 〜 max(2100)（lib/config/index.ts:421-426。★構造由来★）
             ★★week は 1 以上のみ★★（week=0 はサマリ4テーブルで「月次」を表す予約値。
               lib/types/index.ts:757-762）
   ↓ 粒度の判別
【判別】   lib/db/report-mapping.ts:72  periodOfSummaryRow(week: number): Period
             ★week === 0 なら monthly、それ以外は weekly★
             （month=0 AND week=0 が年次。★P1 では読まない★。D-072 項4）
【値の例】 ★構造由来★（CLAUDE.md §4.7 の2026年の例）
             8月第1週  = 08-01 のみ（1日）
             8月第6週  = 08-30〜08-31（2日）
             9月第1週  = 09-01〜09-05（5日）
             9月第2週  = 09-06〜09-12（7日）
           ★構造由来★ year の範囲外（例 `year=-1`）は 422。
             ★以前は period=monthly&year=-1&month=9 が 200 を返していた★
             （lib/config/index.ts:392-399。★記録済み実測 2026-09-14。develop 3e48d30★）
```

## 4.2 KPI 4枚（稼働率 / 出発成功率 / 清掃完了率 / エラー発生率）

### 対象顧客の決定（★このルートだけ 422 の理由が2つある★）

```
【取得】   app/api/reports/kpi/route.ts:79-94   customerId 未指定のときだけ db.customers() を読む
           app/api/reports/kpi/route.ts:96      resolveTargetCustomer(customers, parsed.query)
             （lib/reports/target-customer.ts）
             customer_id 指定 … その顧客（★存在確認はしない★。該当が無ければ 200・空）
             group_id 指定    … 傘下の顧客がちょうど1社ならその顧客。それ以外は 422
             どちらも未指定    … 顧客がちょうど1社ならその顧客。2社以上は 422
   ↓ 422 の実物
【422】    app/api/reports/kpi/route.ts:103-110
             { error: { code: "validation_failed",
                        message: TARGET_CUSTOMER_REQUIRED_MESSAGE,
                        details: { customer_ids: [1, 2] } } }
             ★details は「数値の配列」★（d-watch-api.yaml:693-703）—
               ★画面が文言を読まずに次の操作を出せるようにするため★
             ★この形を許すのは /reports/kpi の422 と /clean-logs/export の422 だけ★
               （lib/types/index.ts:51-61 / :78-82）
   ↓ 施設への解決
【解決】   app/api/reports/kpi/route.ts:130-133
             resolveFacilityIdsByMasters({ groupId: null, customerId: target.customerId })
           ★★リクエストの group_id から直接引かないこと★★（指示書AL §2-3 / 落とし穴1）—
             確定期間は customer_summaries（★顧客単位★）、当期は詳細テーブル（★施設単位★）なので
             ★2つの軸が同じ顧客を指していないと「確定期間は1社・当期は全社」という食い違いが起きる★
             （D-138 の残課題）
```

### 確定期間の KPI（サマリテーブルをそのまま読む）

```
【DBの列】 customer_summaries.operation_ratio  Float @default(0.0)（prisma/schema.prisma:699）
           customer_summaries.launch_ratio     Float（:700）
           customer_summaries.clean_ratio      Float（:701）
           customer_summaries.error_ratio      Float（:702）
           期間キー: year UnsignedSmallInt（:683）/ month UnsignedTinyInt（:684）/ week UnsignedTinyInt（:685）
   ↓ 読む
【取得】   lib/db/index.ts:1857〜  reportKpis(query: ReportKpiDbQuery)
             ★「要求された期間 ＋ その1つ前（前期比の材料）」の2期間ぶんだけ引く★
             （★以前は全件読みで 実測1,633行★。★当方のシード由来★。lib/db/index.ts:1852-1855）
   ↓ 変換
【変換】   lib/db/report-mapping.ts:195-207
             kpiFromSummaryRatios(current: SummaryRatios, prev: SummaryRatios | undefined): ReportKpi
               operation_rate  = toKpiValue(current.operation_ratio, prev?.operation_ratio ?? null)
               launch_rate     = toKpiValue(current.launch_ratio,    prev?.launch_ratio ?? null)
               completion_rate = toKpiValue(current.clean_ratio,     prev?.clean_ratio ?? null)
               error_rate      = toKpiValue(current.error_ratio,     prev?.error_ratio ?? null)
             ★★サマリの値はそのまま入れてよい★★（D-094。率はすでに double(4,1)）
             ★★履歴から再計算しない★★（D-102 / CLAUDE.md 禁止事項1）
           lib/judge/kpi.ts:58-64
             toKpiValue(value: number | null, prev: number | null): KpiValue
             = { value, prev, diff: value === null || prev === null ? null : value - prev }
             ★★どちらかが null なら diff も null（0 にしない）★★
```

### 当期（進行中の週・月）の KPI（★詳細テーブルをその場集計★）

```
【計算式】 CLAUDE.md §4.2
             稼働率       = 清掃実施回数 ÷ 清掃予定回数 × 100
             出発成功率   = 定刻出発回数 ÷ 清掃予定回数 × 100
             清掃完了率   = Σ清掃面積 ÷ Σ清掃対象面積 × 100   ★面積ベース★
             エラー発生率 = エラー発生回数 ÷ 清掃実施回数 × 100
   ↓ 実装
【実装】   lib/judge/kpi.ts:20-23   rate(numerator, denominator): number | null  ★分母0は null★
           lib/judge/kpi.ts:26-46   operationRate / launchRate / completionRate / errorRate
           lib/db/report-mapping.ts:392-415  kpiFromCleanStats(bucket, prev): ReportKpi
   ↓ ★★近似の申し送り★★
【注意】   lib/db/report-mapping.ts:212-219
             ★★このプロジェクトの詳細データは「出発したか(executed)」の1ビットしか持たない★★ —
             `clean_timetables.started_at` は常に予定時刻と同値でシードされており、
             ★「定刻より遅れて出発した」を区別する情報がDBに無い★。
             ★そのため 稼働率と出発成功率は**同じ `executed` を分子に使っている**★。
             ★実データで区別できる列が判明したら、ここだけ直せばよい★
```

### 型と画面

```
【型】     KpiValue（lib/types/index.ts:564-577）= { value: number | null; prev: number | null; diff: number | null }
           ReportKpi（:656-661）= { operation_rate; launch_rate; completion_rate; error_rate }
   ↓ API
【API】    GET /reports/kpi は ★items / meta で包まない素のオブジェクト★
           該当期間の行が無ければ ★404 ではなく 200 ＋ 4指標すべて null★
             （app/api/reports/kpi/route.ts:40-58 の emptyKpi()。指示書F 4-1）
   ↓ 画面
【画面】   ReportsScreen.tsx:516-554  KpiCard 4枚
             稼働率      tone="ok"  betterWhenHigher
             出発成功率  tone="pr"  betterWhenHigher
             清掃完了率  tone="ac"  betterWhenHigher
             エラー発生率 tone="ng" ★betterWhenHigher={false}（下がったら良い）★
           components/features/reports/KpiCard.tsx
             :77    displayRate(value)
             :43-46 deltaTone(diff, betterWhenHigher) — ★丸めた値ではなく生値の符号で判定★
                    例: エラー発生率が -0.4 なら表示は「▼0%」だが色は good（改善）
             :82-94 diff !== null のときだけ▲▼行を出す（★差が取れないなら行ごと出さない★）
                    矢印は生値の符号、数値は Math.round(Math.abs(diff))
             :96    「{prevLabel}: {displayRate(prevValue)}」
【値の例】 ★シード由来★（lib/fixtures/reports.ts:130-135 kpiWeekly）
             operation_rate  = toKpiValue(91.4, 89.6) → { value: 91.4, prev: 89.6, diff: 1.7999…}
               → 表示 "91%" / ▲2% / 前週: 90%
             launch_rate     = toKpiValue(93.8, 94.2) → diff = -0.4 → ▼0% ★色は bad★
             completion_rate = toKpiValue(87.1, 84.9)
             error_rate      = toKpiValue(4.6, 5.8)   → diff = -1.2 → ▼1% ★色は good（改善）★
           ★シード由来★ 前期データなし: toKpiValue(86.0, null) → diff = null → ★▲▼行を出さない★
             （lib/fixtures/reports.ts:259-262）
```

### 422 のときの画面（★指定が足りないことを操作に変える★）

```
【画面】   ReportsScreen.tsx:458-512
             blocks.kpi.kind === "select-customer" のとき
             :473-475 「顧客を選んでください」
             :477-481 サーバが返した理由をそのまま出す（★t() に通さない★）
             :484-489 「選べる屋号が{n}件あります」（n = details.customer_ids.length）
             :492-500 「屋号を選ぶ →」ボタン → focusCustomerSelect()（:147-149）で
                       屋号セレクトに ★実際にフォーカスを移す★
                       ★誘導を文言だけで終わらせない★（§3-4）
             :502-508 候補0社（group_id 指定で傘下に屋号が無い）は
                       「この条件では選べる屋号がありません」
           判定は lib/reports/block-visibility.ts の純粋関数（★画面に決定表を写さない★。:136-140）
```

## 4.3 連続問題施設

```
【DBの列】 （★対応するサマリテーブルが無い★。D-102 項4）
           clean_timetables.start_at / clean_status_histories.clean_error / clean_ratio から算出
   ↓ 取得
【取得】   app/api/reports/continuous-problems/route.ts:67-69
             db.continuousProblems(toReportDbQuery(parsed.query, await resolveFacilityIdsByMasters(...)))
           lib/db/index.ts:2302〜  loadConsecutiveInputs() →
             computeConsecutiveProblemDays() → continuousProblemsInPeriod()
   ↓ 文言（★テンプレートのまま返す。方式A。D-111★）
【文言】   lib/db/report-mapping.ts:560-566
             continuousProblemSummary(consecutiveDays: number, launchFailedDays: number): string
               launchFailedDays === 0              → SUMMARY_TEMPLATE_COMPLETION
               launchFailedDays === consecutiveDays → SUMMARY_TEMPLATE_LAUNCH_FAILED
               それ以外                              → SUMMARY_TEMPLATE_MIXED
           テンプレートの実物（lib/db/report-mapping.ts:52-54 / :64-67）
             "{n}日連続で完了率が△以下"
             "{n}日連続で出発失敗"
             "{n}日連続で△以下（うち{m}日は出発失敗）"
             （頭打ち時）"{n}日以上連続で完了率が△以下" 等
           ★★完成した日本語文を入れないこと★★（lib/types/index.ts:627-631）— EN 切替で和文が残る
   ↓ 絞り込み・並べ替え
【絞込】   app/api/reports/continuous-problems/route.ts:90  filterContinuousProblems(inPeriod)
             ★2日以上のみ返す（L1 は含めない）★。閾値は lib/config から読む
           :91  sortContinuousProblems(filtered)  ★連続日数の降順 → facility_id の昇順★
           :102 capContinuousProblems(sorted.slice(0, limit), maxDays)
             ★★上限は「絞り込み → 並べ替え → 切り出し」の最後にかける★★（:97-99）—
               先にかけると上限に達した施設どうしの降順が潰れる
   ↓ 型
【型】     ContinuousProblem（lib/types/index.ts:611-638）
             { facility_id; facility_name; consecutive_problem_days;
               consecutive_problem_days_capped?: boolean; escalation_level: EscalationLevel;
               last_problem_on: string; summary: string; launch_failed_days?: number }
           ★capped と launch_failed_days が任意項目なのは、fixtures の手書き行を変更しないため★
             （CLAUDE.md §9.5 / D-111 項5）。★prismaSource は常に数値を入れる（0 を含む）★
   ↓ 画面
【画面】   ReportsScreen.tsx:573-627
             :576-577 レベル色: L3 → c.ng / それ以外 → c.wn
             :587     「Lv{escalation_level.replace("L","")}」
             :592-600 ★capped なら t("{n}日以上") / でなければ t("{n}日")★
             :602-604 facility_name
             :614-624 summary のテンプレートに {n}（連続日数）と {m}（launch_failed_days）を差し込む
                      ★launch_failed_days === undefined のときは {m} を置換しない★（0 を捏造しない）
             :628-641 「他{n}件を表示 →」（n = continuousMeta.total − 表示件数。:293-295）
             :642-645 ★「上位10件に戻す」はこのカードには置かない★（モックに無い）
【値の例】 ★シード由来★（lib/fixtures/reports.ts:359-）
             facility_id=9 / 連続5日 / L3 / summary "完了率が5日連続で30%未満。…"
             facility_id=2 / 連続1日 / L1 ← ★「2日以上で絞る」が効いていることの確認用。消さないこと★
             ★12件ある★（limit 既定10 → 「他N件を表示」の発火確認用）
           ★シード由来★ facility_id 13以降は「レポート用のダミー施設」で
             lib/fixtures/facilities.ts には存在しない（:352-357）
```

## 4.4 エラー種別の内訳

```
【DBの列】 facility_error_summaries.error_type   String @db.VarChar(100)（prisma/schema.prisma:775）
           facility_error_summaries.error_count  Int @db.UnsignedSmallInt（:777）
           期間キー: year（:771）/ month（:772）/ week（:773）
   ↓ 集計
【集計】   lib/db/index.ts:2381〜  errorBreakdown(query: ReportDbQuery)
             確定期間 … facility_error_summaries を読み、施設ぶん足し上げる（:2397〜）
             当期     … ★本清掃行を指す robot_error_reports★ を期間で絞って error_type ごとに数える（:2431〜）
           lib/db/report-mapping.ts:778-  errorBreakdownRowsFromSummary(rows)
           lib/db/report-mapping.ts:759-771
             errorBreakdownItemsFromCounts(counts: ReadonlyMap<string, number>): ErrorBreakdownItem[]
               type = label = ★生値★（★7分類にも日本語にも変換しない★。D-077 / D-088）
               rate = total === 0 ? 0 : count / total * 100   ★丸めない生値★
               ★分母はこの Map 内の全件数の合計（reduce で出す。定数に置かない）★
   ↓ 型
【型】     ErrorBreakdownItem（lib/types/index.ts:640-654）
             { type: string; label: string; count: number; rate: number }
   ↓ API
【API】    GET /reports/error-breakdown は ★meta を返さない★（一覧APIの例外。D-088）
           app/api/reports/error-breakdown/route.ts:31-43 に理由あり
   ↓ 画面
【画面】   ReportsScreen.tsx:670-696
             :679  t(formatRawLabel(item.label))
                   lib/format/raw-label.ts:21-26  formatRawLabel(value: string | null | undefined): string
                     null / undefined / 空文字 / 空白のみ → ★"(値なし)"★（EMPTY_VALUE_LABEL。:19）
                     それ以外 → trim しただけの値（★トリム以外の変換をしない★。指示書O 禁止事項2）
             :682-688 <ProgressBar percent={item.rate} tone={ERROR_TONE[item.type] ?? "pr"} />
                   ERROR_TONE（ReportsScreen.tsx:71-78）は ★網羅ではない★（:60-69）
                   ★未知の error_type は必ず来るので、中立色 pr にフォールバックする★
                   ★ok（緑）にしないのは、未知のエラーを「正常」に見せないため★
             :690  {item.count}
             :693  {Math.round(item.rate)}%  ★表示専用の丸め★
             :663-667 0件は「該当なし」（★モックが文言を持っていないので実装者が決めた★）
【値の例】 ★シード由来★（lib/fixtures/reports.ts:483-496。errorTotal = 58）
             EmergencyStop              18件 → rate = 18/58*100 = 31.034…% → 表示 "31%"  tone ng
             LostLocalization           12件 → 20.689…% → "21%"  tone ng
             RobotHardwareErrorToAdmin   9件 → 15.517…% → "16%"  tone wn
             LowBatteryLevel             7件 → 12.068…% → "12%"  tone ac
             CleanWaterEmpty             5件 →  8.620…% → "9%"   tone wn
             TrashFull                   3件 →  5.172…% → "5%"   tone wn
             CanNotReach                 2件 →  3.448…% → "3%"   ★ERROR_TONE に無い → pr★
             ""（空文字）                1件 →  1.724…% → "2%"   ★ラベルは "(値なし)"★
             UnknownErrorFromCloud       1件 →  1.724…% → "2%"   ★未知の値 → pr★
```

## 4.5 施設別パフォーマンス表

```
【DBの列】 facility_summaries.operation_ratio / launch_ratio / clean_ratio / error_ratio
           （prisma/schema.prisma:733-736。すべて Float @default(0.0)）
           期間キー: year（:717）/ month（:718）/ week（:719）
   ↓ 取得
【取得】   app/api/reports/facility-performance/route.ts:74-76
             db.facilityPerformance(toReportDbQuery(parsed.query, intersectFacilityIds(byName, byMasters)))
           lib/db/index.ts:2090-2098  ★要求された期間 ＋ その1つ前★ の2期間だけ引く
           lib/db/index.ts:2101-2103  ★年次（month=0 && week=0）は除外★（P1 では読まない。D-072 項4）
           lib/db/index.ts:2140-2165  buildItem(facilityId, kpi, itemRange)
             status: judgeFacilityStatus(kpi.operation_rate.value)（★直書きしない★）
             consecutive_problem_days: consecutiveProblemDaysAtPeriodEnd(...)
   ↓ 判定
【判定】   lib/judge/kpi.ts:84-91
             judgeFacilityStatus(operationRateValue: number | null): FacilityStatus
               null                                    → "no_data"
               >= FACILITY_STATUS_THRESHOLDS.good(90)   → "good"
               >= FACILITY_STATUS_THRESHOLDS.warning(75) → "warning"
               それ以外                                  → "bad"
             FACILITY_STATUS_THRESHOLDS（lib/config/index.ts:467-472。★暫定値★）
           ★★no_data を作った理由★★（決定1。2026-09-11 ユーザー確定。lib/judge/kpi.ts:78-82）—
             clean_timetables には1週間分の予定がまとめて登録される（D-106）ので
             ★週の初め・月の初めは「予定はあるが実施0件」が正常★。
             ★以前はここを「要改善」に寄せていたため、週の初めに全施設が赤くなっていた★
   ↓ 絞り込み・並べ替え
【絞込】   app/api/reports/facility-performance/route.ts:91-102
             selectFacilityPerformanceRows → status / facility_name / serial で絞る → sortFacilityPerformance
             ★稼働率の昇順（ワースト順）。同値は facility_id の昇順。null は末尾★
               （d-watch-api.yaml:710-711）
           :110  capFacilityPerformance(sorted.slice(0, limit), maxDays)
   ↓ 型
【型】     FacilityPerformance（lib/types/index.ts:586-609）
             { facility_id; facility_name; serial_number;
               operation_rate: KpiValue; launch_rate: KpiValue;
               completion_rate: KpiValue; error_rate: KpiValue;
               status: FacilityStatus; consecutive_problem_days;
               consecutive_problem_days_capped?: boolean }
           FacilityStatus = "good" | "warning" | "bad" | "no_data"（:584）
   ↓ 画面（9列）
【画面】   components/features/reports/FacilityPerformanceTable.tsx:173-183（見出し）
             施設名 / 稼働率 / {前週|前月} / 出発成功率 / 完了率 / {前週|前月} / エラー率 / 変化 / ステータス
           :198-241（セル）
             施設名     item.facility_name
             稼働率     displayRate(item.operation_rate.value)  色 = rateTone(value, "ok")
             前期       displayRate(item.operation_rate.prev)
             出発成功率 displayRate(item.launch_rate.value)     色 = rateTone(value, "pr")
             完了率     displayRate(item.completion_rate.value)
             前期       displayRate(item.completion_rate.prev)
             エラー率   displayRate(item.error_rate.value)      色 = errorRateTone(value)
             変化       diffText(overallDiff(item))             色 = diffTone(...)
             ステータス <Pill text={t(STATUS_LABEL[item.status])} color={statusColor(c, item.status)} />
   ↓ 画面側の計算（★この表で唯一フロントが計算する値★）
【計算】   FacilityPerformanceTable.tsx:98-103
             overallDiff(item): number | null
               op = item.operation_rate.diff / cv = item.completion_rate.diff
               ★どちらかが null なら null★（差を取れない以上、改善とも悪化とも言えない）
               = Math.round((op + cv) / 2)
           :105-108  diffText(diff): null か 0 → "→" / 正 → `▲{diff}` / 負 → `▼{|diff|}`
           :110-113  diffTone(diff): null か 0 → "muted" / 正 → "ok" / 負 → "ng"
   ↓ 色分け
【色】     :73-79  rateTone(value, goodTone): null → "muted" / >=90 → goodTone / >=75 → "wn" / else "ng"
           :82-85  errorRateTone(value): null → "muted" /
                    value > FACILITY_TABLE_THRESHOLDS.errorRateWarning(8) → "ng" / else "muted"
                    （lib/config/index.ts:485-488。★8.0 ちょうどは薄字のまま★）
           :121-126 STATUS_LABEL = { good:"良好", warning:"注意", bad:"要改善", no_data:"判定なし" }
           :137-142 statusColor: good→c.ok / warning→c.wn / no_data→c.txM（★中立色★）/ bad→c.ng
           ★Pill には16進のカラーコードを渡す★（:128-135）—
             Pill は `background: ${color}22` で透過背景を作るので、
             ★`var(--ok)` を渡すと `var(--ok)22` という不正な値になって背景が出ない★
   ↓ フッタ
【フッタ】 :163-164  showExpand = !showAll && totalCount > items.length
                     showCollapse = showAll && totalCount > 0
           :252      「全{totalCount}施設を表示 →」→ limit を SHOW_ALL_LIMIT(=PAGINATION.maxPerPage=200) にして再取得
                     （ReportsScreen.tsx:55 / :172）
           :263      「上位10件に戻す」
【値の例】 ★シード由来★（lib/fixtures/reports.ts:200-206。施設9）
             operation_rate  = toKpiValue(68.0, 75.0) → 表示 "68%" / 前週 "75%" / 色 ng（75未満）
             launch_rate     = toKpiValue(72.0, 80.0) → "72%" / 色 ng
             completion_rate = toKpiValue(55.0, 62.0) → "55%"
             error_rate      = toKpiValue(12.5, 9.0)  → "13%" / ★12.5 > 8 なので ng★
             status          = judgeFacilityStatus(68.0) = "bad" → Pill「要改善」（赤）
             変化            = Math.round(((68.0-75.0) + (55.0-62.0)) / 2) = Math.round(-7) = -7 → "▼7"（ng）
           ★シード由来★（施設11。lib/fixtures/reports.ts:313-318）
             operation_rate = toKpiValue(95.2, 94.0) → status = "good" → Pill「良好」（緑）
```

★★モックからの意図的な逸脱★★（`FacilityPerformanceTable.tsx:53-71`）—
モック（`d-watch-mock.jsx:802` / `:804`）は ★80 / 90 をハードコード★ していますが、
ここでは `FACILITY_STATUS_THRESHOLDS`（good=90 / warning=75）を使います。
理由: 同じ表の「ステータス」列が `judgeFacilityStatus()` の結果なので、
★稼働率セルだけ別の数値で色を塗ると「ステータス=良好なのに稼働率セル=警告色」という矛盾が起きる★。
加えて `CLAUDE.md` §7（閾値のハードコード禁止）。

---

# 第5章 清掃面積（D-141）

- 画面: `app/(dashboard)/clean-area/page.tsx` → `components/features/clean-area/CleanAreaScreen.tsx`
- API: `GET /api/clean-area`（`app/api/clean-area/route.ts` / `d-watch-api.yaml:839`）
- ★★モックに対応する画面がありません★★（`CleanAreaScreen.tsx:25-40`）。
  ★記録済み実測★: `d-watch-mock.jsx` に「面積」「㎡」「累計」のヒットは ★0件★。
  ユーザーの明示指示（2026-09-16。「新画面」）に基づく `CLAUDE.md` §5 の例外です。

## 5.1 クエリパラメータ（`d-watch-api.yaml:901-931`）

| キー | 型 / enum | 既定 | 画面 |
|---|---|---|---|
| `date_from` | `string (date)` | ★省略時・空文字は下限なし★ | `CleanAreaScreen.tsx:112`（初期値は空文字。`:89`） |
| `date_to` | `string (date)` | ★省略時・空文字は上限なし。その日を含む★ | `:113`（初期値は空文字。`:90`） |
| `group_id` / `customer_id` | `integer` | — | `:117` |
| `facility_name` | `string`（部分一致・記号と大小文字を無視） | — | `:114` |
| `group_by` | `group \| customer \| facility` | `facility` | `:109` |

★★日数の上限は設けません★★（`lib/clean-area/query.ts:14-18`）— ★`/clean-logs` との違いはここ★。
`/clean-logs` は ★塊ごとに全走査 × 件数ぶん★ 読むので上限が要りますが、
こちらは ★1本の集計クエリ★ で、返るのは ★粒度ごとの行数（最大でも施設数）★。
★期間を広げても応答の行数は増えません★。
★`MAX_DATE_RANGE_DAYS` をここに持ち込まないこと★。

★★期間の既定は「全期間」★★（`CleanAreaScreen.tsx:86-90`）— ★開いた瞬間が「累計」★になる。
★`todayJst()` 等で初期値を入れないこと★（入れると ★累計を見るために毎回クリアする画面★ になる）。

## 5.2 集計の材料

```
【DBの列】 clean_status_histories.clean_area  Float @default(0.000000)（★double(12,6)★）（prisma/schema.prisma:492）
           clean_status_histories.task_area   Float @default(0.000000)（:491）
           clean_status_histories.started_at  DateTime @db.DateTime(0)（:498）
           clean_status_histories.facility_id BigInt @db.UnsignedBigInt（:475）
   ↓ 絞る対象
【範囲】   lib/db/index.ts:1802  const range = dbDateRange(query.date_from, query.date_to)
           ★★絞る対象は started_at（実績）であって clean_timetables.start_at（予定）ではない★★
             （lib/types/index.ts:1139-1141）— ★履歴が無い清掃（未実施）はそもそも面積を持たない★
           lib/db/index.ts:1805-1814  ★集計と番人で**同じ `where`** を使う★（§6-3）—
             別々に書くと ★片方だけ期間を絞り忘れても誰も気づけない★
           ★★`query.facility_ids !== null` で分岐すること★★（:1808-1813。★事故#3★）—
             `in: undefined` は条件が消えて全件、`in: []` は `WHERE 1=0` で0件。
             ★`query.facility_ids?.length ? ... : {}` と書くと「0件ヒット」が「全件」に化ける★
   ↓ 集計クエリ
【SQL】    lib/db/index.ts:1817-1824
             prisma.cleanStatusHistory.groupBy({
               by: ["facility_id"], where,
               _sum: { clean_area: true, task_area: true },
               _count: { _all: true },        ← ★清掃実施回数＝履歴の行数（予定の数ではない）★
             })
   ↓ 番人
【番人】   lib/db/index.ts:1826-1831
             groupBy({ by: ["clean_timetable_id"], where,
                       _count: { clean_timetable_id: true },
                       having: { clean_timetable_id: { _count: { gt: 1 } } } })
             → duplicated_timetable_count = 行数
           ★★0以外なら SUM() が同じ清掃の面積を2回数えている可能性がある★★
             （lib/db/clean-area-source.ts:119-124）— ★例外もテスト失敗も出ず、数字が大きくなるだけ★
           app/api/clean-area/route.ts:85-90  reportCleanAreaAnomalies({...})
             ★ログを出せるのはルート層だけ★（DataAnomalyLogEvent は route / method が必須）
             ★異常が無ければ1行も出ない★ / ★原因の断定は書かない★
   ↓ 施設マスタ
【マスタ】 lib/db/clean-area-source.ts:161-192  loadCleanAreaFacilityMasters(prisma)
             select は id / facility_name / customer_id / customer.{customer_name, group_id, group.group_name}
             ★★`where` を足さないこと★★（:153-155）—
               ★`deleted_at` で絞ると、その施設の面積が内訳から静かに落ちるだけでなく、
                `aggregateCleanArea()` がマスタに無い facility_id を `total` からも外す設計なので
                ★★全体合計そのものが減ります★★（:22-30）
             group_id の 0 → null は groupIdFromDb()（lib/format/group-id.ts:32-34）に任せる
             ★`=== 0` をここに書かない★。★BigInt のままだと `0n === 0` が false で静かに外れる★
   ↓ 中間型
【型】     CleanAreaFacilityStats（lib/types/index.ts:1181-1189）
             { facility_id: number; clean_area: number | null; task_area: number | null; executed_count: number }
           lib/db/clean-area-source.ts:139-148  cleanAreaStatsFromGroupRows(rows)
             ★★facility_id は BigInt で返るので Number() に通すこと★★（:132-134）—
               通さないと Map のキーが `1n` と `1` で別物になり、
               ★★全施設が「マスタに無い施設」に化けて合計が 0 になります★★
             ★`_sum` の null はここで 0 にする★（対象行0件で Prisma は null を返す）
```

## 5.3 ロールアップ（純粋関数）

```
【集計】   lib/clean-area/aggregate.ts:144-209
             aggregateCleanArea(facilityStats, facilities, scope): CleanAreaAggregate
             - 施設マスタに無い facility_id は ★集計にも total にも入れず★
               unmatched_facility_ids に回す（:158-163 / :137-142）
             - `stat.clean_area ?? 0` / `stat.task_area ?? 0`（:166-167。★`||` にしない★）
             - バケットのキーは bucketKey(scope, id)（:72-74）
               ★id が null になりうるのでキーは文字列★ —
               ★数値キーの Map に null を混ぜると「グループなし」がグループID 0 と同じ扱いに化ける★
             - completion_rate = completionRate(bucket.clean_area, bucket.task_area)（:194）
               ★自前で割り算を書かない★（分母0は null。∞ / NaN にしない）
   ↓ 並び順
【並び】   lib/clean-area/aggregate.ts:219-225  compareRows
             ★clean_area の降順 → 同値なら id の昇順 → id === null（グループなし）は末尾★
             ★同値のときの順序を決め切る★（決めないと再読み込みのたびに行が入れ替わる）
   ↓ 粒度ごとの属性
【属性】   lib/clean-area/aggregate.ts:77-122  bucketSeed(scope, master)
             scope="group"    : id=group_id, name=group_name ?? "グループなし",
                                ★customer_id / customer_name は null★
                                （1つのホールディングスに屋号が複数ぶら下がりうるので
                                  「その行の屋号」が1つに決まらない。★適当に1つ選ばない★）
             scope="customer" : id=customer_id, name=customer_name, group_id/group_name も載せる
             scope="facility" : id=facility.id, name=facility_name, 屋号・ホールディングスも載せる
           CLEAN_AREA_GROUP_NONE_LABEL = "グループなし"（lib/clean-area/aggregate.ts:52）
   ↓ 型
【型】     CleanAreaRow（lib/types/index.ts:1205-1241）
             { scope: CleanAreaScope; id: number | null; name: string;
               group_id: number | null; group_name: string | null;
               customer_id: number | null; customer_name: string | null;
               clean_area: number; task_area: number;
               completion_rate: number | null; executed_count: number }
           CleanAreaTotal（:1248-1253）= { clean_area; task_area; completion_rate: number | null; executed_count }
           CleanAreaResponse（:1286-1290）= { total; items; meta }
   ↓ API
【API】    app/api/clean-area/route.ts:101-106
             meta = { total: items.length, page: 1, per_page: items.length }
             ★meta.total は items の件数（★清掃回数ではない★。清掃回数は total.executed_count）★
             ★unmatched_facility_ids は返さない★（異常の通知先はログであって画面ではない）
             ★★単位（"㎡"）もレスポンスに入れない★★（:98-100）
   ↓ 画面
【画面】   components/features/clean-area/CleanAreaScreen.tsx
             数値カード4枚（:293-324）
               清掃面積     formatNumber(total.clean_area) + <span>{AREA_UNIT}</span>
               清掃対象面積 formatNumber(total.task_area) + AREA_UNIT
               完了率       displayRate(total.completion_rate)  ★分母0は "-"。0% と書かない★
               清掃実施回数 formatNumber(total.executed_count)  ★単位「回」は付けない★（:318-320）
             注記（:330-334）★「期間を指定しないときは全期間の合計です。ただしデータの保持は2年…」★
               （★書かないと3年目に数字が減ったときに問い合わせが来る★。§2-2 / 落とし穴10）
             粒度タブ（:341-352）★並び順は CLEAN_AREA_SCOPES（=YAML の enum）が持つ★
             件数（:355-357）★items.length（絞る前の総数）★
             表（:364-405）
               名前   rowLabel(row)（:182-188）
                      ★scope === "group" && id === null のときだけ画面側の翻訳キーを使う★
                      （★API の name をそのまま t() に通すと、同名の実在する屋号名があったときに誤訳する★）
               所属   rowParent(row)（:191-200）facility → customer_name / customer → group_name ?? "グループなし"
               清掃面積     formatArea(row.clean_area)
               清掃対象面積 formatArea(row.task_area)
               完了率       displayRate(row.completion_rate)
               清掃実施回数 formatNumber(row.executed_count)
             「もっと表示」（:411-426）cleanAreaVisibility(items, expanded)
               （lib/clean-area/visibility.ts:38-50。CLEAN_AREA_INITIAL_VISIBLE = 10。lib/config/index.ts:705）
               ★総数が初期表示件数を「超える」ときだけ★（ちょうど10件なら出さない）
```

## 5.4 数値の整形

```
【整形】   lib/format/number.ts:46-52
             formatNumber(value: number): string
               !Number.isFinite(value) → ★"-"★（★∞ を画面に出さない★。:33）
               それ以外 → new Intl.NumberFormat("en-US").format(Math.round(value) + 0)
             ★Math.round を先に通す★（maximumFractionDigits: 0 任せにしない）—
               端数処理の規則（小数第1位を四捨五入。CLAUDE.md §4.3）を Intl の実装依存にしないため
             ★`+ 0` は -0 を "-0" と出さないため★
             ★★ロケールを "en-US" に固定する理由★★（:9-17）—
               引数なしの toLocaleString() は ★実行環境のロケール★ を使うので、
               サーバ（Node）とブラウザでロケールが違えば ★同じ数値が違う文字列になり★、
               ★SSR とクライアントでハイドレーション不整合になる★。
               ja-JP と en-US は3桁区切り・"," で同じなので JP/EN 切替で表示は変わらない
           lib/format/number.ts:61-63
             formatArea(value: number): string = `${formatNumber(value)}${AREA_UNIT}`
             AREA_UNIT = "㎡"（lib/config/index.ts:697）
【値の例】 ★構造由来★ 341635.4 → formatNumber → "341,635" → formatArea → "341,635㎡"
           ★構造由来★ NaN / Infinity → "-"
```

★★`AREA_UNIT` を1箇所に置いている理由★★（`lib/config/index.ts:686-696`）—
★「単位は㎡」は DFA の判断であって TeamWorks の確認ではありません★（第9回 5章で照会中）。
★回答が違っていたら全画面が間違った単位を主張することになる★ので、
★直すのはここ1行で済むようにしてあります★。★画面やAPIに "㎡" を直書きしないこと★。

★★この画面がサマリテーブルを読まない理由★★（`lib/db/clean-area-source.ts:12-15` /
`d-watch-api.yaml:847-858`）—
★構造由来で独立に成立する2点★（D-145 の訂正後も有効）:

1. ★この画面の期間は自由な日付範囲★。サマリは `year/month/week` の粒度でしか存在せず、
   ★「8月10日〜9月5日」のような範囲を合計する手段が構造的に無い★
2. ★既定が「全期間」なので必ず当期を含むが、進行中の期間はサマリテーブルに存在しない★
   （`CLAUDE.md` §4.7）

★★かつてここに書かれていた「全24行の `task_area` が 1000 ちょうど」という実測は、
当方のシード（`scripts/seed-local-db.ts:1669-1670` の `SUMMARY_TASK_AREA_SQM = 1000`）を
見たものでした★★（★D-145★。2026-09-17 に訂正済み）。
★TeamWorks の集計式への当て推量は取り下げられています★。★結論（サマリを読まない）は不変★。

★★レポート画面の完了率とこの画面の完了率は数字が食い違いえます★★
（`d-watch-api.yaml:855-858`）— ★経路が違うため★（レポートは確定期間をサマリから読む／
こちらは常に履歴から集計する）。★どちらかが壊れているわけではありません★。

---

# 付録A 5画面を貫く「事故りやすい行」の一覧（`file:line` 付き）

| # | 事故 | 実際にその行にある場所 | 壊れ方 |
|---|---|---|---|
| 1 | ★`??` を `\|\|` にする★ | `lib/db/index.ts:913`（`clean_error`）/ `lib/db/index.ts:1094-1095`（面積）/ `lib/format/clean-result.ts:35`（注記）/ `lib/db/rescue-mapping.ts:60-61` | ★`0` が `null` に化ける★。`tsc` も `lint` も捕まえない |
| 1' | ★逆に `??` にしてはいけない1箇所★ | `app/api/dashboard/route.ts:49-55` | `?date=`（空文字）が既定に倒れず、★テーブル全件読みに戻る★ |
| 2 | ★丸めてから判定する★ | `lib/judge/grade.ts:7-11`（注記）/ `lib/judge/grade.ts:41`（`displayRate` の注記） | 89.5 が ◎ になる／90.4 の ◎ が消える |
| 3 | ★`null` と `[]` を混同する★ | `lib/db/index.ts:729-736` / `:1165-1173` / `:1808-1813` / `app/api/clean-logs/route.ts:75-77` / `lib/types/index.ts:155-163` | ★「0件ヒット」が「全件表示」に化ける★（`in: undefined` は `WHERE 1=1`） |
| 4 | ★DBの DATETIME を「真の瞬間」と取り違える★ | `lib/db/date-range.ts:17` / `lib/format/date.ts:51-62` / `lib/db/index.ts:706-711` | 9時間ずれて境界の行が消える。★ローカルDBでは1件も表に出ない★ |
| 5 | ★連続日数を表示期間で絞って数える★ | `lib/db/clean-log-mapping.ts:376-381` / `lib/db/index.ts:696-700` | ★L3 が L1 に化ける★ |
| 6 | ★表示上限を判定にかける★ | `lib/clean-logs/query.ts:196-197` / `app/api/clean-logs/[id]/advice/route.ts:76-79` | 「30日以上」と出ているのに `Lv3` が出ない |
| 7 | ★`isFutureSchedule()` を外す★ | `lib/db/clean-log-mapping.ts:240-244` / `lib/db/index.ts:845-847` | ★未来の予定が全部「出発失敗」になる★ |
| 8 | ★履歴の選び方を2箇所に書く★ | `lib/db/clean-log-mapping.ts:202-207`（注記）/ `lib/db/index.ts:813` / `:1240` | `/detail-log` と `/alerts` で出発失敗の件数が食い違う |
| 9 | ★`facilities()` を施設名/分類の解決に流用する★ | `lib/db/index.ts:964-967` / `:977-981` / `lib/db/clean-area-source.ts:18-38` | ★論理削除された施設のログ・面積が静かに消える★（★罠7★） |
| 10 | ★`>` を `>=` にする（もっと表示）★ | `lib/dashboard/rescue-visibility.ts:41` / `lib/clean-area/visibility.ts:43` / `lib/format/consecutive-days.ts:36` | ★ちょうど同数のときに押しても何も増えないボタンが出る★ |
| 11 | ★`BigInt` を `Number()` に通し忘れる★ | `lib/db/clean-area-source.ts:132-134` / `lib/db/index.ts:1290` / `lib/format/group-id.ts:28-30` | ★全施設が「マスタに無い施設」に化けて合計が 0★ / `NextResponse.json` が例外 |
| 12 | ★`customers.group_id = 0` を漏らす★ | `lib/format/group-id.ts:32-34` / `lib/config/index.ts:651` | ★「グループ0」という存在しないホールディングスが画面に出る★ |
| 13 | ★`facilities.contract` を数値比較する★ | `lib/format/contract.ts:4-9` / `CLAUDE.md` §4.6 | ★全施設が「契約なし」になりメニューが全部消えるが、例外もテスト失敗も出ない★ |

# 付録B 5画面で使う設定値の一覧（★すべて構造由来★）

| 定数 | 値 | 場所 | 使う画面 |
|---|---|---|---|
| `GRADE_THRESHOLDS` | `{ excellent: 90, good: 70, poor: 10 }` | `lib/config/index.ts:18-25` | 1 / 2 |
| `ESCALATION_THRESHOLDS` | `{ level2Days: 2, level3Days: 3 }` | `:27-32` | 1 / 2 / 4 |
| `COMPLETION_DROP.dropRatio` | `0.2` | `:48-51` | 1 |
| `RESCUE_THRESHOLDS.notExecutedDays` | `3` | `:69-72` | 1 |
| `RESCUE_INITIAL_VISIBLE` | `5` | `:102` | 1 |
| `CONSECUTIVE_DAYS_DISPLAY` | `{ defaultMax: 30, minMax: 3, maxMax: 365 }` | `:133-140` | 1 / 2 / 4 |
| `CONSECUTIVE_LOOKBACK` | `{ calendarDaysPerScheduleDay: 7, plusDays: 1 }` | `:167-172` | 1 / 2 / 4 |
| `LAUNCH_GRACE_MINUTES` | `30` | `:178` | ★どの判定からも読まない★（D-091 項1） |
| `DB_DATETIME_OFFSET_MINUTES` | `540`（9×60） | `:193` | 全画面（`fromDbJst` / `toDbJst` のみ） |
| `PRECHECK_MINUTES_BEFORE` | `[60, 30]` | `:214` | 2 / 3 |
| `CLEAN_TIMETABLE_PRECHECK.main` | `0` | `:225-228` | 1 / 2 / 3 |
| `CLEAN_ERROR` | `{ normal:0, notExecuted:1, securityEnabled:2, controlBoardError:3, robotError:4 }` | `:263-274` | 1 / 2 / 3 |
| `ROBOT_STATUS_STANDBY` | `"Standby."` | `:322` | 2 / 3 |
| `PAGINATION` | `{ defaultPerPage: 50, maxPerPage: 200, maxVisiblePages: 7 }` | `:325-341` | 2 / 3 / 4 |
| `MAX_DATE_RANGE_DAYS` | `731` | `:360` | 2 |
| `MAX_CSV_ROWS` | `20_000` | `:385` | 2 |
| `REPORT_YEAR` | `{ min: 2000, max: 2100 }` | `:421-426` | 4 |
| `DB_IN_CLAUSE_MAX_ITEMS` | `1000` | `:464` | 1 / 2 / 3 |
| `FACILITY_STATUS_THRESHOLDS` | `{ good: 90, warning: 75 }` | `:467-472` | 4 |
| `FACILITY_TABLE_THRESHOLDS.errorRateWarning` | `8` | `:485-488` | 4 |
| `SUCCESS_RATE_TARGET` | `95` | `:491` | 1 |
| `MAKER_NAMES` | `{ 1:"PUDU", 2:"Keenon" }` | `:597-600` | 2 |
| `GROUP_ID_NONE` | `0` | `:651` | 4 / 5 |
| `ROBOT_STATUS_TONES` | 日本語6値 → 色 | `:676-683` | 2 / 3 |
| `AREA_UNIT` | `"㎡"` | `:697` | 5 |
| `CLEAN_AREA_INITIAL_VISIBLE` | `10` | `:705` | 5 |

# 付録C 追いきれなかったもの

| 項目 | 理由 |
|---|---|
| ★`status_1h` / `status_30m` の実DB上の取得元★ | ★未確定★（D-077 項3）。`robots.status` の46値は動作フェーズのみで、ハンドルアップ・オフラインを表現していない。現在の日本語6値は ★fixtures 由来の暫定値★ |
| ★`DsAlert.section_name` の取得元★ | ★実DB上の取得元が不明★（`lib/types/index.ts:865-870`）。fixtures では暫定のダミー値。★ダッシュボードのセキュリティバナーの `section_name` はこの影響下★ |
| ★`error_type` の実際の値の集合★ | ★Pudu API が何を返すか不明★（D-077）。`docs/handoff/analysis/error-type-mapping.md` の24値は ★参考資料であって enum ではない★ |
| ★サマリ4テーブルの集計式★ | ★定義書に記載が無く、TeamWorks の実装と一致する保証がない★（D-090 項12）。★先方の集計バッチは現在のDBで一度も動いていない★（D-145） |
| ★`launch_rate` の分子★ | ★「定刻より遅れて出発した」を区別する列がDBに無い★ため、当期の集計では稼働率と同じ `executed` を使う近似（`lib/db/report-mapping.ts:212-219`）。第9回 11章で照会中 |
| ★本番の行数・清掃回数★ | ★本番DBに接続していない★（D-148）。現在入っている行は当方のシード由来（D-145）なので、★規模の根拠に使えない★ |
