/**
* 確定期間KPI(`GET /reports/kpi`)の ★対象顧客をどう決めるか★(指示書AK ★段階6★ / §2-1)。
*
* ------------------------------------------------------------------------
* ★★なぜ「1社に定まるときだけ出す」のか★★
* ------------------------------------------------------------------------
* 確定期間のKPIは `customer_summaries` を読む(D-102)。
* ★★このサマリは顧客ごとの行で、複数顧客ぶんを合算する材料がDBに無い★★(指示書AK §2-1 の表):
*
* | 指標 | 分子 | 分母 | 合算 |
* |---|---|---|---|
* | 清掃完了率 | ★1回ごとの完了率の合計が無い★ | ★件数が無い★ | ★★できない★★(`clean_area` / `task_area` の合計で割ると Σ面積 ÷ Σ面積 になり、サマリの式(1回ごとの完了率の平均。第10回 N-3)と違う。D-155) |
* | 出発成功率 | `launch_count` | `task_count`(★P-6=A で確定★) | 材料はある |
* | ★稼働率★ | ★列が無い(正常終了の回数)★ | `task_count` | ★★できない★★ |
* | ★エラー発生率★ | `error_count` | ★`task_count`(P-6=B)★ | 材料はある |
*
* ★4指標を揃えて出せない(稼働率・清掃完了率の材料が無い)ので、合算しない方針は変えない★
* (第11回 P-6 で出発成功率・エラー発生率の材料はそろったが、指示書AU §11 Q3 で方針は据え置き。D-158)。
*
* ★率の平均を取るのは誤り★(施設数の違いが消える)。だから ★合算せず、1社に定まるときだけ出す★。
*
* ------------------------------------------------------------------------
* ★★決定表(指示書AK §2-1。覆すなら理由を残すこと)★★
* ------------------------------------------------------------------------
* | 入力 | 結果 |
* |---|---|
* | `customer_id` が指定されている | ★`group_id` も指定されていて、その顧客がその傘下でなければ 200・空(AND。指示書BB A17・D-182)。それ以外はその顧客★(★存在確認はしない★。6-9) |
* | `group_id` が指定されている・傘下1社 | ★その顧客★ |
* | `group_id` が指定されている・傘下2社以上 | ★★422★★ |
* | どちらも未指定・顧客が1社だけ | ★その顧客★(★P1 はここ★) |
* | どちらも未指定・顧客が2社以上 | ★★422★★ |
*
* ★★「静かに1社を選ぶ」ことは絶対にしないこと★★(★落とし穴6★。D-133 が明確に否定している)—
* ★`customer_id` が明示されている場合を除き、候補が2つ以上あれば必ず 422★。
* ★どの顧客かを決める根拠が無いまま選ぶと、選び間違いに誰も気づけない★。
*
* ★★500 ではなく 422★★ — ★サーバの不具合ではなく「指定が足りない」という話★だから
* (指示書AK §2-1)。★候補を `details.customer_ids` で返す★ので、画面は
* 「どれを選べばよいか」を文言を読まずに示せる。
*
* ★純粋関数にすること★(`CLAUDE.md` §9.4)— DBアクセスもログ出力も混ぜない
* (顧客一覧は呼び出し側が `db.customers()` で読んで渡す)。
*/
import type { MasterIdFilters } from "@/lib/api/master-id";
import type { Customer } from "@/lib/types";
/** 対象顧客が1社に定まった */
export interface TargetCustomerResolved {
ok: true;
customerId: number;
}
/**
* 対象顧客が定まらなかった(候補が0社、または2社以上)。
* ★`candidateCustomerIds` はそのまま 422 の `details.customer_ids` になる★。
*/
export interface TargetCustomerAmbiguous {
ok: false;
candidateCustomerIds: number[];
}
/**
* ★顧客とグループが食い違うので該当なし★(`customer_id` と `group_id` の両方が指定され、その顧客がそのグループの傘下でない。
* YAML の `GroupId`「AND。そうでなければ0件。404 にしない」。指示書BB A17・D15・D-182)。
* ★ルートはデータ層を呼ばずに 200・空のKPI を返す★。
*/
export interface TargetCustomerNoMatch {
ok: true;
customerId: null;
}
export type TargetCustomerResult =
| TargetCustomerResolved
| TargetCustomerNoMatch
| TargetCustomerAmbiguous;
/**
* 422 の文言(指示書AK §2-1 の原文。★勝手に言い換えないこと★)。
* ★定数にしてあるのは、ルートとテストで同じ文字列を見るため★。
*/
export const TARGET_CUSTOMER_REQUIRED_MESSAGE =
"顧客またはグループを指定してください。複数の顧客のデータを合算した数値は出せません。";
/**
* 確定期間KPIの対象顧客を決める。
*
* @param customers ★顧客マスタの全行★(`db.customers()` の戻り値。`group_id` は
* ★API の形(`null` = ホールディングスなし)★に変換済み。D-011 / `groupIdFromDb()`)
* @param filters `parseMasterIdFilters()` が検証済みの `groupId` / `customerId`
*
* ★★`customerId` だけが指定されていたら、顧客マスタを一切見ない★★ —
* ★存在確認をしない★(指示書AK 期待値表 6-9)。存在しないIDなら
* ★サマリが1行も当たらず 200・空★になるだけで、それは「該当なし」という正常な結果。
* ★404 にも 422 にもしないこと★(「データがまだ無い期間」と区別がつかなくなる。
* `app/api/reports/kpi/route.ts` の `emptyKpi()` と同じ考え方)。
* ★★`customerId` と `groupId` の両方が指定されていたら AND★★(指示書BB A17・D15・D-182。★AK §2-1 の決定表1行目
* 「customer_id が勝つ」を上書き★)— その顧客がそのグループの傘下なら(傘下が2社以上でも)その顧客、
* 傘下でなければ `{ ok: true, customerId: null }`(該当なし → 200・空)。傘下の判定に顧客マスタを使う。
*
* ★★傘下0社(`groupId` 指定で候補が1社も無い)も 422★★(★指示書に明記が無い箇所★。
* 起草者の判断。理由は3つ):
* 1. ★この関数の契約は「1社に定まるときだけ ok」★で、0社は定まっていない
* 2. ★200・空で返すと「対象顧客なし」を静かに選んだことになる★ — 候補0社の 422 は
* ★画面に「その条件では顧客が選べない」と伝えられる★(`details.customer_ids` が空配列)
* 3. ★6-9(存在しない `customer_id` → 200・空)とは事情が違う★ — あちらは
* ★顧客を1社に決めたうえでサマリが0行★だが、こちらは ★決める相手がいない★
*/
export function resolveTargetCustomer(
customers: readonly Pick<Customer, "id" | "group_id">[],
filters: MasterIdFilters
): TargetCustomerResult {
// ★★`!== null` で分岐すること★★ — `filters.customerId || ...` と書くと、
// 将来 0 が入り込んだときに「未指定」に化ける(`lib/facilities/resolve-by-master.ts` と同じ作法)
if (filters.customerId !== null) {
// ★`group_id` も指定されていたら AND★(YAML の `GroupId`。指示書BB A17・D-182。★AK §2-1 の決定表1行目「customer_id が勝つ」を上書き★)—
// その顧客がそのグループの傘下でなければ「該当なし」(200・空)。★`group_id` が無ければ今どおり存在確認をしない(6-9)★
if (filters.groupId !== null) {
const inGroup = customers.some((c) => c.id === filters.customerId && c.group_id === filters.groupId);
if (!inGroup) return { ok: true, customerId: null };
}
return { ok: true, customerId: filters.customerId };
}
const candidates =
filters.groupId === null
? customers
: customers.filter((customer) => customer.group_id === filters.groupId);
if (candidates.length === 1) {
return { ok: true, customerId: candidates[0].id };
}
// ★0社も2社以上もここ★(★1社のときだけが ok★)
return { ok: false, candidateCustomerIds: candidates.map((customer) => customer.id) };
}