/**
* 清掃面積の集計(指示書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;
}