Newer
Older
client_dwatch / lib / clean-area / aggregate.ts
/**
 * 清掃面積の集計(指示書AN 段階2 / §5-2)。
 *
 * ```
 * 施設ごとの { facility_id, clean_area, task_area, executed_count, clean_ratio_sum, clean_ratio_count }
 *   + 施設マスタ(id / facility_name / customer_id / customer_name / group_id / group_name)
 * → 粒度(group / customer / facility)でロールアップした配列 + 全体の合計
 * ```
 *
 * ★★純粋関数にすること★★(`CLAUDE.md` §9.4)— ★DBアクセスもログ出力も混ぜない★。
 *   集計クエリ(`groupBy` + `_sum`)は段階3、API は段階4 の担当。
 *
 * ★★`components/` に書かないこと★★ — `vitest.config.ts` の `include` は
 *   `lib/**` と `app/**` なので、★`components/` に置いたテストは走りません★(D-120 項2 / 落とし穴12)。
 *
 * ★★清掃完了率は1回ごとの完了率(`runCompletionRate()`)の単純平均★★(2026-09-30 指示書AQ 決定4 / §5-1・
 *   2026-10-02 D-155)— ★以前は Σclean_area ÷ Σtask_area(期間の合計どうしの面積比)だった★。
 *   ★同じ施設がレポート画面で78%・この画面で120%★と出うるため、★レポート画面と同じ `meanCleanRatio()`
 *   (`lib/judge/kpi.ts`)で畳む★。1回ごとの完了率はデータ層が作って Σ と件数で渡す(この関数は面積を割らない)。
 *   ★面積の合計(`clean_area` / `task_area`)は表示用にそのまま残す★(指示書AQ §1-3)が、
 *   ★その2つ(合計どうし)を割って完了率を作らないこと★。
 *
 * ★★面積は `clean_task_reports` 由来★★(指示書AN §1-2 / 落とし穴1。指示書AQ 決定2)—
 *   `facility_summaries` / `customer_summaries` の `task_area` / `clean_area` は
 *   ★実データで全行 1000 固定であり、期間内の合計になっていない★(第9回 Q2-1 で照会中)。
 *   この関数はその集計結果を受け取るだけだが、★サマリ由来の値を渡さないこと★。
 *
 * ★丸めない★(`CLAUDE.md` §4.3 / §9.3)— `completion_rate` は生値のまま返し、
 *   ★整数化は表示層だけで行う★。
 *
 * ★Kahan 加算も整数化も要らない★(指示書AN §1-3。実測で 1,440,000 行でも差は 0.00004 で
 *   整数表示は一致)— ★普通に足す★。
 */
import { meanCleanRatio } from "@/lib/judge/kpi";
/**
 * ★型は `lib/types/` に集約する★(`CLAUDE.md` §9.2。段階4 で YAML を確定させたときに移した)—
 *   `CleanAreaRow` / `CleanAreaTotal` は `d-watch-api.yaml` の同名スキーマと対の形なので、
 *   ★契約を2箇所に持たない★。ここで再定義(や re-export)しないこと。
 */
import type {
  CleanAreaAggregate,
  CleanAreaFacilityMaster,
  CleanAreaFacilityStats,
  CleanAreaRow,
  CleanAreaScope,
} from "@/lib/types";

/**
 * ホールディングスに属さない屋号をまとめる行の表示名(指示書AN §5-3 B-3)。
 *
 * ★「グループ0」を作らないこと★ — `customers.group_id` の `0` は「所属なし」のセンチネル(D-011)で、
 *   ★存在しないホールディングスとして画面に出してはいけない★(指示書AK 落とし穴3)。
 *   `groupIdFromDb()`(`lib/format/group-id.ts`)が読み取り時に `null` へ変換済みなので、
 *   ★ここに来るのは `null` だけ★。
 *
 * ★文字列をここ1箇所に置く★ — 画面(段階5)もこの定数を使うこと。
 *   ★文言を変えるときに直すのは1行★。
 */
export const CLEAN_AREA_GROUP_NONE_LABEL = "グループなし";

