/**
* レポート系メソッド(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;
}