Newer
Older
client_dwatch / lib / db / report-mapping.ts
/**
 * レポート系メソッド(reportKpis / facilityPerformance / errorBreakdown / continuousProblems)を
 * 組み立てるときの変換ロジック(純粋関数のみ)。
 *
 * ★`lib/db/clean-log-mapping.ts` と同じ作法★ — DBアクセスを混ぜず、`prismaSource` の
 * 各メソッドから呼ぶだけにして、テストは fixtures 相当の入力と純粋関数だけで書けるようにする
 * (指示書T 2-4 / CLAUDE.md 禁止事項13)。
 *
 * ★`continuousProblems` は指示書Wで実装した(第6歩当時は対応するサマリテーブルが無く先送り
 *   していたが、詳細テーブルから算出する形で実現した。D-102項4)★。
 */
// ★完了率は1回ごとの完了率(`runCompletionRate()`)の単純平均(`meanCleanRatio()`)★(D-155)。
//   ★`meanCleanRatio()` は `lib/judge/kpi.ts` に移した★(2026-09-30。`CLAUDE.md` §9.1:
//   KPI計算は `lib/judge/`。★レポート画面・F-08・エリア別完了率・R-3 が同じ関数で畳む★。§5-1)。
//   ★面積比の `completionRate()` は使う箇所が0件になったので削除された★。
import {
  errorRate,
  isCompletedTask,
  judgeEscalationLevel,
  launchRate,
  meanCleanRatio,
  operationRate,
  runCompletionRate,
  toKpiValue,
} from "@/lib/judge";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { fromDbJst, jstIsoFromDb } from "@/lib/format/date";
import { isResultPending } from "@/lib/clean-logs/result-pending";
import { weekOfMonth } from "@/lib/format/period";
import type {
  ContinuousProblem,
  ErrorBreakdownItem,
  Period,
  ReportKpi,
  ReportPeriodKey,
} from "@/lib/types";
// ★`notExecutedFromHistory()` / `CleanResultLike` はもうこのファイルからは使わない★ —
//   `clean_task_reports` には `clean_error` 相当の列が無く(指示書AQ 1-1)、
//   ★未実施の判定は `aggregateCleanStatsByFacility()` の引数(述語)で受け取る★形にしたため。
//   ★新しい判定関数を作るのは担当C★(`lib/db/clean-log-mapping.ts`)。
import {
  foldProblemDaysByDate,
  isFutureSchedule,
  type ConsecutiveInput,
} from "./clean-log-mapping";
// ★`detectedAtFromError()` はもうこのファイルからは使わない★(期間帰属を本清掃の `start_at` に
//   変えたため。決定7)。★関数自体は削除しない — `prechecks()`(アラート)が使っている★
import type { PrecheckDetectedAtSource } from "./precheck-mapping";

// ---------------------------------------------------------------- 連続問題施設(summary テンプレート)

/**
 * `ContinuousProblem.summary` に返す3種のテンプレート文字列(指示書W 確定1・確定2・確定3)。
 *
 * ★数値を埋めずにテンプレートのまま返す(方式A)★ — 画面側が `{n}` に
 *   `consecutive_problem_days`、`{m}` に `launch_failed_days` を差し込む。
 * ★★`lib/i18n.ts` に足すキーと1文字も違ってはいけない★★(全角括弧・「うち」・「は」を含む)。
 *   違うと `translate()` が未登録キーとして日本語のまま返す(fail-silent。D-111 項8)。
 * ★対処の助言も原因の推定も含まない(確定1。D-111)★。
 */
export const SUMMARY_TEMPLATE_COMPLETION = "{n}日連続で完了率が△以下";
export const SUMMARY_TEMPLATE_LAUNCH_FAILED = "{n}日連続で出発失敗";
export const SUMMARY_TEMPLATE_MIXED = "{n}日連続で△以下(うち{m}日は出発失敗)";

/**
 * 連続日数が★表示上限で頭打ちになった★ときのテンプレート3種(指示書AF 段階5-1)。
 *
 * ★数値を埋めずにテンプレートのまま返す(方式A)★ — 上の3本と同じ。
 * ★★`lib/i18n.ts` に足すキーと1文字も違ってはいけない★★(全角括弧を含む)。
 *   違うと `translate()` が未登録キーとして日本語のまま返す(fail-silent。D-111 項8)。
 * ★対処の助言も原因の推定も含まない(確定1。D-111)★。
 */
export const SUMMARY_TEMPLATE_CAPPED_COMPLETION = "{n}日以上連続で完了率が△以下";
export const SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED = "{n}日以上連続で出発失敗";
/** ★★`{m}` を持たない★★ — 上限で切った窓の中の内訳は計算できないため(指示書AF §B-5) */
export const SUMMARY_TEMPLATE_CAPPED_MIXED = "{n}日以上連続で△以下(出発失敗を含む)";

// ---------------------------------------------------------------- 期間キー

/**
 * サマリ4テーブルの行(`month` / `week`)から `Period` を決める。
 *
 * | 条件 | 戻り |
 * |---|---|
 * | ★`month === 0 && week === 0`★ | ★`"yearly"`★ |
 * | `week === 0`(`month >= 1`) | `"monthly"` |
 * | それ以外 | `"weekly"` |
 *
 * ★★`month` を必ず見ること★★(`CLAUDE.md` §4.7 / 指示書AQ §3 罠2)—
 *   ★以前は `week === 0 ? "monthly" : "weekly"` で `month` を見ていなかった★ため、
 *   ★年次行(`month=0 && week=0`)が「月次」として静かに混入★した。
 *   呼び出し側の「年次除外 filter」が外れた瞬間、`customerSummariesByPeriodKey()` が
 *   ★`monthly/2026/0/0` の重複を検知して 500★ になる(=気づけるのはそこまで行った時だけで、
 *   `errorBreakdownRowsFromSummary()` 側は★年次の件数を月次に足し込んだまま黙って通る★)。
 *
 * ★★引数は「行そのもの」を受ける(`(month, week)` の2引数にしない)★★ —
 *   `month` と `week` はどちらも `number` なので、★位置を取り違えても型エラーにならない★。
 *   サマリ行はいずれも `month` / `week` を持つので、呼び出し側は `row` をそのまま渡せる。
 *
 * ★`"yearly"` は `Period`(`lib/types/index.ts`)への追加が前提★ — 追加は★担当B★の担当。
 */
export function periodOfSummaryRow(row: { month: number; week: number }): Period {
  if (row.month === 0 && row.week === 0) return "yearly";
  if (row.week === 0) return "monthly";
  return "weekly";
}

/** `${period}/${year}/${month}/${week}` の形の Map キー(`reportKpis()` の前期引き用) */
export function periodMapKey(
  period: Period,
  year: number,
  month: number,
  week: number
): string {
  return `${period}/${year}/${month}/${week}`;
}

/**
 * `customer_summaries` 1行ぶんの最小限の形(`customerSummariesByPeriodKey()` の入力)。
 *
 * ★`customer_id` を `bigint | number` の両方で受ける★ — Prisma は `bigint` を返すが、
 *   スタブや検証スクリプトが `number` を渡しても ★`1n` と `1` が別の顧客として扱われない★
 *   ようにするため(判定は `String()` で正規化する。落とし穴: `1n !== 1`)。
 */
export interface CustomerSummaryOwnerLike {
  customer_id: bigint | number;
  year: number;
  month: number;
  week: number;
}