/** 集計中の可変バケット(`CleanAreaRow` から派生する前の形) */
interface Bucket {
  id: number | null;
  name: string;
  group_id: number | null;
  group_name: string | null;
  customer_id: number | null;
  customer_name: string | null;
  clean_area: number;
  task_area: number;
  executed_count: number;
  /**
   * ★完了率の材料(1回ごとの完了率の Σ と件数)★ — Σ と件数を持ち回り、★割るのは最後に1回だけ★(平均の平均にしない)。
   * ★名前は変えていない★(指示書AS D7)
   */
  clean_ratio_sum: number;
  clean_ratio_count: number;
}

/** バケットの数値列(★粒度に依存しない属性の外側★) */
type BucketNumbers =
  | "clean_area"
  | "task_area"
  | "executed_count"
  | "clean_ratio_sum"
  | "clean_ratio_count";

/**
 * ★`id` が `null` になりうるのでキーは文字列にする★ —
 *   数値キーの Map に `null` を混ぜると「グループなし」が
 *   ★グループID 0 と同じ扱いに化ける★ことがあるため、最初から別の空間にする。
 */
function bucketKey(scope: CleanAreaScope, id: number | null): string {
  return `${scope}:${id === null ? "none" : id}`;
}

/** その粒度でのバケットの素(キーと、行に載せる属性) */
function bucketSeed(
  scope: CleanAreaScope,
  master: CleanAreaFacilityMaster
): { key: string; seed: Omit<Bucket, BucketNumbers> } {
  if (scope === "group") {
    return {
      key: bucketKey(scope, master.group_id),
      seed: {
        id: master.group_id,
        // ★所属なしは「グループなし」1行にまとめる★(「グループ0」を作らない)
        name: master.group_name ?? CLEAN_AREA_GROUP_NONE_LABEL,
        group_id: master.group_id,
        group_name: master.group_name,
        // ★屋号は1つに決まらないので `null`★(`CleanAreaRow.customer_id` の注記)
        customer_id: null,
        customer_name: null,
      },
    };
  }
  if (scope === "customer") {
    return {
      key: bucketKey(scope, master.customer_id),
      seed: {
        id: master.customer_id,
        name: master.customer_name,
        // ★屋号は1つのホールディングスにしか属さない★ので、その屋号の施設なら同じ値になる。
        //   ★最初に当たった施設の値を使う★(万一データが割れていても例外にしない=画面を止めない)
        group_id: master.group_id,
        group_name: master.group_name,
        customer_id: master.customer_id,
        customer_name: master.customer_name,
      },
    };
  }
  return {
    key: bucketKey(scope, master.id),
    seed: {
      id: master.id,
      name: master.facility_name,
      group_id: master.group_id,
      group_name: master.group_name,
      customer_id: master.customer_id,
      customer_name: master.customer_name,
    },
  };
}

/**
 * 施設ごとの集計値を、指定の粒度でロールアップする。
 *
 * @param facilityStats 施設ごとの `{ facility_id, clean_area, task_area, executed_count, clean_ratio_sum, clean_ratio_count }`
 *   (★同じ `facility_id` が複数行で来ても合算する★。`groupBy` は1行/施設を返すが、
 *   ★呼び出し側の形が変わったときに静かに落ちないように★)
 * @param facilities 施設マスタ(`db.facilities()` 等の結果。★`group_id` は API の形=`null` が所属なし★)
 * @param scope 集計の粒度
 *
 * ★★`total` は粒度に依存しない★★(§5-3 B-5 / 落とし穴4)—
 *   ★どの粒度でも「内訳の和 = 全体合計」になること★。粒度タブを切り替えて合計が動いたら
 *   ロールアップが壊れている。
 *
 * ★★施設マスタに無い `facility_id` は集計に入れない★★(§5-3 B-4。★決定と理由★)—
 *   名前も所属も作れないので、どの粒度の行にも置けない。
 *   ★`total` にだけ足すと「全体合計 ≠ 内訳の和」になり、どこで落ちたのか追えなくなる★ので、
 *   ★`total` からも外す★(★1回ごとの完了率の Σ・件数も同じ★)。★代わりに `unmatched_facility_ids` で返し、静かに消さない★。
 *   (マスタに無い施設 = 削除済み施設や dangling な `facility_id`。
 *   ★原因の断定はしない★ — 呼び出し側が差分だけをログに残す)
 */
