Newer
Older
client_dwatch / docs / データの流れ_画面6-10_20260917.md

データの流れ — 画面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)

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

// ❌ 絶対にやってはいけない
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ストアの共通土台として言及したが、★本文書の主題(画面の縦線)から外れるため行単位では追っていない★