/**
 * `customer_summaries` の行を ★期間キー → 行★ の Map にする(`reportKpis()` の確定期間用)。
 *
 * ★★複数の顧客の行が混ざっていたら例外にする(D-5。ユーザー判断 2026-09-14)★★ —
 *   「★レポートのKPIは運用担当が判断に使う数字。別会社の数字で判断されるより、
 *   画面が出ないほうがまし★」。
 *
 * ★★検知を「期間キーの衝突」だけで書かないこと★★(★指示書AH 罠5★)—
 *   壊れ方は2系統あり、別々に選ばれる:
 *
 *   | | 何が起きるか |
 *   |---|---|
 *   | 前期比 | 期間キー(`customer_id` を含まない)の Map で★後勝ち上書き★ |
 *   | 当期の値 | `selectKpiRow()` の `rows.find()` が★最初の1行★を採る |
 *
 *   ★A社が当期の行だけ・B社が前期の行だけを持つとキーは衝突しない★が、
 *   ★前期比だけが静かに汚染される★(差分が両社の差という無意味な数字になる)。
 *   だから ★判定は「全行の `customer_id` が1種類か」★ で行い、キー衝突は別途併せて見る。
 *
 * ★★同一顧客で同じ期間キーが2行来た場合も例外にする★★(起草者の判断。ユーザー決定の拡張)—
 *   スキーマに一意制約が無いので起こりえて、★壊れ方(後勝ちで不定)が同じ★だから。
 *
 * ------------------------------------------------------------------------
 * ★★2026-09-16(指示書AK ★段階6★)— `where` を `customer_id` で絞るようになった★★
 * ------------------------------------------------------------------------
 * ★以前ここには「★`customer_id` で `where` を絞って回避しないこと★」と書いてあった★ —
 *   ★分類(ホールディングス/屋号)が画面から消えていた当時(D-053)は
 *   ★★どの顧客かを決める根拠が無かった★★ため。★その前提は変わった★:
 *   `/reports/kpi` は `group_id` / `customer_id` を実際に読み、
 *   ★`resolveTargetCustomer()`(`lib/reports/target-customer.ts`)が対象顧客を1社に決める★。
 *   ★決まらなければデータ層を呼ばずに 422★(指示書AK §2-1)。
 *   ★★だから「静かに1社を選ぶ」ことは今もしていない★★(D-133 の否定は生きている)。
 *
 * ★★それでもこの関数は残す★★(★指示書AK §9-2 の指定★):
 *   - ★同じ期間キーの重複は同一顧客でも起こりうる★(`customer_summaries` に一意制約が無い。
 *     ★後勝ちで不定になる★。期待値表 6-8)
 *   - ★複数顧客の検知も防御的に残す★ — `where` の書き換えで絞りが外れたときに、
 *     ★静かに別会社の数字を混ぜるより止まるほうがまし★(D-5 のユーザー判断)
 * ★「期待する `customer_id`」を `lib/config/` に置くのは今も不可★(ユーザーが採らなかった案B)。
 *
 * ★★年次(`month === 0 && week === 0`)の扱い★★(★指示書AQ §3 罠2★)—
 *   ★`periodOfSummaryRow()` が `month` を見るようになった★ので、年次行が混ざっても
 *   期間キーは `yearly/2026/0/0` になり、★月次(`monthly/2026/9/0`)とは衝突しない★。
 *   ★以前ここには「年次を除外した**後**に呼ぶこと」と書いてあった★ — それは
 *   ★`periodOfSummaryRow()` が年次を「月次」と誤判定していた当時の回避策★であり、
 *   ★根本原因が直った今は前提条件ではない★(呼び出し側で年次を読むかどうかは
 *   `lib/db/index.ts` 側の判断。★担当G★)。
 *   ★ただし「同じ期間キーが2行」の検知はそのまま残る★ — 年次行が本当に2行あれば止まる。
 *
 * ★施設サマリ(`facility_summaries`)には広げない★(D-6)— あちらは Map キーに
 *   `facility_id` が入っているので★上書きが起きない★(壊れ方が違う)。
 *
 * ★例外メッセージには `customer_id` と期間キーだけを載せる★ — 施設名などの PII を入れない
 *   (ルート層は固定文言の500に潰すのでレスポンスには出ないが、ログには出るため)。
 */
export function customerSummariesByPeriodKey<T extends CustomerSummaryOwnerLike>(
  rows: readonly T[]
): Map<string, T> {
  // ★まず「全行の customer_id が1種類か」を見る(キー衝突より先。罠5)★
  const customerIds = new Set<string>();
  for (const row of rows) customerIds.add(String(row.customer_id));
  if (customerIds.size > 1) {
    throw new Error(
      `[report-mapping] customerSummariesByPeriodKey: customer_summaries に複数の顧客の行が混ざっています(customer_id=${[
        ...customerIds,
      ]
        .sort()
        .join(", ")})。どの顧客のKPIかを決める根拠が無いため中断します(D-5 / D-053)`
    );
  }

  const byPeriodKey = new Map<string, T>();
  for (const row of rows) {
    const key = periodMapKey(periodOfSummaryRow(row), row.year, row.month, row.week);
    if (byPeriodKey.has(key)) {
      throw new Error(
        `[report-mapping] customerSummariesByPeriodKey: 同じ期間キー(${key})の customer_summaries が複数あります(customer_id=${String(
          row.customer_id
        )})。一意制約が無く後勝ちで不定になるため中断します(D-5)`
      );
    }
    byPeriodKey.set(key, row);
  }
  return byPeriodKey;
}

// ---------------------------------------------------------------- 確定期間(サマリテーブル由来)

/** サマリ行が持つ4本の率フィールド(customer_summaries / facility_summaries 共通) */
export interface SummaryRatios {
  operation_ratio: number;
  launch_ratio: number;
  clean_ratio: number;
  error_ratio: number;
}

/**
 * サマリ行の率 + 前期行の率 → `ReportKpi`。
 * ★サマリの値はそのまま `KpiValue.value` に入れてよい(D-094。率はすでに `double(4,1)`)★
 * ★履歴(詳細テーブル)から再計算しない(D-102 / CLAUDE.md 禁止事項1)★
 */
export function kpiFromSummaryRatios(
  current: SummaryRatios,
  prev: SummaryRatios | undefined
): ReportKpi {
  return {
    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),
  };
}

// ---------------------------------------------------------------- 当期(詳細テーブルからその場集計)

/**
 * 当期のKPIを組み立てるための中間集計値(施設1件、または全社ぶんの合算)。
 *
 * ★★当期の3つの率はサマリと同じ式★★(第11回 P-6。D-158)。★どれも分母は `planned`★:
 *   稼働率 = `completed` ÷ `planned` / 出発成功率 = `executed` ÷ `planned` /
 *   エラー発生率 = `errorCount` ÷ `planned`(上限で切らない)。完了率だけは `cleanRatioSum` ÷ `cleanRatioCount`。
 *
 * - `planned` = 期間内・過去の本清掃の予定の数
 * - `executed` = そのうちレポート行がある数(★開始できた回数=出発成功率の分子★)
 * - ★`completed` = そのうち最新のレポート行の `status` が正常終了の数(稼働率の分子。`isCompletedTask()`)★
 * - `errorCount` = エラーの数(エラー発生率の分子)
 *
 * ★時刻の条件は無い★(P-6=A)。`clean_task_reports.started_at` は、予定のある清掃では予定の `start_at` と
 *   同じ値、予定外のタスクでは実際の開始時刻が入る(ユーザー回答 2026-10-05。`CLAUDE.md` §4.7)ので、
 *   ★定刻かどうかはそもそも区別できない★。★出発成功率は時刻を見ない(P-6=A)★。
 *   旧: 「`clean_timetables.started_at` は常に予定時刻と同値」と書き、稼働率と出発成功率に同じ `executed` を
 *   使っていた(その列は存在せず、記述が誤りだった。D-157)。
 *
 * ★不変条件: `completed ≤ executed ≤ planned`★(だから 稼働率 ≤ 出発成功率 ≤ 100)。
 * 確定期間(`kpiFromSummaryRatios`)はサマリの値をそのまま読む(D-158。ここは当期だけ)。
 */
