Newer
Older
client_dwatch / lib / clean-area / anomaly.ts
/**
 * 清掃面積の★番人★のログ出力(指示書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 か 0 の行(予定外。0 は TeamWorks 回答 2026-10-27)は見張りの対象外★(数え方の正は
 *   `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・0 の行は数えない★)。
   * ★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,
    });
  }
}