Newer
Older
client_dwatch / lib / dashboard / aggregate.ts
/**
 * ダッシュボード(F-01)の集計。**純粋関数のみ**(DBアクセスを混ぜない)。
 *
 * 定義の正は `docs/decisions.md` D-049。
 * ★ただしスケジュール進捗の対象日と `planned` はユーザー判断 2026-10-29「案B」で変えた★(`computeScheduleProgress()`)。
 *
 * ★`lib/judge/kpi.ts` は使わない★
 *   kpi.ts は週次/月次/年次の集計期間の KPI(CLAUDE.md §4.2)用で、
 *   ここでやりたい日次の**件数カウント**とは別物。混ぜると分母の意味が変わる。
 *
 * ★丸めない★
 *   率は生値のまま返し、表示用の丸めは画面側で行う(CLAUDE.md §4.3 / §9.3)。
 */
import { toJstIso } from "@/lib/format/date";
import type { CleanLogItem, DashboardResponse, DsAlert } from "@/lib/types";

const MS_PER_DAY = 86_400_000;

/**
 * 成功とみなす判定(◎○)
 *
 * ★アラートの `main_run_succeeded`(`lib/db/precheck-mapping.ts`。D-171)もこの定数を使う★ —
 *   「本清掃は成功」の定義をダッシュボードの「成功」と揃えるため。★別の場所に同じ配列を書かないこと★
 */
export const SUCCESS_GRADES = ["◎", "○"] as const;
/** 失敗とみなす判定(△×) */
const FAILURE_GRADES = ["△", "×"] as const;

/**
 * 清掃結果サマリ。
 *
 * `failed` を「出発した△×」に限定するのは、未出発の行を
 * `not_executed` と `failed` の両方に数えて二重計上しないため。
 *
 * ★結果待ち(`result_pending`)は `pending` に数え、success / failed / not_executed に入れない★
 * (ユーザー判断 2026-10-07。D-173)。★`success_rate` の分母にも入れない★(分母0なら null)。
 * ★旗を `departed` より先に見ること★ — 結果待ちの行は `departed: false` / `grade: "×"` のままなので、
 * 後に回すと未実施に数えてしまう。
 *
 * ★予定外タスクの行(`unscheduled: true`)も同じ規則で数える★(指示書BG §2-2)— `departed: true`・`result_pending: false`
 * なので、★成功(◎○)か失敗(△×)のどちらかになり、未実施・結果待ちにはならない★(表U3 U3-1)。
 */
export function computeSummary(
  logs: CleanLogItem[]
): DashboardResponse["summary"] {
  let success = 0;
  let failed = 0;
  let notExecuted = 0;
  let pending = 0;

  for (const row of logs) {
    if (row.result_pending) {
      pending += 1;
      continue;
    }
    if (!row.departed) {
      notExecuted += 1;
      continue;
    }
    if ((SUCCESS_GRADES as readonly string[]).includes(row.grade)) {
      success += 1;
      continue;
    }
    if ((FAILURE_GRADES as readonly string[]).includes(row.grade)) {
      failed += 1;
    }
  }

  // ★`pending` は分母に入れない★(D-173)
  const denominator = success + failed + notExecuted;
  return {
    success,
    failed,
    not_executed: notExecuted,
    pending,
    success_rate: denominator === 0 ? null : (success / denominator) * 100,
  };
}

/**
 * スケジュール進捗の対象日(JST の `YYYY-MM-DD`)。
 *
 * ★`date` を省略したとき(直近モード)は JST の前日★(ユーザー判断 2026-10-29「案B」)—
 *   「ダッシュボードは前日(10/28)の清掃結果なので、今日は未実施なので今日の件数で数えちゃいけない」。
 *   朝に開くと、今夜の清掃(まだ始まっていない)を「出発 0 ÷ 予定 7」と数えていた。
 * ★`date` を指定したときはその日★(従来どおり)。
 * ★`now` は呼び出し側(ルート)で1回だけ作ったもの★(窓・直近の選び方と同じ「現在」を使う)。
 * ★`toJstIso()` で日付を切り出す★(実行環境の TZ に依存しない。JST に夏時間は無いので24時間引いてよい)。
 *
 * @param date `date` クエリ(検証済み)。省略・空文字は `null` で渡す
 */