export interface CleanStatsBucket {
  /** 清掃予定回数 */
  planned: number;
  /** 開始できた回数(レポート行がある回数)。出発成功率の分子 */
  executed: number;
  /** ★正常終了の回数(最新のレポート行の `status` が正常終了。`isCompletedTask()`)。稼働率の分子(P-6=A)★ */
  completed: number;
  /** 清掃対象面積の合計(㎡) */
  taskArea: number;
  /** 清掃面積の合計(㎡) */
  cleanArea: number;
  /**
   * エラー発生回数。
   *
   * ★★本清掃行(`precheck === CLEAN_TIMETABLE_PRECHECK.main`)を指す
   *   `robot_error_reports` の件数★★(TeamWorks 第8回 A7。2026-09-11。D-114 項2)—
   *   ★事前チェック行を指すエラーは含めない★(「事前チェックは本清掃を円滑に実施できる
   *   ためのものなので, サマリには含めない」という回答)。
   *   ★`clean_timetable_id` が `null` の行 / dangling の行も含めない★(決定3)。
   *
   * ★★以前ここには「事前チェック行に紐づく」と書いてあった★★ — ★第8回で質問する前の
   *   **仮定**であり、回答で逆だと分かった。★古い記述を残さない★(次に読む人が両方を信じるため)。
   *
   * ★分母は予定回数(第11回 P-6=B)★。
   *
   * ★清掃の結果は `clean_task_reports` だけから読み、`clean_error` 列は読まない★(D-151)。
   *   ★制御盤・セキュリティのエラー、清掃を開始できなかったエラーも `robot_error_reports` に入り★
   *   (第11回 P-8・P-10)、★`clean_timetable_id` も入る★(P-9=A)。
   *   ★本清掃の予定の id が入った行は、区分を問わずこの件数に入る★(どの予定の id が入るかは無回答)。
   *   ★区分(ロボット/制御盤/セキュリティ・清掃開始失敗)の見分けと D-Security アラートの取得元の
   *   切り替えは次フェーズ★(D-158 当方の対応5)。
   *   旧: 第8回 A6-1「`clean_error` 2/3 の清掃は行が作られない」— 第11回で変わった。
   */
  errorCount: number;
  /**
   * ★1回ごとの完了率(`runCompletionRate()` の戻り)の**合計**★(完了率の分子。D-155)。
   * ★名前は変えていない★(指示書AS D7)— 中身は `clean_task_reports.clean_ratio` 列ではない。
   *
   * ★★平均そのものを持たない理由★★ — ★率は加重平均できない★(`CLAUDE.md` §4.7 の
   *   「率は加重平均できないため合算できない」と同じ話)。★Σ と件数を別々に持てば、
   *   施設をまたいで合算しても平均が壊れない★(`sumCleanStatsBuckets()`)。
   *   平均を取り出すのは ★`meanCleanRatio()` だけ★。
   */
  cleanRatioSum: number;
  /**
   * 上の `cleanRatioSum` に足した件数(=完了率の平均の★分母★)。
   * ★いまは `executed` と同じ値になる★(`task_area = 0` も 0% として数えるため。P-5=B。D-158)。
   * ★それでも別に持つ★(Σ と件数の組で `meanCleanRatio()` に渡す形を変えないため)。
   */
  cleanRatioCount: number;
}

export function emptyCleanStatsBucket(): CleanStatsBucket {
  return {
    planned: 0,
    executed: 0,
    completed: 0,
    taskArea: 0,
    cleanArea: 0,
    errorCount: 0,
    cleanRatioSum: 0,
    cleanRatioCount: 0,
  };
}

function addCleanStats(a: CleanStatsBucket, b: CleanStatsBucket): CleanStatsBucket {
  return {
    planned: a.planned + b.planned,
    executed: a.executed + b.executed,
    completed: a.completed + b.completed,
    taskArea: a.taskArea + b.taskArea,
    cleanArea: a.cleanArea + b.cleanArea,
    errorCount: a.errorCount + b.errorCount,
    cleanRatioSum: a.cleanRatioSum + b.cleanRatioSum,
    cleanRatioCount: a.cleanRatioCount + b.cleanRatioCount,
  };
}

/*
 * ★`meanCleanRatio()`(清掃完了率の畳み方)はここから `lib/judge/kpi.ts` に移した★(2026-09-30)—
 *   ★挙動は同じ★(単純平均・件数0は `null`)。根拠の docstring も一緒に移してある。
 *   ★`CleanStatsBucket` は `CleanRatioTally`(`cleanRatioSum` / `cleanRatioCount`)を満たすので、
 *   そのまま渡せる★。★ここで再定義しないこと★(畳み方が2箇所に分かれる)。
 */

/** 複数施設ぶんのバケットを1つに合算する(`reportKpis()` の全社集計用) */
export function sumCleanStatsBuckets(
  buckets: Iterable<CleanStatsBucket>
): CleanStatsBucket {
  let total = emptyCleanStatsBucket();
  for (const bucket of buckets) total = addCleanStats(total, bucket);
  return total;
}

/** `clean_timetables`(本清掃のみ)1件ぶんの最小限の形 */
export interface MainTimetableLike {
  id: bigint;
  facility_id: bigint;
  start_at: Date;
}

/**
 * ★当期の率の集計(`aggregateCleanStatsByFacility()`)が読む本清掃の形★ — `MainTimetableLike` + ★`end_at`★。
 * `end_at` は ★結果待ち(`isResultPending()`)の判定用★(表2 C11。ユーザー判断 2026-10-07。D-173)。
 * ★`start_at` と同じ JST の壁時計値★(`fromDbJst()` を通す)。
 * ★`MainTimetableLike` に足さなかった理由★: エラー件数の集計(`errorCounts` 系)は終了時刻を使わないので、
 *   そちらの読み取りに列を増やさないため。
 */
export interface CleanStatsTimetableLike extends MainTimetableLike {
  end_at: Date;
}

/**
 * 本清掃行(`precheck === CLEAN_TIMETABLE_PRECHECK.main`)の id(文字列) → `start_at`。
 *
 * ★2つの用途を1本で賄う★ —
 *   1. ★キーの集合★が「本清掃行の id の Set」になる(エラーの分子の判定に使う)
 *   2. ★値★が期間帰属の基準になる(決定7。エラーの期間は本清掃の `start_at` の JST 日付)
 *
 * ★`BigInt` は `===` でキー比較できないので文字列化する★。
 */
export function mainTimetableStartAtById(
  mainRows: readonly MainTimetableLike[]
): Map<string, Date> {
  const map = new Map<string, Date>();
  for (const row of mainRows) {
    map.set(row.id.toString(), row.start_at);
  }
  return map;
}

