/**
* 清掃ログ(`CleanLogItem`)を組み立てるときの変換ロジック(純粋関数のみ)。
*
* ★`lib/db/ds-status.ts` と同じ作法★ — DB アクセスを混ぜず、`prismaSource.cleanLogs()`
* から呼ぶだけにして、テストは fixtures と純粋関数だけで書けるようにする
* (指示書R 2-5 / 禁止事項13)。
*
* ★ここは「判定ロジック」の置き場所ではない★ — ◎○△× と L1〜L3 の判定は
* `lib/judge/` を呼ぶ(D-090 項16。`CLAUDE.md`「判定ロジックは切替の外側に置く」は
* 「fixtures 用と Prisma 用に分岐させるな」の意味であり、「`lib/db/` の外で呼べ」ではない)。
*/
import { GRADE_THRESHOLDS, MAKER_NAMES, ROBOT_STATUS_STANDBY } from "@/lib/config";
import {
countConsecutiveProblemDays,
judgeEscalationLevel,
judgeGrade,
runCompletionRate,
} from "@/lib/judge";
import { scheduledTimetableId } from "@/lib/db/timetable-link";
import { stripMakerPrefix } from "@/lib/format/robot-model";
import type { ScheduleDayResult } from "@/lib/judge/escalation";
import type { CleanLogItem } from "@/lib/types";
/**
* `devices.maker_id` + `devices.model_name` → `CleanLogItem.model_name`(D-090 項4 の決定)
*
* ★連結規則: `"<メーカー名> <機種名>"`(半角スペース1つ区切り)★ — 例 `"PUDU CC1"`。
* fixtures(`lib/fixtures/clean-logs.ts:50` / `lib/fixtures/facilities.ts`)と
* `lib/config/index.ts` の `ROBOT_MODELS` がこの形なので、それに合わせた。
*
* ★未知の `maker_id` は `String(maker_id)` をそのまま出す★ — `prismaSource.facilities()`
* (`lib/db/index.ts` の `MAKER_NAMES[...] ?? String(...)`)と同じ扱い。「不明」等に潰すと
* 対応表に追記が必要なことに誰も気づけない(`lib/config/index.ts:91-93` / D-077 と同じ思想)。
*/
export function formatModelName(makerId: number, modelName: string): string {
const maker = MAKER_NAMES[makerId] ?? String(makerId);
// ★`model_name` がメーカー名込み("PUDU CC1")でも「PUDU PUDU CC1」にしない★(`stripMakerPrefix()`。ユーザー指示 2026-10-29)
return `${maker} ${stripMakerPrefix(maker, modelName)}`;
}
/** `robot_error_reports` のうち、状態文字列を作るのに必要な最小限の形 */
export interface RobotErrorLike {
/** ★DB の生値。7分類へのマッピングも日本語化もしない(D-077 / D-088)★ */
error_type: string;
/** `robot_error_reports.errored_at`(nullable。`prisma/schema.prisma:334`) */
errored_at: Date | null;
}
/**
* 事前チェック行に紐づくエラー群 → `status_1h` / `status_30m`(D-065 Q2 / D-098)
*
* ★エラーがあればその `error_type` を生値のまま★。★無ければ `ROBOT_STATUS_STANDBY`★。
*
* ★注意(重要)★: `Standby.` は「この行の `robots.status` を実際に読んで得た値」ではない。
* `robot_error_reports` に該当行が無いことを「エラーが無い=待機」と読み替えている
* (D-065 Q2)。値そのものは TeamWorks の `robots.status` コード表の原文
* (`docs/handoff/TeamWorks回答_第2回_20260903.md:56` の `'0' => 'Standby.'`)であり、
* 当方が作った文言ではないので D-077(推測で変換しない)に反しない。
*
* ★同じ事前チェック行に複数のエラーがある場合は `errored_at` が最新の1件を採る★
* (指示書R 2-3 の推奨)。`errored_at` が null の行は「時刻不明」として最も古く扱い、
* 時刻が同着なら入力順の先頭を採る(安定ソートなのでDBの返却順がそのまま効く)。
*/
export function robotStatusFromErrors(errors: readonly RobotErrorLike[]): string {
if (errors.length === 0) return ROBOT_STATUS_STANDBY;
const sorted = [...errors].sort(
(a, b) => (b.errored_at?.getTime() ?? -Infinity) - (a.errored_at?.getTime() ?? -Infinity)
);
return sorted[0].error_type;
}
/**
* ★「その清掃の結果が記録されているか」★ — ★`clean_task_reports` の行の有無だけを見る★
*
* ★★取得元が `clean_status_histories` → `clean_task_reports` に変わった★★
* (指示書AQ 決定2・決定3。2026-09-30 ユーザー確定)。★見ているものは変わっていない★ —
* どちらでも「対応する結果行があるか」だけを見る関数である。
*
* ★★これは「出発したか」の判定ではない★★(D-107 で改称した)—
* 旧名 `departedFromHistory()` は ★「行があるから出発した」という当方の推測★ を
* 関数名で主張していた。★出発したかどうかの判定は `notExecutedFromReport()` が正★。
*
* ★★「行が**無い**なら出発失敗」という側は残っている★★ — ただし推測ではなく
* ★「過去なのに結果行が無いのはデータ収集処理の障害だから、黙って消さずに見せる」★
* という決定として(D-107 設計3-C。fail-loud)。★その条件は
* `notExecutedFromReport()` の中に入っている★ので、★呼び出し側でこの関数と
* 別の条件を AND / OR しないこと★(D-103 項1。2箇所に規則が散る)。
*
* ★帰結として `lib/db/index.ts` からは直接呼ばれない★(`notExecutedFromReport()` /
* `completionRateFromReport()` の内部でだけ使う)。★それでも関数として残してある★ —
* 判定の言葉を1箇所に保ち、意味をテストで固定しておくため。
*
* ★型述語にしてあるのは呼び出し側で型を絞るため★(実行時の挙動は `report !== undefined`)。
*
* @param report `clean_task_reports` から `clean_timetable_id` で引いた行(無ければ undefined)
*/
export function hasCleanResult<T>(report: T | undefined): report is T {
return report !== undefined;
}
/**
* `clean_task_reports` のうち、★完了率の算出に必要な最小限の形★
*
* ★★完了率は `clean_area` / `task_area` から `runCompletionRate()` で出す★★(D-155。第11回で確定。D-158)—
* ★出所★: `task_area Float` / `clean_area Float`(`prisma/schema.prisma:533-534`。
* 実DDLは `double(12,6)`。D-094)。★Prisma の型は `number`(Decimal ではない)★。
* ★旧: D-151(指示書AQ 決定4)では「`clean_area` / `task_area` を足さないこと。完了率は
* `clean_ratio` を正とする」だった★が、TeamWorks から「`clean_ratio` 列の計算式が間違っている」と
* 回答があり(第10回 N-3)、サマリと同じ1回ごとの面積比に変えた(D-155 項1)。
*
* ★★`clean_error` は持っていない★★ — ★`clean_task_reports` にその列が無い★
* (`prisma/schema.prisma:517-558`)。旧 `CleanResultLike` は削除した。
*/
export interface CleanTaskReportLike {
/** 清掃した面積の生値(同じ場所を複数回清掃した面積の総和。第10回 N-4) */
clean_area: number;
/** 清掃対象の面積の生値。★0 のとき完了率は 0★(`runCompletionRate()`。P-5=B。旧: null) */
task_area: number;
/**
* ★完了率には使っていない★(D-155。指示書AS D6)。
* ★`runCompletionRate()` を `clean_ratio` 列に戻すときのために残している★
* (`prisma/schema.prisma:539`。実DDLは `double(4,1)`)。
*/
clean_ratio: number;
}
/**
* ★「清掃が実施されなかった(=出発失敗)」の判定★ — ★`cleanLogs()` と `prechecks()` が共有する★
*
* ★★判定式(指示書AQ §1-1。2026-09-30 ユーザー確定)★★:
* ```
* 未実施 = 本清掃の clean_timetables 行に対応する clean_task_reports の行が無い
* ```
*
* ★★旧 `notExecutedFromHistory()` からの変更点★★ —
* 旧実装は ★第1分岐(行が無い)★ と ★第2分岐(`clean_error === 1`)★ の OR だった。
* ★★第2分岐は消えた★★ — ★新しい取得元 `clean_task_reports` に `clean_error` 列が無い★
* (`prisma/schema.prisma:517-558`)ため、★条件として成立しない★。
* ★残った第1分岐は旧実装のものと同一の規則★なので、
* ★これは新しい推測ではなく既にあった規則の片方を残しただけ★(指示書AQ §1-1)。
* ★`clean_task_reports.status` を代わりに読まないこと★ —
* ★`status` は判定に使わない★(D-155 項2。ユーザー決定 2026-10-02。`prisma/schema.prisma:529`。
* 値の意味は第10回 N-2 で受領済み)。未実施は「行が無いこと」だけで決める。
*
* ★★`undefined`(レポート行が無い)を `true`(出発失敗)にする理由★★ —
* ★これは推測ではなく決定★(D-107 / 設計3-C。指示書AQ §1-1 で取得元を変えても維持)。
* ★過去なのに結果行が無いのはデータ収集処理の障害★である。
* ★★行ごと結果から消すと(fail-silent)、運用担当は「何かが足りない」ことにすら
* 気づけない★★ — 画面には何も出ず、件数がそっと減るだけ。出発失敗として並べば
* 「翌朝確認型」の運用の中で異常として目に入る(`CLAUDE.md` §1)ので、
* ★fail-silent より fail-loud を採る★。
* ★帰結★: 前提が外れていても出発失敗が画面から消えることはなく、
* 代わりに「結果がまだ書かれていないだけの清掃」まで出発失敗として出る(★過剰検知★)。
* ★意図的に過剰検知の側を選んでいる★。
*
* ★★「出発失敗」と「未実施」は同一扱いのまま★★(ユーザー確認 2026-09-30)。
* `CleanLogItem.departed === false`(清掃詳細ログ F-02)と
* `PrecheckItem.severity === "launch_failed"`(アラート F-03)は ★同じ出来事★であり、
* ★出発失敗の定義はこの関数1本だけ★(D-103 項1。2箇所に書かない)。
*
* ★★`clean_timetable_id` が NULL のレポート行の扱いに注意★★(指示書AQ 落とし穴1)—
* `clean_task_reports.clean_timetable_id` は ★nullable★(`prisma/schema.prisma:524`)。
* NULL は「予定外のタスク」(予定を持たない実在の清掃。TeamWorks 第10回 N-1 = A)で、
* `latestReportByTimetableId()` が Map に入れない(★完了率・実施回数に数えない★。D-155 項3)。
* ★`0` も予定外★(TeamWorks 回答 2026-10-27。判定は `scheduledTimetableId()` の1本)。
* 予定(本清掃)側から見れば、★引き当たるレポートが無ければ「レポート行が無い」=未実施★として出る。
*
* ★「予定+猶予30分」を当方で計算しない★(D-091 項1。`LAUNCH_GRACE_MINUTES` は読まない)。
* ★未来の予定を除外するのは `isFutureSchedule()` の仕事★(必須。同関数参照)。
*
* ★★引数を総称型にしてある★★ — ★もう読むべき列が1つも無い★(行の有無だけが判定材料)ので、
* 特定のカラムを要求する型にすると「その列を読んでいる」という誤解を型が主張してしまう。
*
* @param report `clean_task_reports` から `clean_timetable_id` で引いた行(無ければ undefined)
*/
export function notExecutedFromReport<T>(report: T | undefined): boolean {
// ★`report === undefined` をインラインで書かず `hasCleanResult()` を通す★
// (判定の言葉を1箇所に保つため)
return !hasCleanResult(report);
}
/**
* `clean_task_reports` → `CleanLogItem.completion_rate`(★1回ごとの完了率 / 未実施は null★)
*
* | 入力 | 戻り |
* |---|---|
* | ★レポートの行が無い★ | ★`null`★(未実施) |
* | それ以外 | ★`runCompletionRate()`★(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て。★`task_area = 0` は 0★) |
*
* ★★経緯★★: D-151(2026-09-30。指示書AQ 決定4)では `clean_task_reports.clean_ratio` 列の生値だった。
* TeamWorks から「`clean_ratio` 列の計算式が間違っている」と回答があり(第10回 N-3。2026-10-02)、
* ★サマリと同じ1回ごとの面積比(上限100・小数第1位切り捨て)に変えた★(D-155 項1。★暫定★)。
* ★第11回で確定した★(P-2=A。`task_area = 0` は 0%: P-5=B。D-158。旧: `task_area = 0` は null)。
* ★1回ごとの完了率を決めるのは `runCompletionRate()` だけ★ — ここで割り算を書かないこと。
* ★行をそのまま渡す★(`clean_ratio` 列に戻すときに `runCompletionRate()` の1関数で済むように。D6)。
* ★期間の合計どうしの面積比(Σ`clean_area` ÷ Σ`task_area`)は廃止したまま★(ここは1回ずつ割る)。
*
* ★★「0%で実施した」と「未実施」は別物★★(★この区別は維持する★。指示書U 落とし穴1)—
* ★行があって `clean_area = 0`(`task_area > 0`)なら `0` を返す(`null` にしない)★(表1 R8)。
* 潰すと「0%で実施した」に化け、
* ★判定(◎○△×)はどちらも × なので画面もテストも赤くならないまま壊れる★。
*
* ★★不変条件★★: `notExecutedFromReport()` と対になっており、
* ★`departed === false` ⟺ `completion_rate === null`★ が保たれる(★P-5=B で両向きに戻った★。D-158。
* 旧: `task_area = 0` の清掃が `departed === true` で `completion_rate === null` になり、逆は成り立たなかった)。
* ★それでも「未実施」は `completion_rate` ではなく `departed` で数える★(【未決1】案A。D-156 / 指示書AU D3)
* — 完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため(要レスキュー R-1・連続問題施設の
* 出発失敗日。`ConsecutiveInput.departed` / `RescueRowInput.departed`)。
* ★連続日数の問題日(`isProblemCompletion()`)は `null` を問題日のまま数える★(判定 × と整合する)。
* `lib/dashboard/aggregate.ts` の「予定 / 実施」は `departed` を数えている。
*
* ★丸めないこと★(`CLAUDE.md` §4.3)— 切り捨ては `runCompletionRate()` の中の1回ごとの値だけ。
*/
export function completionRateFromReport(
report: CleanTaskReportLike | undefined
): number | null {
// ★判定は `notExecutedFromReport()` を通す★(規則が2箇所に散らないように)。
// 型述語を兼ねる `hasCleanResult()` で絞ってから返す
if (notExecutedFromReport(report) || !hasCleanResult(report)) return null;
return runCompletionRate(report);
}
/**
* `clean_task_reports` を `clean_timetable_id` で引ける Map にする
* — ★`cleanLogs()` と `prechecks()` が共有する★
*
* ★1本清掃 = 1レポートの想定だが、実データで複数あったときに黙って先頭を採らないよう
* ★順序キーが最大の1件★を残す★(「今の状態」は `clean_statuses` 側。D-093 項1)。
* ★同着は入力順の先頭★(`<` 比較なので後続で上書きされない)。
*
* ★★順序キー = `ended_at` があればそれ、無ければ `started_at`★★(指示書AQ §1-4)—
* ★`clean_task_reports.ended_at` は nullable★(`prisma/schema.prisma:541`。
* 旧取得元 `clean_status_histories.ended_at` は NOT NULL だったので型ごと直した)。
* ★★この規則を決める場所はこの関数1箇所だけ★★(指示書AQ §1-4)—
* 呼び出し側や `orderBy` で別の順序キーを書くと、
* `/detail-log` と `/alerts` で選ばれる行が食い違う(D-103 が禁じた状態)。
* そのため ★`reportOrderKey()` は export していない★。
*
* ★★`clean_timetable_id` が NULL の行は Map に入れない★★(指示書AQ 落とし穴1)—
* ★`clean_task_reports.clean_timetable_id` は nullable★(`prisma/schema.prisma:524`)。
* NULL をキーにすると `"null"` という実在しない `clean_timetable_id` のエントリが生まれ、
* ★たまたま同じキーになった複数の無関係なレポートが互いを上書きし合う★。
* ★落として構わない理由★: NULL は「予定外のタスク」(予定を持たない実在の清掃。
* TeamWorks 第10回 N-1 = A)で、本清掃 id で引く対象ではなく、★完了率・実施回数にも
* 数えない★(D-155 項3)。★設計上ここから外れる行★であり、TeamWorks が後で埋める想定ではない。
* ★★`clean_timetable_id` が `0` の行も同じく入れない★★(TeamWorks 回答 2026-10-27「0は予定外タスク」)—
* ★判定は `scheduledTimetableId()`(`lib/db/timetable-link.ts`)の1本★。`=== null` だけだと
* `"0"` というキーのエントリが生まれ、予定外のレポートどうしが上書きし合う(NULL のときと同じ事故)。
*
* ★キーは `clean_timetable_id.toString()`★ — `BigInt` はキーとして `===` で比較できない。
*
* ★申し送り★: 1つの `clean_timetable_id` にレポートが必ず1行かは TeamWorks へ確認中
* (D-107 の申し送り 項8)。複数あり得る場合にこの規則が妥当かの裏取りが要る。
*/
export function latestReportByTimetableId<
T extends { clean_timetable_id: bigint | null; ended_at: Date | null; started_at: Date },
>(reports: readonly T[]): Map<string, T> {
const byTimetableId = new Map<string, T>();
for (const report of reports) {
// ★予定外(NULL・0)はキーにできない("null" / "0" という偽のキーを作らない)★
const timetableId = scheduledTimetableId(report.clean_timetable_id);
if (timetableId === null) continue;
const k = timetableId.toString();
const current = byTimetableId.get(k);
if (!current || reportOrderKey(current) < reportOrderKey(report)) {
byTimetableId.set(k, report);
}
}
return byTimetableId;
}
/**
* ★「最新1件」を決める順序キー★(指示書AQ §1-4)— ★module-private。export しないこと★
*
* ★`ended_at` があればそれ、無ければ `started_at`★。
* `started_at` は NOT NULL(`prisma/schema.prisma:540`)なので必ず値が決まる。
*
* ★`??` を `||` にしないこと★ — `Date` はオブジェクトなので今は差が出ないが、
* 将来キーを数値に変えたときに `0`(1970-01-01)が落ちる。
*/
function reportOrderKey(report: { ended_at: Date | null; started_at: Date }): number {
return (report.ended_at ?? report.started_at).getTime();
}
/**
* ★清掃詳細ログの行の一意キー★(`CleanLogItem.row_key`。指示書BG §2-1)— ★作るのはこの2関数だけ★。
*
* - 予定のある行 … `"tt-<予定の id>"`(`clean_timetables.id`)
* - 予定外の行 … `"rp-<report_id>"`(`clean_task_reports.report_id`)
*
* ★前置きを分けてある★ので、予定の id とレポートの id が同じ数字でも重ならない。
* 画面の React の key と行の開閉状態はこのキーで持つ(予定外の行は `id` が null で重なるため)。
*/
export function scheduledCleanLogRowKey(timetableId: number | bigint): string {
return `tt-${timetableId.toString()}`;
}
/** 予定外の行のキー(`scheduledCleanLogRowKey()` の対)。`report_id` は文字列のまま(19桁で安全な整数を超える) */
export function unscheduledCleanLogRowKey(reportId: string): string {
return `rp-${reportId}`;
}
/**
* ★予定外タスクの1件★(`clean_task_reports` のうち `clean_timetable_id` が 0 か NULL の行)から
* 清掃詳細ログの1行を作るのに要る最小限の形(指示書BG §1・§2-1)。
*/
export interface UnscheduledReportLike extends CleanTaskReportLike {
/** `clean_task_reports.report_id`(主キー。varchar(20)) */
report_id: string;
/** ★0 か NULL のときだけ予定外★(`scheduledTimetableId()` の1本で見る) */
clean_timetable_id: bigint | number | null;
/** `clean_task_reports.status` の生値(表示名にするのは画面・CSV。D-155 項2) */
status: number;
/** ★`started_at` の JST の ISO8601(`jstIsoFromDb()` を通した値)★ — 予定外の行の `scheduled_at` になる */
started_at_iso: string;
}
/** 予定外の行の、施設・ロボットの表示に要る値(予定のある行と同じ出し方。呼び出し側が詰める) */
export interface UnscheduledRowContext {
facility_id: number;
facility_name: string;
/** 清掃エリアに有効なロボットがいなければ null(予定のある行と同じ。指示書BB U2・D-182) */
model_name: string | null;
serial_number: string | null;
}
/**
* ★予定外タスクか★ — ★`scheduledTimetableId()` を通す★(`=== null` を直書きしない。指示書BG §5)。
* 0 と NULL を同じに扱う(表U1 U1-3)。
*/
export function isUnscheduledReport(report: { clean_timetable_id: bigint | number | null }): boolean {
return scheduledTimetableId(report.clean_timetable_id) === null;
}
/**
* 予定外タスクの1件 → 清掃詳細ログの1行(★連続日数・L1〜L3 は呼び出し側が渡す★。純粋関数。指示書BG §2-1)。
*
* | 項目 | 値 |
* |---|---|
* | `id` | ★`null`★(予定の id が無い。`report_id` は19桁で安全な整数を超えるので数値の id にしない) |
* | `row_key` / `report_id` / `unscheduled` | `"rp-<report_id>"` / `report_id` / `true` |
* | `scheduled_at` | ★`started_at`★(実際に始めた時刻。CLAUDE.md §4.7) |
* | 事前チェック4列 | ★`null`★(予定外には事前チェックの回が無い) |
* | `departed` | ★`true`★(清掃結果の行そのものが起点なので、出発済み。`notExecutedFromReport()` と同じ答え) |
* | `task_status` | `status` の生値 |
* | `result_pending` | ★`false`★(結果はもう届いている) |
* | `completion_rate` | `completionRateFromReport()`(= `runCompletionRate()`。予定のある行と同じ1本。`task_area = 0` は 0) |
* | `grade` | `judgeGrade()`(予定のある行と同じ) |
*
* ★予定のある行(`clean_timetable_id` が 0 でも NULL でもない)を渡したら例外★ — 呼び出し側の絞り込みの誤りを
* 黙って予定外の行に化けさせない。
*/
export function unscheduledCleanLogItem(
report: UnscheduledReportLike,
context: UnscheduledRowContext,
consecutiveProblemDays: number
): CleanLogItem {
if (!isUnscheduledReport(report)) {
throw new Error(
`[clean-logs] 予定のあるレポート(report_id=${report.report_id})を予定外の行にしようとしました`
);
}
const completionRate = completionRateFromReport(report);
return {
id: null,
row_key: unscheduledCleanLogRowKey(report.report_id),
report_id: report.report_id,
unscheduled: true,
facility_id: context.facility_id,
facility_name: context.facility_name,
model_name: context.model_name,
serial_number: context.serial_number,
scheduled_at: report.started_at_iso,
status_1h: null,
status_1h_minutes: null,
status_30m: null,
status_30m_minutes: null,
// ★`notExecutedFromReport()` を通す★(行があるので必ず false = 出発済み。規則を2箇所に書かない)
departed: !notExecutedFromReport(report),
task_status: report.status,
result_pending: false,
completion_rate: completionRate,
grade: judgeGrade(completionRate),
consecutive_problem_days: consecutiveProblemDays,
escalation_level: judgeEscalationLevel(consecutiveProblemDays),
};
}
/**
* ★清掃詳細ログの並び順★(`scheduled_at` の新しい順。同着は予定のある行を先に、その中は `id` 昇順、
* 予定外どうしは `row_key` 昇順)— 予定のある行と予定外の行を混ぜて並べる(指示書BG §2-1)。
* ★予定のある行どうしの順序は今までと同じ★(`scheduled_at` 降順 → `id` 昇順)。
*/
export function compareCleanLogItems(a: CleanLogItem, b: CleanLogItem): number {
if (a.scheduled_at !== b.scheduled_at) {
return a.scheduled_at < b.scheduled_at ? 1 : -1;
}
if (a.id !== null && b.id !== null) return a.id - b.id;
if (a.id !== null) return -1;
if (b.id !== null) return 1;
return a.row_key < b.row_key ? -1 : a.row_key > b.row_key ? 1 : 0;
}
/**
* ★「予定時刻がまだ来ていない本清掃」か★ — ★`cleanLogs()` と `prechecks()` が共有する★
*
* ★なぜ要るか★: `clean_timetables` には ★1週間分の清掃予定がまとめて登録される★
* (ユーザー回答 2026-09-10。「仮に予定が無い場合は『清掃予定なし』として異常にはしない」)。
* つまり ★最大7日先までの本清掃行が常時DBに存在する★。
* ★★`notExecutedFromReport()` は「レポートの行が無い」を出発失敗と判定する★★(D-107 設計3-C)ため、
* ★これから実施する予定の行まで全部「出発失敗」として並んでしまう★
* (実測: 3日後の予定を1行入れると `launch_failed` が 13 → 14 件に増えた)。
* ★まだ時刻が来ていない清掃は「出発しなかった」とは言えない★ので、
* 出発失敗/未出発の判定対象から外す。
*
* ★★本関数は D-107(設計3-C)の下では**必須**★★ — ★消さないこと★。
* ★未来の予定にはまだレポートの行が無いので、外すと未来の清掃が**全部「出発失敗」になる**★。
* ★加えて逆向きの事故も止める★ — もし TeamWorks が「予定の行を作った時点でレポートの行も
* 先に作る」実装だった場合、未来の予定に面積が既定値(`clean_area = 0`)のレポートが付き、
* ★未来の清掃が「完了率0%・×」として並ぶ★(`task_area` も既定値 0 なら完了率 0・×。P-5=B)。本関数はその事故も1行で止める。
* ★★取得元が `clean_task_reports` になって、この危険は増している★★ —
* ★未実施の判定材料が「行の有無」だけになった★ため、行が先に作られると
* ★全部「実施済み・完了率0%」に化け、例外もテスト失敗も出ない★(指示書AQ §1-1)。
*
* ★★判定材料は `start_at` と現在時刻だけ★★ —
* ★`LAUNCH_GRACE_MINUTES`(猶予30分)をここから読まない★(D-091 項1 / 指示書R 禁止事項3)。
* 「予定+猶予30分」を当方で計算するのは禁止されている。
* ★本関数がやっているのは「まだ予定時刻に達していない」ことの判定であって猶予の話ではない★ —
* ★猶予分を足したくなっても足さないこと★。足した瞬間に、設定値を変えると過去のログの
* 判定まで変わるという D-091 項1 が禁じた状態になる。
*
* ★`now` を引数にしてある理由★: テストが実行時刻で揺れないようにするため(`CLAUDE.md` §9.4)。
* 呼び出し側は1回の集計で ★同じ `now` を使い回す★こと(行ごとに `new Date()` を作ると、
* 走査の途中で時刻が進んで境界の行の扱いがぶれる)。
*
* ★同時刻(`start_at === now`)は「来ていない」に入れない★ — 予定時刻には達しているので
* 出発失敗の判定対象に含める(`>` であって `>=` ではない)。
*
* @param startAt `clean_timetables.start_at`(本清掃の予定開始時刻)
* @param now 判定の基準時刻。既定は現在時刻
*/
export function isFutureSchedule(startAt: Date, now: Date = new Date()): boolean {
return startAt.getTime() > now.getTime();
}
/**
* その清掃が「問題あり」か(△以下 または 未実施)。
*
* ★`CLAUDE.md` §4.1 の△は「10%以上70%未満」、§4.5 のR3/R4は「△以下」なので、
* 完了率ちょうど 70.0(判定は ★○★)は「問題なし」。
* ★★`<` であって `<=` ではない★★(★D-139。ユーザー指示 2026-09-16
* 「◯、×は90%以上のように境はふくめてください」で境界が上のランクに入るよう変わった★)。
* ★★旧 D-020b 下では `<=` が正しかった★★ — 当時は 70.0 が △ だったため
* (指示書V 決定3。2026-09-11 ユーザー確定)。★その前提ごと覆っている★。
*
* ★★`judgeGrade()`(`lib/judge/grade.ts`)と結果が常に一致する★★ —
* 入力が違うだけの同じ規則なので、★片方の比較演算子だけを動かさないこと★
* (`judgeGrade()` の「○ = 70%以上」より、△× ⟺ `null` または 70 未満)。
*
* ★★閾値は `GRADE_THRESHOLDS.good` から読む。`70` を直書きしない★★(`CLAUDE.md` §7)。
*
* ★`lib/fixtures/clean-logs.ts:226` の `consecutive_problem_days` の生成式
* (`rate === null || rate < GRADE_THRESHOLDS.good`)は★同じ概念で、既に `<`★なので
* D-139 後はそのまま一致する(変更不要)★。
*/
export function isProblemCompletion(completionRate: number | null): boolean {
return completionRate === null || completionRate < GRADE_THRESHOLDS.good;
}
/** 連続日数の算出に必要な最小限の形 */
export interface ConsecutiveInput {
/**
* `CleanLogItem.id`(= 本清掃の `clean_timetables.id`)。
* ★予定外タスクの1回は `null`★(指示書BG §2-3。`CleanLogItem.id` と同じ — 予定の id が無く、19桁の `report_id` を
* 数値の id にしない)。★予定の id と同じ値の空間に入れないため★、`computeConsecutiveProblemDays()` の Map には載らない
* (施設 × 日付で引く `consecutiveProblemDaysByFacilityDate()` を使う)
*/
id: number | null;
/**
* ★予定外タスクの1回か★(`clean_task_reports` の `clean_timetable_id` が 0 か NULL。指示書BG §2-3)。
* ★必須(optional にしない)★ — 予定外も予定のある清掃と同じく、その施設のその日の結果に入る(問題日の OR・
* 予定外だけの日もスケジュール存在日)。未実施にはならない(`departed` は常に true)
*/
unscheduled: boolean;
/**
* 施設の識別子(`CLAUDE.md` §4.5 の R3/R4「同一施設で」に合わせた分割キー)。
* ★★以前は `serial_number: string` を分割キーにしていたが誤りだった★★ —
* 「P1 は 1清掃エリア = 1ロボットなので serial_number で代用できる」という前提は、
* 1施設に複数の清掃エリア(=複数ロボット)が実在する施設(施設5=2台 / 施設7=3台)が
* あるため成り立たない(指示書V 不具合B)。
*/
facility_id: number;
/** ISO8601(+09:00)。日付昇順に並べ替えるキー */
scheduled_at: string;
/**
* 1回ごとの完了率(`completionRateFromReport()` = `runCompletionRate()` の戻り)。丸める前の生値。
* ★未実施は null★(未実施の数え方は `departed` で見る。指示書AU D3)
*/
completion_rate: number | null;
/**
* ★出発したか(=レポートの行があるか)★ — `!notExecutedFromReport(report)`。
*
* ★★【未決1】案A(D-156。指示書AS 段階3-4)★★: ★要レスキュー R-1(未実施3日連続)と
* 連続問題施設の出発失敗日(`launchFailedDaysInRun()`)は、`completion_rate === null` ではなく
* この値で「未実施」を数える★。★いまは `completion_rate === null` と同値★(P-5=B)だが、
* ★完了率の規則がまた変わっても、未実施の数え方が巻き込まれないため★に別に持つ(指示書AU D3)。
* ★★optional(`?`)にしないこと★★ — 詰め忘れが `undefined`(=偽=全部未実施)のまま通る。
*/
departed: boolean;
/**
* 面積の生値(★旧: 急落判定 R-3 の面積加重に使っていた。指示書AI 段階2★)。
*
* ★★R-3 は1回ごとの完了率の単純平均★★(`meanCleanRatio()`。指示書AQ 決定4 / §5-5・D-155)—
* ★判定(`judgeRescue()`)はこの2列を読まない★(`RescueRowInput` が型から外している)。
* ★完了率の分子・分母(Σ面積 ÷ Σ面積)としてここを読み直さないこと★。
*
* ★レポートの行が無い行(=未実施)は 0★。
* ★★`completion_rate` は `runCompletionRate()` で作ったもの。ここで割り直さないこと★★
* (規則が2箇所に散る)。1行(=1清掃エリア)の率で、未実施は `null`。
* ★★optional(`?`)にしないこと★★ — 詰め忘れが `undefined` のまま通る。
* ★旧: 面積加重が静かに 0/0 → `null` に化ける(指示書AI §13-2)という理由だった★が、
* R-3 が単純平均に変わったのでその事故はもう起きない。★型を必須のまま残しているのは、
* Prisma 版(`loadConsecutiveInputs()`)と fixtures 版(`rescueInputsFromCleanLogs()`)の
* 形を揃えておくため★(どちらも 0 を詰めている)。
*/
clean_area: number;
task_area: number;
}
/**
* 同一日の複数行を OR で1日に畳む(A案。ユーザー確定 2026-09-11 / D-109)。
*
* ★なぜ畳むのか★ — `countConsecutiveProblemDays()`(`lib/judge/escalation.ts`)の契約は
* 「スケジュール存在日1日分の結果」を★1日1エントリ★で渡すことである。
* 施設に複数の清掃エリア(ロボット)があると、同じ日に複数行が生まれるため、
* 畳まずに渡すと同じ日付の要素が複数になり「N日連続」と水増しされる(落とし穴1)。
*
* ★畳み方(A案)★: 同一日に問題ありの行が1つでもあれば、その日は「問題あり」とする(OR)。
* ★★ユーザー確定の原文(2026-09-11)★★: 「2台のうち 1日目Aが問題 / 2日目Bが問題 /
* 3日目Aが問題 でも3日連続で問題発生と記録する」。
* ★B案(全台が問題のときだけ問題日とする。AND)は不採用★ —
* 3台ある施設がほぼ永久にエスカレーションしなくなるため。
*
* ★★同一ロボットが同じ日に2回清掃した場合も同じ理由で1日に畳む(決定2)★★ —
* D-026「連続はスケジュール存在日の連続」は日単位であり、回数単位ではない。
*
* @returns 日付昇順・重複なしの `ScheduleDayResult[]`
*/
export function foldProblemDaysByDate(
rows: readonly ConsecutiveInput[]
): ScheduleDayResult[] {
const isProblemByDate = new Map<string, boolean>();
for (const row of rows) {
const date = row.scheduled_at.slice(0, 10);
const isProblem = isProblemCompletion(row.completion_rate);
isProblemByDate.set(date, (isProblemByDate.get(date) ?? false) || isProblem);
}
return [...isProblemByDate.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([date, isProblem]) => ({ date, isProblem }));
}
/**
* `consecutive_problem_days` を全行ぶんまとめて算出する(D-026 / 指示書R 6-3 / 指示書V 不具合B)。
*
* ★DB に対応するカラムは無い★ので、施設ごとに全行を集め、`foldProblemDaysByDate()` で
* 同一日を1エントリに畳んだうえで、各行について「その行の日付までの連続」を数える。
*
* ★★分割キーは `facility_id`★★(`CLAUDE.md` §4.5 の R3/R4「同一施設で」)。
* ★★畳み込みはこの関数の内部だけで行う★★(呼び出し側でやると規則が2箇所に散る。D-103)。
* ★★同じ日の全行に同じ連続日数が入る★★(畳んだ1エントリの値を全員で共有するため)。
*
* ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」★(D-026 / `CLAUDE.md` §4.5)。
* ここでは ★「清掃ログの行が存在する日」=「スケジュール存在日」★ とみなす
* (`clean_timetables` の行はスケジュールから生成されるため。D-049)。
* したがって清掃予定のない日は自然に飛ばされ、連続が途切れない(畳み込み後も同じ)。
* 判定そのものは `lib/judge/escalation.ts` の `countConsecutiveProblemDays()` に渡す
* (どの日がスケジュール存在日かの判断は呼び出し側の責任、という同関数の契約どおり)。
*
* ★★施設単位にした帰結(決定4。意図した結果)★★ — 正常終了した行にも R3/R4 が出ることが
* あり(別の台のせいで施設の連続が2日以上になる)、逆に別の台のせいでその台にとって
* 初回の出発失敗でも R1 が出ないことがある(`lib/judge/advice.ts` の
* `consecutiveProblemDays < level2Days`。指示書BB A13 で閾値を設定から読むようにした)。
*
* ★★入力は「一覧と同じ期間で絞った行」にしないこと★★(指示書AC C-2 / 段階1):
* この関数は ★渡された行の範囲でしか連続を数えられない★。一覧と同じ期間で絞った行を渡すと
* ★期間の外にある前日の行が消えて連続が途切れる★ — 「9/8× 9/9× 9/10×」(3日連続 = L3)を
* 「9/10〜9/10」で絞って渡すと ★1日 = L1 に化ける★。
* ★★例外もテスト失敗も出ない fail-silent★★。
*
* ★入力を作る責務は `loadConsecutiveInputs()`(`lib/db/consecutive-source.ts`)の1本に
* 切り出してある★ — ★★`cleanLogs()` / `facilityPerformance()` / `continuousProblems()` は
* その1本を呼ぶこと★★(D-109 / D-103 の再発防止。連続日数を2箇所で別々に計算しない)。
* ★★この関数を呼ぶ前に自前で期間を絞り直さないこと★★。
*
* ★★その1本は指示書AC 段階6 で有界になっている★★ — ★全期間読みではありません★。
* 読む範囲は ★「表示期間 + 保存された表示上限 +1日ぶんの遡り」★ で、
* 遡り幅は `loadConsecutiveDaysMax()` の値から導く(★定数に写し取らないこと★)。
* ★真の連続が上限以下なら全部読めて正しい値★、★上限を超えるなら「{上限}日以上」★になる
* (根拠と遡り幅の決め方は `loadConsecutiveInputs()` の docstring が正)。
* ★例外★: `date_from` が `null`(単一ID取得など下限なしの呼び出し)のときは下限が付かない。
* ★代わりに読む列を最小限にしてある★(`select` で4列/6列。`include` は一切しない。
* ★列を増やしてよい条件は `loadConsecutiveInputs()` の docstring が正★)。
* 畳み込みは施設ごとの1パスを足すだけなので、走査回数は増えも減りもしない。
*
* ★★予定外タスクの行(`id === null`。指示書BG §2-3)も畳み込みには入る★★(その日の問題日の OR・予定外だけの日も
* スケジュール存在日)が、★戻りの Map には載せない★(予定の id が無い)。予定外の行の値は
* `consecutiveProblemDaysByFacilityDate()`(施設 × 日付)で引くこと — 同じ施設・同じ日の行は全部同じ値になる。
*
* @returns `CleanLogItem.id`(★予定のある行だけ★)→ 連続問題日数
*/
export function computeConsecutiveProblemDays(
rows: readonly ConsecutiveInput[]
): Map<number, number> {
const byFacility = new Map<number, ConsecutiveInput[]>();
for (const row of rows) {
const list = byFacility.get(row.facility_id);
if (list) list.push(row);
else byFacility.set(row.facility_id, [row]);
}
const result = new Map<number, number>();
for (const list of byFacility.values()) {
// 同一日を1エントリに畳む(日付昇順で返る)
const days = foldProblemDaysByDate(list);
const dayIndexByDate = new Map<string, number>();
days.forEach((day, i) => dayIndexByDate.set(day.date, i));
for (const row of list) {
// ★予定外タスク(id なし)は畳み込みにだけ入り、Map には載らない★(指示書BG §2-3)
if (row.id === null) continue;
const date = row.scheduled_at.slice(0, 10);
// ★foldProblemDaysByDate() が同じ行から作った days なので必ず見つかる★
const dayIndex = dayIndexByDate.get(date) as number;
// 「その日まで」の連続なので、先頭からその日までを渡す(同じ日の全行が同じ値になる)
result.set(row.id, countConsecutiveProblemDays(days.slice(0, dayIndex + 1)));
}
}
return result;
}
/** `consecutiveProblemDaysByFacilityDate()` のキー(施設 × JST の日付) */
export function facilityDateKey(facilityId: number, scheduledAt: string): string {
return `${facilityId}|${scheduledAt.slice(0, 10)}`;
}
/**
* `computeConsecutiveProblemDays()` と同じ規則で、★施設 × 日付 → 連続問題日数★ を返す(指示書BG §2-1・§2-3)。
*
* ★予定外タスクの行(`CleanLogItem.id` が null)の連続日数を引くため★ — 予定外の行は予定の id を持たないので、
* id で引く `computeConsecutiveProblemDays()` の Map からは引けない。
* ★同じ施設の同じ日の行は全部同じ値★(`foldProblemDaysByDate()` で1日に畳むため)なので、施設 × 日付で引いて同じ答えになる。
* ★入力は `computeConsecutiveProblemDays()` と同じもの★(`loadConsecutiveInputs()` の戻り)。
* 予定外の行がその日の結果に入っているかは入力を作る側(`loadConsecutiveInputs()`)の責任。
*/
export function consecutiveProblemDaysByFacilityDate(
rows: readonly ConsecutiveInput[]
): Map<string, number> {
const byFacility = new Map<number, ConsecutiveInput[]>();
for (const row of rows) {
const list = byFacility.get(row.facility_id);
if (list) list.push(row);
else byFacility.set(row.facility_id, [row]);
}
const result = new Map<string, number>();
for (const [facilityId, list] of byFacility) {
const days = foldProblemDaysByDate(list);
days.forEach((day, i) => {
result.set(
facilityDateKey(facilityId, day.date),
countConsecutiveProblemDays(days.slice(0, i + 1))
);
});
}
return result;
}