export function aggregateCleanArea(
  facilityStats: readonly CleanAreaFacilityStats[],
  facilities: readonly CleanAreaFacilityMaster[],
  scope: CleanAreaScope
): CleanAreaAggregate {
  const masterById = new Map<number, CleanAreaFacilityMaster>();
  for (const master of facilities) masterById.set(master.id, master);

  const buckets = new Map<string, Bucket>();
  const unmatched = new Set<number>();
  let totalCleanArea = 0;
  let totalTaskArea = 0;
  let totalExecuted = 0;
  let totalRatioSum = 0;
  let totalRatioCount = 0;

  for (const stat of facilityStats) {
    const master = masterById.get(stat.facility_id);
    if (master === undefined) {
      unmatched.add(stat.facility_id);
      continue;
    }

    // ★`_sum` の `null` をここで 0 にする★(A-2)— ★`null` を画面まで漏らさない★
    const cleanArea = stat.clean_area ?? 0;
    const taskArea = stat.task_area ?? 0;
    // ★1回ごとの完了率の Σ も null なら 0 にする★(型の `null` は旧 `_sum` の名残。件数は null にならない)
    const ratioSum = stat.clean_ratio_sum ?? 0;

    totalCleanArea += cleanArea;
    totalTaskArea += taskArea;
    totalExecuted += stat.executed_count;
    totalRatioSum += ratioSum;
    totalRatioCount += stat.clean_ratio_count;

    const { key, seed } = bucketSeed(scope, master);
    const bucket = buckets.get(key);
    if (bucket === undefined) {
      buckets.set(key, {
        ...seed,
        clean_area: cleanArea,
        task_area: taskArea,
        executed_count: stat.executed_count,
        clean_ratio_sum: ratioSum,
        clean_ratio_count: stat.clean_ratio_count,
      });
    } else {
      bucket.clean_area += cleanArea;
      bucket.task_area += taskArea;
      bucket.executed_count += stat.executed_count;
      bucket.clean_ratio_sum += ratioSum;
      bucket.clean_ratio_count += stat.clean_ratio_count;
    }
  }

  const items: CleanAreaRow[] = [...buckets.values()]
    .map(({ clean_ratio_sum, clean_ratio_count, ...bucket }) => ({
      scope,
      ...bucket,
      // ★自前で割り算を書かない★(指示書AN §2-6)— ★完了率は1回ごとの完了率の単純平均★
      //   (指示書AQ 決定4 / §5-1・D-155)。件数0は `null`(`∞` / `NaN` にしない)
      completion_rate: meanCleanRatio({
        cleanRatioSum: clean_ratio_sum,
        cleanRatioCount: clean_ratio_count,
      }),
    }))
    .sort(compareRows);

  return {
    scope,
    total: {
      clean_area: totalCleanArea,
      task_area: totalTaskArea,
      // ★内訳と同じ畳み方★ — Σ と件数の合計から1回だけ割る(★内訳の率を平均しない★)
      completion_rate: meanCleanRatio({
        cleanRatioSum: totalRatioSum,
        cleanRatioCount: totalRatioCount,
      }),
      executed_count: totalExecuted,
    },
    items,
    unmatched_facility_ids: [...unmatched].sort((a, b) => a - b),
  };
}

/**
 * ★`clean_area` の降順 → 同値ならIDの昇順★(§5-3 C-1 / C-2)。
 *
 * ★同値のときの順序を決め切ること★ — 決めないと入力(=DBが返す順)次第で並びが揺れ、
 *   ★再読み込みのたびに行が入れ替わる★。
 * ★`id === null`(グループなし)は最後★ — ★昇順の中で `null` の位置を決めないと
 *   比較が非決定になる★ため、明示的に一番後ろに置く。
 */
function compareRows(a: CleanAreaRow, b: CleanAreaRow): number {
  if (a.clean_area !== b.clean_area) return b.clean_area - a.clean_area;
  if (a.id === b.id) return 0;
  if (a.id === null) return 1;
  if (b.id === null) return -1;
  return a.id - b.id;
}