/**
 * `robot_error_reports` を ★本清掃id単位★ の件数に集計する(★エラー率の分子★)。
 *
 * ★★分子の定義(TeamWorks 第8回 A7。2026-09-11。D-114 項2)★★:
 *   ★本清掃行を指すエラーだけを数える★。★事前チェック行を指すものは含めない★
 *   (「事前チェックは本清掃を円滑に実施できるためのものなので, サマリには含めない」)。
 *
 * ★★「本清掃行の集合に**入っている**」という肯定条件で数える★★ —
 *   `clean_timetable_id` には FK も KEY も無く(`prisma/schema.prisma`。
 *   `lib/db/index.ts` の落とし穴1)、★`null` も dangling もありうる★ので
 *   「事前チェックでない=本清掃」と推測してはいけない。
 *   ★`null` と dangling はこの条件の自然な帰結として落ちる★(決定3。特別扱いのコードを足さない)。
 */
export function errorCountByMainTimetable(
  errorReports: readonly { clean_timetable_id: bigint | null }[],
  mainStartAtById: ReadonlyMap<string, Date>
): Map<string, number> {
  const result = new Map<string, number>();
  for (const report of errorReports) {
    if (report.clean_timetable_id === null) continue;
    const key = report.clean_timetable_id.toString();
    if (!mainStartAtById.has(key)) continue;
    result.set(key, (result.get(key) ?? 0) + 1);
  }
  return result;
}

/**
 * `clean_task_reports` 1行ぶんの、この集計に必要な最小限の形(指示書AQ 決定2)。
 *
 * ★`clean_error` は持たない★ — `clean_task_reports` に ★`clean_error` 相当の列が無い★
 *   (指示書AQ 1-1)。★未実施の判定はこの型からは出せない★ので、
 *   `aggregateCleanStatsByFacility()` は判定を★引数(`isNotExecuted`)で受け取る★。
 * ★`clean_area` / `task_area` は1回ごとの完了率(`runCompletionRate()`)の材料★(D-155)。
 *   面積の合計(`taskArea` / `cleanArea`)にも足す。
 * ★`clean_ratio` は完了率に使っていない★(D-155)。★`runCompletionRate()` を列に戻すときのために
 *   残している★(指示書AS D6)。
 *
 * ★★`lib/db/clean-log-mapping.ts` の `CleanTaskReportLike`(★担当C★)とは別の型★★ —
 *   いまは列が同じだが、★用途が違う★(あちらは清掃詳細ログ1行の完了率、こちらは期間集計)ので、
 *   ★名前を分けてある★
 *   (同名の型が2つあると、`lib/db/index.ts` が両方を import したときに
 *   ★どちらの意味か読めなくなる★)。★TypeScript は構造的部分型なので、
 *   面積列を含む行はそのまま両方の引数に渡せる★。
 */
export interface CleanStatsReportLike {
  clean_ratio: number;
  task_area: number;
  clean_area: number;
  /**
   * `clean_task_reports.status`。★稼働率の分子(`isCompletedTask()`)にだけ使う★(第11回 P-6=A)。
   * ◎○△× の判定・未実施の判定には使わない(D-155 項2)
   */
  status: number;
}

/**
 * 本清掃行 + 清掃結果(`clean_task_reports`)+ エラー件数(本清掃id単位)から、
 * 期間内・施設ごとの `CleanStatsBucket` を作る。
 *
 * ★未来の予定(`isFutureSchedule()`)は分母(`planned`)にも分子にも加算しない★
 *   (指示書V 不具合A)— `clean_timetables` には1週間分の予定がまとめて登録される(D-106)ので、
 *   まだ来ていない予定を分母に入れると稼働率・出発成功率・エラー発生率が実際より下振れする。
 *   `cleanLogs()`(`lib/db/index.ts`)・`prechecks()`・連続日数の算出と同じ扱いに揃えた。
 * ★★結果待ち(`isResultPending()`)の予定も、未来の予定と同じく数えない★★(予定回数にも入れない。
 *   表2 C11。ユーザー判断 2026-10-07。D-173)— レポートがまだ届いていないだけかもしれない清掃を
 *   未実施として分母・分子に入れると、当期の率が届くまでのあいだだけ下振れする。
 *   ★バケットは作る(決定1。行ごと捨てない)★。★判定に使うレポートの有無は `isNotExecuted` と同じ述語★。
 *
 * ★★ただし「行ごと捨てない」★★(決定1。2026-09-11 ユーザー確定)— この Map のキーが
 *   `facilityPerformance()` の当期行そのものになる(`lib/db/index.ts` の当期組み立て箇所)ため、
 *   未来の行しか無い施設のバケットを作らずに捨てると、その施設が一覧から消える
 *   (週の初め・月の初めに全施設が消える不具合として実測された)。
 *   ★範囲外の行だけ `continue` で捨て、未来の行は空のバケットだけ作って加算をスキップする★。
 *
 * ★★完了率は1回ごとの完了率(`runCompletionRate()`)の単純平均★★(D-155。期間の合計どうしの
 *   面積比は D-151 で廃止したまま)— 畳み方は ★「清掃1回 = 1票」★(根拠は `meanCleanRatio()` の docstring)。
 *   ここでは ★Σ(`cleanRatioSum`)と件数(`cleanRatioCount`)を積むだけ★で、割らない。
 *   ★1回ごとの完了率を足すのは「実施と数えた行 かつ 結果行が存在する」ときだけ★
 *   (`task_area = 0` の清掃は 0% として足す。P-5=B。D-158。★いまは件数が `executed` と同じ値になる★)—
 *   未実施を 0% として混ぜると、★稼働率で既に数えている失敗を完了率でも二重に数える★
 *   (旧実装の面積比でも、未実施行は `task_area` / `clean_area` に何も足さないので
 *   分母にも分子にも入っていなかった。★その扱いを引き継ぐ★)。
 * ★`clean_area` / `task_area` の合算は ★結果行があれば未実施判定と無関係に足す★ —
 *   総清掃面積(F-08。指示書AQ 1-3)は「実際に清掃した面積」なので、
 *   完了率の都合で落とさない(これも旧実装と同じ挙動)。
 *
 * @param mainRows `clean_timetables`(precheck=本清掃のみ)の全件
 * @param reportByTimetableId 本清掃id(文字列)→ `clean_task_reports` の1行。
 *   ★どの1行を採るか(`ended_at` ?? `started_at` が最新)は呼び出し側の責務★(指示書AQ 1-4。★担当C★)
 * @param isNotExecuted ★「未実施(=出発失敗)か」を判定する述語★。
 *   ★★このファイルに規則を書かない★★ — 指示書AQ 1-1 の
 *   「★未実施 = 対応する `clean_task_reports` の行が無い★」は ★担当C★ が
 *   `lib/db/clean-log-mapping.ts` に置く。ここは★受け取って呼ぶだけ★にして、
 *   ★規則が2箇所に散らない★ようにする(D-103 項1と同じ理由)。
 *   ★引数にした(TODO コメントにしなかった)理由★: 名前で参照すると担当Cの完成待ちになり、
 *   その間このファイルのテストが書けない。★述語を注入すれば依存が切れる★。
 * @param errorCountByMain `errorCountByMainTimetable()` の戻り値(本清掃idキー)
 * @param range `periodRange()` の戻り値(JST日付の [from, to]、両端含む)
 * @param now 「まだ来ていない予定」を判定する基準時刻。★必須引数(既定値を置かない)★ —
 *   省略できると「今日の日付」でテストが通ってしまい、境界値の検証にならないため
 */
