Newer
Older
client_dwatch / lib / reports / target-customer.ts
/**
 * 確定期間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) };
}