/**
* 清掃面積の★番人★のログ出力(指示書AN ★段階3★ / §2-5 / §6-3。落とし穴2)。
*
* ------------------------------------------------------------------------
* ★★何を見張っているのか★★
* ------------------------------------------------------------------------
* ```
* 1つの clean_timetable に clean_task_reports が2行以上あると、
* SUM() が同じ清掃の面積を2回数える。
* ★例外もテスト失敗も出ない。数字が大きくなるだけ。★
* ```
* (★旧: `clean_status_histories`。2026-09-30 に集計元を `clean_task_reports` に差し替えた。
* 指示書AQ 決定2 / 決定3★)
* 旧取得元の実データは全538行が1対1でしたが、★既存コードは「最新1行に畳んでから足す」実装★
* (`latestReportByTimetableId()`。旧 `latestHistoryByTimetableId()`)= ★N行を想定しています★。
* ★`clean_timetable_id` が NULL の行は見張りの対象外★(数え方の正は
* `countDuplicatedTimetables()`。指示書AQ 罠1 / §5-3)。
* ★TeamWorks に照会中★(第9回 Q2-3)なので、★静かに壊れる代わりにログに出す★。
*
* もう1つ、`aggregateCleanArea()` が返す ★`unmatched_facility_ids`★
* (施設マスタに無い `facility_id`)も同じ扱いにしてあります —
* ★その面積は内訳にも全体合計にも入らない★ので、★黙って消さない★。
*
* ------------------------------------------------------------------------
* ★★なぜ `lib/db/` ではなくここで `logEvent()` を呼ぶのか★★
* ------------------------------------------------------------------------
* `DataAnomalyLogEvent` は ★`route` / `method` が必須★でした(★D-178 で任意になった★。2026-10-16。
* 詳細ログの事前チェックが3つ以上のときにデータ層から出すため)が、
* ★データ層(`lib/db/`)はどのルートから呼ばれたかを知りません★。
* ★不明な情報を書かない★のがこのリポジトリの作法で、同じ理由から
* `lib/settings/advice.ts` は `route` / `method` を載せずにログを出しています。
* ★ここではルートを載せられる層で出す設計を保つ★(型が任意になっても、ルートが分かるなら載せる)。
*
* ★★そこで「数えるのはデータ層・出すのはルート層」に分けてあります★★:
* - 数える … `DataSource.cleanArea()`(`duplicated_timetable_count`)
* - 出す … ★この関数★(ルートが `ROUTE` / `"GET"` を渡す)
* ★段階2 の `unmatched_facility_ids` が「戻り値で返して呼び出し側に出させる」設計なので、
* 2つの異常を別々の層から出さないためにも、こちらに揃えてあります★。
*
* ★★ルート(段階4)は必ずこの関数を呼ぶこと★★ — 呼ばないと番人は何も言いません
* (★呼び忘れても例外もテスト失敗も出ない★ので、`CleanAreaSourceResult` の
* docstring にも同じ注意を書いてあります)。
*
* ★★原因の断定を書かないこと★★(`docs/logging-design.md` §7)—
* 「TeamWorks の収集処理が二重登録している」のような推測を `metric` にも入れない。
* ★期待値(0)と実測だけを残す★(`app/api/dashboard/route.ts` の `data.anomaly` と同じ形)。
*/
import { logEvent } from "@/lib/logger";
import type { DataAnomalyLogEvent } from "@/lib/logger/events";
/**
* ★`metric` の文字列はここ1箇所★ — ログを grep する側と実装で綴りがズレないようにする。
*
* ★テーブル名と数え方がそのまま読める名前にすること★
* (`clean_log_rows_vs_scheduled`(D-049)と同じ流儀)。
*/
export const CLEAN_AREA_ANOMALY_METRICS = {
/**
* 1つの `clean_timetable_id` に `clean_task_reports` が2行以上ある件数(★NULL の行は数えない★)。
* ★2026-10-01 に改名した★(ユーザー指示。D-151)。旧: `"clean_status_history_rows_per_timetable"`
* — 取得元を `clean_task_reports` に替えた後も旧テーブル名のままで、★警告を見た人が
* 旧テーブルを調べに行ってしまう★ため。★改名の時点でこの `metric` を集計・監視している側は
* 無かった★(出力先は標準出力のみ。`lib/logger/index.ts`)。
* ★本番稼働後は改名しないこと★ — この名前で警告を拾う監視が作られると、改名した瞬間に
* その監視が黙って反応しなくなる。
*/
duplicatedTimetables: "clean_task_report_rows_per_timetable",
/** 施設マスタに無い `facility_id`(その面積は合計にも内訳にも入らない)の件数 */
unmatchedFacilities: "clean_area_facility_ids_without_master",
} as const;
export interface CleanAreaAnomalyInput {
/** 呼び出し元のルート(例: `/api/clean-area`)。★データ層は知らないのでルートが渡す★ */
route: string;
method: string;
/** `DataSource.cleanArea()` の `duplicated_timetable_count` */
duplicated_timetable_count: number;
/** `aggregateCleanArea()` の `unmatched_facility_ids` */
unmatched_facility_ids: readonly number[];
}
/**
* 異常が1つも無ければ★1行も出さない★(0件を毎回 info で出すとログが埋まる)。
*
* @param emit テストから差し替えるための出口(★本番コードは省略して `logEvent` を使う★。
* `lib/settings/advice.ts` の `onWarn` と同じ作法)。
*/
export function reportCleanAreaAnomalies(
input: CleanAreaAnomalyInput,
emit: (event: DataAnomalyLogEvent) => void = (event) => logEvent("warn", event)
): void {
if (input.duplicated_timetable_count > 0) {
emit({
event: "data.anomaly",
route: input.route,
method: input.method,
metric: CLEAN_AREA_ANOMALY_METRICS.duplicatedTimetables,
// ★「2行以上ある `clean_timetable_id` は0件であってほしい」という期待値★
expected: 0,
actual: input.duplicated_timetable_count,
});
}
if (input.unmatched_facility_ids.length > 0) {
emit({
event: "data.anomaly",
route: input.route,
method: input.method,
metric: CLEAN_AREA_ANOMALY_METRICS.unmatchedFacilities,
expected: 0,
// ★施設IDそのものは載せない★ — 件数で異常の有無は分かり、
// ★IDの列挙は件数が増えるとログを埋める★(必要ならDBを引き直せる)
actual: input.unmatched_facility_ids.length,
});
}
}