export function aggregateCleanStatsByFacility(
  mainRows: readonly CleanStatsTimetableLike[],
  reportByTimetableId: ReadonlyMap<string, CleanStatsReportLike>,
  isNotExecuted: (report: CleanStatsReportLike | undefined) => boolean,
  errorCountByMain: ReadonlyMap<string, number>,
  range: { from: string; to: string },
  now: Date
): Map<number, CleanStatsBucket> {
  const result = new Map<number, CleanStatsBucket>();
  for (const row of mainRows) {
    // ★DBのDATETIMEには日本時間の壁時計値が入っている(TeamWorks第8回A2。指示書Y)ので、
    //   `jstIsoFromDb()` でJST日付の桁を作ってから比較する★
    //   (YYYY-MM-DDは辞書順=日付順。`lib/format/period.ts` の isCurrentPeriod() と同じ考え方)
    const jstDate = jstIsoFromDb(row.start_at).slice(0, 10);
    if (jstDate < range.from || jstDate > range.to) continue;

    const facilityId = Number(row.facility_id);
    const bucket = result.get(facilityId) ?? emptyCleanStatsBucket();

    // ★期間内にはあるが、まだ来ていない予定はバケットを作るだけで加算しない(決定1)★
    if (isFutureSchedule(fromDbJst(row.start_at), now)) {
      result.set(facilityId, bucket);
      continue;
    }

    const key = row.id.toString();
    const report = reportByTimetableId.get(key);
    const executed = !isNotExecuted(report);
    // ★結果待ちも未来の予定と同じく、バケットを作るだけで加算しない(表2 C11。D-173)★
    if (
      isResultPending({
        startAt: fromDbJst(row.start_at),
        endAt: fromDbJst(row.end_at),
        now,
        hasReport: executed,
      })
    ) {
      result.set(facilityId, bucket);
      continue;
    }
    // ★正常終了(稼働率の分子。P-6=A)は `isCompletedTask()` だけが決める★(status だけで決める。面積は見ない)
    const completed = executed && isCompletedTask(report);
    // ★完了率の分子・分母に入れるのは「実施と数えた行 かつ 結果行がある」ときだけ★
    //   (結果行が無ければ足す完了率が無い。0 を足すと「0%で清掃した」に化ける)。
    //   ★1回ごとの完了率は `runCompletionRate()` だけが決める★(D-155。行をそのまま渡す。D6)。
    const runRate = executed && report !== undefined ? runCompletionRate(report) : null;

    result.set(facilityId, {
      planned: bucket.planned + 1,
      executed: bucket.executed + (executed ? 1 : 0),
      completed: bucket.completed + (completed ? 1 : 0),
      taskArea: bucket.taskArea + (report?.task_area ?? 0),
      cleanArea: bucket.cleanArea + (report?.clean_area ?? 0),
      errorCount: bucket.errorCount + (errorCountByMain.get(key) ?? 0),
      cleanRatioSum: bucket.cleanRatioSum + (runRate ?? 0),
      cleanRatioCount: bucket.cleanRatioCount + (runRate === null ? 0 : 1),
    });
  }
  return result;
}

/**
 * `CleanStatsBucket` + 前期の4率 → `ReportKpi`(当期用)。
 *
 * ★稼働率・出発成功率・エラー発生率は `lib/judge/kpi.ts` の3関数に通す★(`CLAUDE.md` §4.2)。
 *   ★3つとも分母は予定回数(第11回 P-6。サマリと同じ)★。旧: エラー発生率の分母は実施回数だった。
 * ★★完了率だけは1回ごとの完了率の単純平均(`meanCleanRatio()`。`lib/judge/kpi.ts`)★★(D-155)—
 *   ★期間の合計どうしの面積比は使わない★。理由は `meanCleanRatio()` の docstring。
 * ★4指標とも丸めない生値を `KpiValue.value` に入れる(`CLAUDE.md` §4.3)★。
 */
export function kpiFromCleanStats(
  bucket: CleanStatsBucket,
  prev: SummaryRatios | undefined
): ReportKpi {
  return {
    operation_rate: toKpiValue(
      operationRate(bucket.completed, bucket.planned),
      prev?.operation_ratio ?? null
    ),
    launch_rate: toKpiValue(
      launchRate(bucket.executed, bucket.planned),
      prev?.launch_ratio ?? null
    ),
    completion_rate: toKpiValue(meanCleanRatio(bucket), prev?.clean_ratio ?? null),
    error_rate: toKpiValue(
      errorRate(bucket.errorCount, bucket.planned),
      prev?.error_ratio ?? null
    ),
  };
}

// ---------------------------------------------------------------- 連続問題日数(facilityPerformance用)

/**
 * `computeConsecutiveProblemDays()` に渡す1件(本清掃)の最小限の形。
 * ★`facility_id` を持たせているのは、期間内で「その施設の最終日」を選ぶため★。
 * ★`ConsecutiveInput`(`lib/db/clean-log-mapping.ts`)の分割キーも `facility_id` になった
 *   (指示書V 不具合B)ので、`ConsecutiveInput` はこの型が要求するフィールドをすべて持つ。
 *   `lib/db/index.ts` では詰め替えず `ConsecutiveInput[]` をそのまま渡している。
 */
export interface FacilityConsecutiveEntry {
  id: number;
  facility_id: number;
  /** ISO8601(+09:00)。`clean_timetables.start_at` 由来 */
  scheduled_at: string;
}

/**
 * 期間内で、その施設の「最終エントリ」(scheduled_at が最大の1件)を返す。
 * 1件も無ければ `undefined`(呼び出し側が0件やnullを組み立てる)。
 *
 * ★`consecutiveProblemDaysAtPeriodEnd()` の選び方の実体はここ1箇所★
 *   (D-103。呼び出し側で「最終エントリを自分で探す」コードを書かないこと)。
 * ★`last_problem_on`(指示書W。`ContinuousProblem`)の算出にも使う★ —
 *   連続日数だけでなく、日付そのものが要る場面はこちらを直接呼ぶ。
 *
 * @param entries 全期間・全施設の行(呼び出し側で施設を絞り込まない)
 * @param range `periodRange()` の戻り値(JST日付の [from, to]、両端含む)
 */
export function lastEntryInPeriod<T extends FacilityConsecutiveEntry>(
  entries: readonly T[],
  facilityId: number,
  range: { from: string; to: string }
): T | undefined {
  let latest: T | undefined;
  for (const entry of entries) {
    if (entry.facility_id !== facilityId) continue;
    const date = entry.scheduled_at.slice(0, 10);
    if (date < range.from || date > range.to) continue;
    if (!latest || entry.scheduled_at > latest.scheduled_at) latest = entry;
  }
  return latest;
}

/**
 * 施設別パフォーマンスの `consecutive_problem_days`(★着手前確認A案。D-103★)。
 *
 * ★`computeConsecutiveProblemDays()` は「その清掃ログの時点までの連続日数」を1件ごとに返す★
 *   (`cleanLogs()` と全く同じ関数・全く同じ入力の作り方。値の意味が一致する)。
 * `facilityPerformance()` の1行は「期間」を表すため、この関数で
 * ★「期間内でその施設の最終清掃ログ」を選び、その1件の連続日数を採用する★
 * (=「その期間の最終日時点の連続日数」)。★選び方自体は `lastEntryInPeriod()` に委ねる★。
 *
 * ★期間内にその施設の清掃ログが1件も無ければ 0★(連続なしと同義に扱う。
 *   TeamWorksのサマリにこの値は存在しないため、0か算出済みの値かのどちらかになる。
 *   着手前確認のC案(`number | null` にして「未算出」を区別する)は不採用のため、
 *   ここでも null は使わない)。
 */