export function scheduleProgressTargetDate(now: Date, date: string | null): string {
  if (date !== null) return date;
  return toJstIso(new Date(now.getTime() - MS_PER_DAY)).slice(0, 10);
}

/**
 * スケジュール進捗(ユーザー判断 2026-10-29「案B」)。
 *
 * - `planned` = ★対象日に開始予定の本清掃の件数 + 対象日に始まった予定外タスクの件数★ — 予定の表(`clean_timetables` +
 *   `clean_timetable_histories`、`precheck = 0`)から作った清掃ログの行と、予定外タスクの行(`unscheduled: true`。
 *   日付は `started_at`)を数える(★A案★。指示書BG §2-2。ユーザー指示 2026-10-29「割合はA案で」。表U3 U3-3)。
 *   ★`clean_schedules` の曜日の設定は使わない★(2026-10-29 ステージングで曜日の設定 7件・予定の表 5件と食い違った。
 *   旧 D-049 の「スケジュールマスタ由来」は廃止)。
 * - `completed` = そのうち出発した件数(`departed`。成功+失敗であり「完了」ではない)。
 *   ★結果待ち・未実施は `planned` にだけ入る★(それが「進み具合」)。★予定外は常に出発済みなので両方に1件ずつ入る★。
 *   ★事前チェックの回は数えない★(清掃ログの行にならない。指示書BG §5)。
 * - `rate` = completed ÷ planned × 100。★planned が 0 なら null★。★丸めない★(CLAUDE.md §4.3)
 *
 * ★施設の絞り込み・削除済みの施設/清掃エリアの除外は `logs` の側で済んでいる★(`db.cleanLogs()` と同じ行)。
 * ★未来の予定は `logs` に入っていない★(`isFutureSchedule()`)— 前日の行は全部過去なので落ちない。
 *
 * @param logs 対象日を含む清掃ログ(★他の日の行が混ざっていてよい★。日付はここで絞る)
 * @param date 対象日(YYYY-MM-DD, JST。`scheduleProgressTargetDate()` の戻り)
 */
export function computeScheduleProgress(
  logs: readonly CleanLogItem[],
  date: string
): DashboardResponse["schedule_progress"] {
  // ★日付の切り出しは文字列で行う★ — `scheduled_at` は ISO8601(+09:00)なので先頭10文字が JST の日付
  const ofDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date);
  const planned = ofDay.length;
  // 「完了」ではなく「出発」。成功+失敗の合計になる(結果待ちの行は `departed: false`)
  const completed = ofDay.filter((row) => row.departed).length;

  return {
    date,
    planned,
    completed,
    rate: planned === 0 ? null : (completed / planned) * 100,
  };
}

/**
 * セキュリティ復帰漏れのバナー。
 *
 * 解消(`resolved`)以外を未解消として扱う。
 * ★DB のデータでは `security_off` が出ないので、DB 接続時は常に空★(指示書AX D4。コードは残す — U7)。
 * 返すのは `facility_name` / `section_name` / `since` の3つだけで、
 * 自由記述の `detail` / `note` は含めない(§4 の記録禁止フィールド。
 * レスポンスに載せる必要も無い)。
 */
export function computeSecurityBanner(
  alerts: DsAlert[]
): DashboardResponse["security_banner"] {
  return alerts
    .filter((a) => a.alert_type === "security_off" && a.resolution_status !== "resolved")
    .map((a) => ({
      facility_name: a.facility_name,
      section_name: a.section_name,
      since: a.detected_at,
    }));
}