export function consecutiveProblemDaysAtPeriodEnd(
  entries: readonly FacilityConsecutiveEntry[],
  consecutiveById: ReadonlyMap<number, number>,
  facilityId: number,
  range: { from: string; to: string }
): number {
  const latest = lastEntryInPeriod(entries, facilityId, range);
  if (!latest) return 0;
  return consecutiveById.get(latest.id) ?? 0;
}

/**
 * 「その施設の全期間の行」から、`endDate` で終わる連続 `consecutiveDays` 日ぶんの日付を返す
 * (指示書W。`ContinuousProblem` の summary/launch_failed_days の材料)。
 *
 * ★★`facilityRows` には期間内だけでなく**その施設の全期間の行**を渡すこと★★ —
 *   ランは期間をまたぐ(前月から続いている連続が、期間内の行だけで畳むと途中で切れる)。
 *   `consecutive_problem_days` は全期間走査で出しているので、日数だけ合わせて
 *   ランの日付を期間内だけに絞ると `{m}`(`launchFailedDaysInRun()`)が過小になる。
 *
 * ★内部で `foldProblemDaysByDate()` を呼ぶ★(独自に日付を数え直さない。D-109の問題の再発防止)。
 * `endDate` が畳み込み後の日付に存在しなければ `[]`(呼び出し側は同じ行から取った日付を渡す
 * 契約なので、通常は起きない。例外にしない)。
 *
 * @param facilityRows その施設ぶんの `ConsecutiveInput[]`(全期間)
 * @param endDate `YYYY-MM-DD`。ランの最終日(`lastEntryInPeriod()` の日付)
 * @param consecutiveDays `computeConsecutiveProblemDays()` が返した連続日数
 */
export function problemRunDates(
  facilityRows: readonly ConsecutiveInput[],
  endDate: string,
  consecutiveDays: number
): string[] {
  const days = foldProblemDaysByDate(facilityRows);
  const endIndex = days.findIndex((d) => d.date === endDate);
  if (endIndex === -1) return [];
  const startIndex = Math.max(0, endIndex - consecutiveDays + 1);
  return days.slice(startIndex, endIndex + 1).map((d) => d.date);
}

/**
 * ラン(`problemRunDates()` の戻り値)のうち、出発失敗(未実施)があった日数を返す。
 *
 * ★出発失敗=`departed === false`★(【未決1】案A。D-156。指示書AS 段階3-4)—
 *   ★`completion_rate === null` で数えないこと★。`lib/db/clean-log-mapping.ts` の不変条件は
 *   ★`departed === false` ⟺ `completion_rate === null`★(P-5=B で `task_area = 0` が 0% になり、両向きに戻った。D-158)。
 *   ★それでも `departed` で数える★(指示書AU D3)— 完了率の規則がまた変わっても、未実施の数え方が
 *   巻き込まれないため(1つの値を2つの意味に兼用しない)。
 * ★同じ日に「未実施の行」と「実施した行」が混在する場合、その日は出発失敗の日として
 *   1日だけ数える(OR。D-109 決定2 の同一日畳み込みと同じ規則)★。
 * ★ランの外の日は数えない★(`runDates` に含まれる日付だけを対象にする)。
 *
 * @param facilityRows その施設ぶんの `ConsecutiveInput[]`(全期間。`problemRunDates()` と同じ入力でよい)
 * @param runDates `problemRunDates()` の戻り値
 */
export function launchFailedDaysInRun(
  facilityRows: readonly ConsecutiveInput[],
  runDates: readonly string[]
): number {
  const runDateSet = new Set(runDates);
  const failedByDate = new Map<string, boolean>();
  for (const row of facilityRows) {
    const date = row.scheduled_at.slice(0, 10);
    if (!runDateSet.has(date)) continue;
    const isFailed = !row.departed;
    failedByDate.set(date, (failedByDate.get(date) ?? false) || isFailed);
  }
  let count = 0;
  for (const failed of failedByDate.values()) {
    if (failed) count += 1;
  }
  return count;
}

/**
 * `consecutive_problem_days` と `launch_failed_days` から `ContinuousProblem.summary` の
 * テンプレート文字列を選ぶ(指示書W 確定1・確定2・確定3)。
 *
 * ★戻り値は数値を埋めていないテンプレートのまま★(方式A。画面側が `{n}` `{m}` を差し込む)。
 * ★前提: `n >= 1` かつ `0 <= m <= n`★(`n <= 0` では呼ばれない。呼び出し側が行を作らない。決定6)。
 *
 * | 条件 | 戻り値 |
 * |---|---|
 * | 出発失敗の日が1日も無い(`m === 0`) | `SUMMARY_TEMPLATE_COMPLETION` |
 * | 全日が出発失敗(`m === n`) | `SUMMARY_TEMPLATE_LAUNCH_FAILED` |
 * | 混在(C案。2026-09-11 ユーザー確定) | `SUMMARY_TEMPLATE_MIXED` |
 *
 * ★★却下した2案★★: (a)「出発失敗を優先する」(D-111項2の決定4)— 直近に清掃を実施していても
 *   「出発失敗」と書くことになり事実と食い違う。(b)「完了率が△以下」で固定 — 出発失敗を隠す。
 */
export function continuousProblemSummary(
  consecutiveDays: number,
  launchFailedDays: number
): string {
  if (launchFailedDays === 0) return SUMMARY_TEMPLATE_COMPLETION;
  if (launchFailedDays === consecutiveDays) return SUMMARY_TEMPLATE_LAUNCH_FAILED;
  return SUMMARY_TEMPLATE_MIXED;
}

/**
 * 清掃ログの日付(`YYYY-MM-DD`。重複していてよい)から、週次+月次の期間キーを列挙する。
 *
 * ★★2026-09-13(指示書AC 段階5)以降、`continuousProblems()` はこの関数を呼ばない★★ —
 *   ★期間が引数(`ReportDbQuery`)で決まるようになったので、要求された1期間だけを
 *   組み立てる★(全期間ぶんの行を作ってからルート側で1期間を選ぶのをやめた)。
 *   ★★関数とテストは残してある★★ — 「日付の集合 → 週次+月次のキー」という規則は
 *   ★`weekOfMonth()` を必ず通す★という規律込みで固定する価値があり、
 *   ★全期間ぶんの行が要る用途(バッチ・検証スクリプト)が出たときの受け皿になるため。
 *   ★使うときは必ずこの関数を経由し、「1日から7日ごと」と書き直さないこと★。
 *
 * ★`continuousProblems()` に対応するサマリテーブルが無いため(D-102項4)、
 *   `facilityPerformance()`(サマリ行から期間キーを列挙。`lib/db/index.ts`)とは違い、
 *   詳細行の日付そのものから列挙する必要がある★。
 * ★★週番号は必ず `weekOfMonth()` に通す★★ — 月跨ぎ週の帰属を知っているのは
 *   `weekOfMonth()` だけ(D-071 / `CLAUDE.md` §4.7)。自分で「1日から数えて7日ごと」と
 *   書かないこと。
 * ★`todayJst()` は呼ばない★ — 当期を特別扱いしない(決定2。確定期間も当期も同じ処理で作る)。
 * ★年次(`month=0 && week=0`)のキーは作らない★ — この関数は常に実在する日付から
 *   `weekOfMonth()` で年月を導くため、`month=0` は構造的に発生しない。
 *   ★2026-09-30 に年次を解禁した(D-151)が、この関数は変えていない★ — ★アプリ内に呼び出し元が
 *   無い★(定義とテストのみ)ため。年次キーが要る呼び出し元を足すときに、ここも年次を作るよう直すこと。
 *   旧: 「P1では読まない。D-072項4」— D-151 で破棄。
 *
 * @returns `year` → `month` → `week`(`week:0` の月次が先頭)の昇順。重複なし
 */
export function reportPeriodKeysFromDates(
  dates: readonly string[]
): ReportPeriodKey[] {
  const byKey = new Map<string, ReportPeriodKey>();
  for (const date of dates) {
    const { year, month, week } = weekOfMonth(date);
    const monthly: ReportPeriodKey = { period: "monthly", year, month, week: 0 };
    const weekly: ReportPeriodKey = { period: "weekly", year, month, week };
    byKey.set(periodMapKey(monthly.period, monthly.year, monthly.month, monthly.week), monthly);
    byKey.set(periodMapKey(weekly.period, weekly.year, weekly.month, weekly.week), weekly);
  }
  return [...byKey.values()].sort(
    (a, b) => a.year - b.year || a.month - b.month || a.week - b.week
  );
}

/**
 * 1期間ぶんの `ContinuousProblem[]` を組み立てる(指示書W 段階5)。
 *
 * ★★DB を読む `continuousProblems()`(`lib/db/index.ts`)の中に直接書くと、
 *   「連続0日は行を作らない」「ロボット0台の施設も出す」「L1もデータ層は返す」を
 *   テストできない★★(禁止事項「テストをDB依存にしない」)ため、純粋関数に切り出してある。
 *   `continuousProblems()` は「DBから材料を集めてこの関数を呼ぶだけ」にすること。
 *
 * ★組み立ての順序(この順で)★:
 *   1. `facilityNameById` を回す(`entries` を回さない — 名前の無い施設の行を作らないため。
 *      `entries` にあるが `facilityNameById` に無い施設idは無視する)
 *   2. `consecutiveProblemDaysAtPeriodEnd()` で期末時点の連続日数を求め、`<= 0` なら `continue`
 *   3. `judgeEscalationLevel()` で L1/L2/L3 を求め、`null`(0日)なら `continue`
 *      (★`as` キャストや `?? "L1"` で握り潰さない。決定6★)
 *   4. `lastEntryInPeriod()` で期間内の最終エントリを取得。無ければ `continue`
 *      (2 で0日を弾いているので通常は起きないが、`!` や `as` は使わない)
 *   5. `facilityRows = entries.filter(e => e.facility_id === facilityId)`
 *      ★★その施設の**全期間**の行を渡す★★ — ランは期間をまたぐ(前月から続く連続を
 *      期間内だけで畳むと途中で切れ、`launch_failed_days` が過小になる)
 *   6. `problemRunDates()` でランの日付配列を求める
 *   7. `launchFailedDaysInRun()` で `{m}` を求める
 *   8. `continuousProblemSummary()` で summary テンプレートを選ぶ
 *   9. 行を積む
 *
 * ★ロボット0台の施設を除外しない(決定5)★ — `facilityNameById` は呼び出し側
 *   (`prismaSource.continuousProblems()`)が `robots` を join せずに作る。この関数は
 *   `facilityNameById` にある施設をそのまま対象にするだけで、robots の有無を一切見ない。
 * ★「2日以上」の絞り込み・並べ替えはこの関数の責務ではない★(決定4。`lib/reports/query.ts` の仕事)。
 *   戻りの順序は `facilityNameById` の反復順(= Map の挿入順)のまま。
 *
 * @param entries 過去の行のみ・全施設・全期間の `ConsecutiveInput[]`
 * @param consecutiveById `computeConsecutiveProblemDays()` の戻り値
 * @param facilityNameById 施設id → 施設名(★ロボット0台の施設も入っている★)
 * @param range `periodRange()` の戻り値(JST日付の [from, to]、両端含む)
 */
export function continuousProblemsInPeriod(
  entries: readonly ConsecutiveInput[],
  consecutiveById: ReadonlyMap<number, number>,
  facilityNameById: ReadonlyMap<number, string>,
  range: { from: string; to: string }
): ContinuousProblem[] {
  const result: ContinuousProblem[] = [];
  for (const [facilityId, facilityName] of facilityNameById) {
    const days = consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, facilityId, range);
    if (days <= 0) continue;

    const level = judgeEscalationLevel(days);
    if (level === null) continue;

    const latest = lastEntryInPeriod(entries, facilityId, range);
    if (!latest) continue;

    const facilityRows = entries.filter((e) => e.facility_id === facilityId);
    const runDates = problemRunDates(facilityRows, latest.scheduled_at.slice(0, 10), days);
    const launchFailedDays = launchFailedDaysInRun(facilityRows, runDates);
    const summary = continuousProblemSummary(days, launchFailedDays);

    result.push({
      facility_id: facilityId,
      facility_name: facilityName,
      consecutive_problem_days: days,
      escalation_level: level,
      last_problem_on: latest.scheduled_at.slice(0, 10),
      summary,
      launch_failed_days: launchFailedDays,
    });
  }
  return result;
}

/**
 * `ContinuousProblem[]` に★表示上限★をかける(指示書AF 段階5-2)。★純粋関数★。
 *
 * ★★呼ぶ場所は「絞り込み → 並べ替え → 切り出し」の**後**★★ —
 *   `sortContinuousProblems()` は `consecutive_problem_days` の降順で並べるので、
 *   ★先に頭打ちにすると上限に達した施設どうしの順序が潰れます★
 *   (45日 / 31日 が両方 30日になり、元の重さの差が消える。指示書AF §G 落とし穴2)。
 *   `filterContinuousProblems()` の「2日以上」も生値で判定させる。
 *
 * ★★`escalation_level` は触らない★★ — L1〜L3 は★上限をかける前の生値★で判定済み
 *   (`CLAUDE.md` §4.3 / 指示書AF 禁止事項9)。ここで作り直すと
 *   「3日以上と出ているのに Lv3 が出ない」になる。
 *
 * ★上限に達した行は `launch_failed_days` を**落とす**★(指示書AF §B-5):
 *   `d-watch-api.yaml` が `0 <= launch_failed_days <= consecutive_problem_days` を定めており、
 *   `n` を頭打ちにするとこの不等式が壊れる(実際45日・うち40日が出発失敗 → `n=30` で `m=40 > n`)。
 *   `min(m, n)` に丸めるのは「直近n日のうち何日が出発失敗か」の捏造になる。
 *   `launch_failed_days` はもともと任意項目なので、省略しても契約違反にならない。
 *
 * @param items ★絞り込み・並べ替え・切り出し済み★の配列
 * @param maxDays 表示上限(`loadConsecutiveDaysMax()` の戻り値を渡す。既定値を直書きしない)
 */
export function capContinuousProblems(
  items: readonly ContinuousProblem[],
  maxDays: number
): ContinuousProblem[] {
  return items.map((item) => {
    const { days, capped } = capConsecutiveDays(item.consecutive_problem_days, maxDays);
    if (!capped) {
      // ★他のフィールドは一切作り直さない★(summary も launch_failed_days もそのまま)
      return { ...item, consecutive_problem_days_capped: false };
    }
    // ★`launch_failed_days` はキーごと落とす★(`undefined` を入れるのではなく列挙しない。
    //   JSON に `"launch_failed_days": null` が出ないようにするため)
    return {
      facility_id: item.facility_id,
      facility_name: item.facility_name,
      consecutive_problem_days: days,
      consecutive_problem_days_capped: true,
      // ★生値で判定済みの結果をそのまま運ぶ(判定し直さない)★
      escalation_level: item.escalation_level,
      last_problem_on: item.last_problem_on,
      summary: cappedSummaryTemplate(item.summary),
    };
  });
}

/**
 * 上限で頭打ちになったときの summary テンプレートを選ぶ(`capContinuousProblems()` 専用)。
 *
 * ★元のテンプレートからの1対1の差し替え★ — 日数と出発失敗日数の関係は変わらないので、
 *   どのテンプレートが選ばれていたかだけを見れば足りる(`continuousProblemSummary()` を
 *   もう一度呼ぶ必要は無い)。★未知の文字列はそのまま返す★(作り話をしない)。
 */
function cappedSummaryTemplate(summary: string): string {
  if (summary === SUMMARY_TEMPLATE_COMPLETION) return SUMMARY_TEMPLATE_CAPPED_COMPLETION;
  if (summary === SUMMARY_TEMPLATE_LAUNCH_FAILED) return SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED;
  if (summary === SUMMARY_TEMPLATE_MIXED) return SUMMARY_TEMPLATE_CAPPED_MIXED;
  return summary;
}

// ---------------------------------------------------------------- エラー種別内訳

/** `facility_error_summaries` 1行ぶんの最小限の形(`customer_error_summaries` は使わない) */
export interface ErrorSummaryRowLike {
  year: number;
  month: number;
  week: number;
  /** ★DBの生値。7分類へのマッピングも日本語化もしない(D-077/D-088)★ */
  error_type: string;
  error_count: number;
}

/**
 * 件数の `Map`(`error_type` → 件数)→ `ErrorBreakdownItem[]`。
 *
 * ★`type` と `label` の両方に同じ生値を入れる★(D-077/D-088。7分類にも日本語にも変換しない)。
 * ★`rate` は丸めない生値★(CLAUDE.md §4.3)。★分母はこの Map 内の全件数の合計★
 *   (`reduce` で出す。合計を定数に置かない。`lib/fixtures/reports.ts:478` と同じ作法)。
 * 件数が1件も無ければ空配列(該当0件の表示。`errorBreakdownEmpty` と同じ扱い)。
 */
export function errorBreakdownItemsFromCounts(
  counts: ReadonlyMap<string, number>
): ErrorBreakdownItem[] {
  const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
  return [...counts].map(([type, count]) => ({
    type,
    label: type,
    count,
    rate: total === 0 ? 0 : (count / total) * 100,
  }));
}

/**
 * `facility_error_summaries` の全行から、期間ごとに同一 `error_type` を全施設ぶん足し上げる
 * (★facility版で確定。D-102項4の解決 — `facilityPerformance()` と粒度を揃える★)。
 *
 * ★履歴から再計算しない(サマリの `error_count` をそのまま合算するだけ。D-102)★。
 * ★年次(`month===0 && week===0`)の行もそのまま渡してよい★(2026-09-30 に年次を解禁。D-151)。
 * 粒度の判別は `periodOfSummaryRow()` が `month` も見て行うので、年次が月次に化けることはない。
 * 旧: 「呼び出し側で年次を除外してから渡すこと(P1では読まない。D-072項4)」— D-151 で破棄。
 */
export function errorBreakdownRowsFromSummary(
  rows: readonly ErrorSummaryRowLike[]
): Array<ReportPeriodKey & { items: ErrorBreakdownItem[] }> {
  const countsByPeriodKey = new Map<string, Map<string, number>>();
  const periodInfoByKey = new Map<string, ReportPeriodKey>();

  for (const row of rows) {
    const period = periodOfSummaryRow(row);
    const key = periodMapKey(period, row.year, row.month, row.week);
    if (!periodInfoByKey.has(key)) {
      periodInfoByKey.set(key, { period, year: row.year, month: row.month, week: row.week });
    }
    const byType = countsByPeriodKey.get(key) ?? new Map<string, number>();
    byType.set(row.error_type, (byType.get(row.error_type) ?? 0) + row.error_count);
    countsByPeriodKey.set(key, byType);
  }

  const result: Array<ReportPeriodKey & { items: ErrorBreakdownItem[] }> = [];
  for (const [key, byType] of countsByPeriodKey) {
    const periodInfo = periodInfoByKey.get(key);
    // ★periodInfoByKeyはcountsByPeriodKeyと同じループで両方セットしているので必ず存在する★
    if (!periodInfo) continue;
    result.push({ ...periodInfo, items: errorBreakdownItemsFromCounts(byType) });
  }
  return result;
}

/** `robot_error_reports` 1行ぶんの最小限の形(当期のその場集計用) */
export interface ErrorReportLike extends PrecheckDetectedAtSource {
  /** ★DBの生値。7分類へのマッピングも日本語化もしない(D-077/D-088)★ */
  error_type: string;
}

/**
 * 当期(詳細テーブル)のエラー内訳を ★本清掃行を指すエラーだけ★ `error_type` ごとに数える。
 *
 * ★★分子の集合は `errorCountByMainTimetable()` と同じ★★(決定2)—
 *   同じレポート画面(F-06)に定義の違う2つの数字を並べないため。
 *   ★事前チェック行を指すもの / `null` / dangling は数えない★(TeamWorks 第8回 A7・決定3)。
 *
 * ★★期間帰属は「そのエラーが指す本清掃行の `start_at` の JST 日付」★★(決定7)—
 *   エラー率の分母は清掃予定回数(P-6=B。`CLAUDE.md` §4.2)なので、分子も予定(本清掃)を基準にする。
 *   ★`errored_at` 基準ではない★(深夜清掃が日をまたぐと分子と分母が別の期間に入るため)。
 *   `reportKpis()` / `facilityPerformance()` は `aggregateCleanStatsByFacility()` が
 *   本清掃行を期間で絞ってからエラー件数を足すので ★すでにこの帰属★。そちらに揃える向き。
 * ★`jstIsoFromDb()` で日本時間の壁時計値に補正してから日付の桁を取る★
 *   (TeamWorks 第8回 A2。指示書Y)。`YYYY-MM-DD` は辞書順=日付順。
 * ★`type` / `label` は生値のまま。7分類・日本語への変換はしない(D-077 / D-088)★
 */
export function countMainErrorTypesInRange(
  errorReports: readonly (ErrorReportLike & { clean_timetable_id: bigint | null })[],
  mainStartAtById: ReadonlyMap<string, Date>,
  range: { from: string; to: string }
): Map<string, number> {
  const counts = new Map<string, number>();
  for (const report of errorReports) {
    if (report.clean_timetable_id === null) continue;
    const startAt = mainStartAtById.get(report.clean_timetable_id.toString());
    // ★事前チェック行を指すもの・dangling はここで落ちる(肯定条件の自然な帰結)★
    if (startAt === undefined) continue;
    const jstDate = jstIsoFromDb(startAt).slice(0, 10);
    if (jstDate < range.from || jstDate > range.to) continue;
    counts.set(report.error_type, (counts.get(report.error_type) ?? 0) + 1);
  }
  return counts;
}