/**
* レポート画面の4ブロックを ★1本ずつ★ 出すための判定(指示書AL ★段階2★ / §3)。
*
* ------------------------------------------------------------------------
* ★★なぜ画面コンポーネントの中に書かずに `lib/` へ出すのか★★
* ------------------------------------------------------------------------
* `vitest.config.ts` の `include` は `lib/**` と `app/**` なので、
* ★`components/` に置いたテストは走りません★(D-120 項2 / 指示書AL §3-5)。
* ★指示書AJ で `lib/dashboard/rescue-visibility.ts` を切り出したのと同じ形★です。
*
* ------------------------------------------------------------------------
* ★★何を決めているか★★
* ------------------------------------------------------------------------
* | # | 状況 | 期待(指示書AL §3-5) |
* |---|---|---|
* | 2-1 | 4本すべて成功 | 4ブロックとも出す |
* | ★2-2★ | ★KPIだけ422★ | ★残り3ブロックを出す★+KPIは「顧客を選んでください」 |
* | 2-3 | KPIだけ500 | 残り3ブロックを出す+KPIのブロックにエラー |
* | ★2-4★ | ★4本とも失敗★ | ★★画面全体を1行のエラーにする★★ |
* | 2-5 | 施設別だけ失敗 | 残り3ブロックを出す |
*
* ★2-4 だけ「画面全体」なのは、★4つとも空の枠が並ぶより1行のほうが分かる★から
* (指示書AL §3-3 の監督決定)。
*
* ------------------------------------------------------------------------
* ★★「顧客を選んでください」に振り分ける条件★★
* ------------------------------------------------------------------------
* ★`status === 422` だけでは足りない★ — `/reports/kpi` の 422 には
* ★理由が2つある★(`app/api/reports/kpi/route.ts` の冒頭):
*
* 1. パラメータが不正(`details` は `{ week: ["..."] }` のような ★文字列の配列★)
* 2. 対象顧客が1社に定まらない(`details` は `{ customer_ids: [1, 2] }` = ★数値の配列★)
*
* ★だから `details.customer_ids` が ★数値の配列として実際に入っているか★ で振り分ける★。
* ★文言では判定しないこと★ — EN 切替やサーバ側の言い換えで壊れる
* (`details` を機械可読にした理由そのもの。`lib/types/index.ts` の `ApiErrorDetails`)。
*
* ★純粋関数にすること★(`CLAUDE.md` §9.4)— fetch もログも state も混ぜない。
*/
import { ApiFetchError } from "@/lib/api/client";
import type { ApiErrorDetails } from "@/lib/types";
/** 422 が候補顧客を返すときのキー。★ルートと画面で同じ文字列を見るため定数にする★ */
export const CUSTOMER_IDS_DETAIL_KEY = "customer_ids";
/** 1本ぶんの失敗。★`ApiFetchError` をそのまま持ち回らない★(判定に要るのはこの3つだけ) */
export interface ReportFetchFailure {
/** HTTP ステータス。★ネットワーク到達不能などレスポンスが無い場合は 0★(`ApiFetchError` と同じ) */
status: number;
/** サーバが返した文言。`ApiError` として読めなかった場合は null(画面の既定文言に落とす) */
serverMessage: string | null;
details: ApiErrorDetails;
}
export type ReportFetchOutcome<T> =
| { ok: true; value: T }
| { ok: false; failure: ReportFetchFailure };
/**
* `Promise.allSettled()` の1件を結果に変換する。
*
* ★`ApiFetchError` 以外の例外(想定外のバグ)も失敗として扱う★ —
* ここで投げ直すと ★1本の想定外が4本ぶんの表示を巻き添えにする★(=直したい元の症状)。
* ★`status: 0` にするのは `ApiFetchError` のネットワーク失敗と同じ扱い★で、
* 422 判定に混ざらない値だから(0 は「レスポンスが無い」の意)。
*/
export function toFetchOutcome<T>(
result: PromiseSettledResult<T>
): ReportFetchOutcome<T> {
if (result.status === "fulfilled") {
return { ok: true, value: result.value };
}
if (result.reason instanceof ApiFetchError) {
return {
ok: false,
failure: {
status: result.reason.status,
serverMessage: result.reason.serverMessage,
details: result.reason.details,
},
};
}
return {
ok: false,
failure: { status: 0, serverMessage: null, details: null },
};
}
/**
* `details.customer_ids` を ★数値の配列として読めたときだけ★ 返す。読めなければ null。
*
* ★`Array.isArray` だけで済ませないこと★ — `details` は3つの形の union(`ApiErrorDetails`)で、
* パラメータ不正の 422 は ★文字列の配列★(`{ week: ["..."] }`)を返す。
* キー名が一致しても中身が文字列なら「候補顧客」ではない。
*
* ★空配列は null ではなく `[]` を返す★ — ★「候補が0社」も意味のある答え★だから
* (`group_id` 指定で傘下0社のとき。`lib/reports/target-customer.ts` の docstring)。
*/
export function customerIdCandidates(details: ApiErrorDetails): number[] | null {
if (details === null) return null;
const raw: unknown = (details as Record<string, unknown>)[
CUSTOMER_IDS_DETAIL_KEY
];
if (!Array.isArray(raw)) return null;
if (!raw.every((id) => typeof id === "number" && Number.isFinite(id))) {
return null;
}
return raw as number[];
}
/** 1ブロックの見せ方 */
export type ReportBlockView =
/** 取得できた。中身を出す */
| { kind: "ready" }
/** 失敗した。★そのブロックの中に★ メッセージを出す(どれが欠けたか分かるように) */
| { kind: "error"; message: string | null }
/**
* 対象顧客が定まらない(KPIの 422)。★「選んでください」へ誘導する★。
* ★`candidateCustomerIds` を画面へ渡す★ — ★件数を出す/セレクトを強調する等、
* 「文言を読ませるだけ」で終わらせないため★(指示書AL §3-4)。
*/
| {
kind: "select-customer";
message: string | null;
candidateCustomerIds: number[];
};
export interface ReportBlockOutcomes {
kpi: ReportFetchOutcome<unknown>;
facilityPerformance: ReportFetchOutcome<unknown>;
continuousProblems: ReportFetchOutcome<unknown>;
errorBreakdown: ReportFetchOutcome<unknown>;
}
export interface ReportBlocksView {
/** ★4本とも失敗★。true のときは個別ブロックを出さず、画面全体を1行のエラーにする */
allFailed: boolean;
/**
* `allFailed` のときに出す1行の文言。null なら画面側の既定文言を使う。
* ★最初に見つかった「サーバが返した文言」を採る★ — `Promise.all` だったころの
* 「最初に失敗した1本の文言を出す」と同じ見え方に寄せている。
*/
message: string | null;
kpi: ReportBlockView;
facilityPerformance: ReportBlockView;
continuousProblems: ReportBlockView;
errorBreakdown: ReportBlockView;
}
/** KPI 以外の3本。★422 の特別扱いをしない★(顧客が定まらない話はKPIだけ) */
function plainView(outcome: ReportFetchOutcome<unknown>): ReportBlockView {
return outcome.ok
? { kind: "ready" }
: { kind: "error", message: outcome.failure.serverMessage };
}
function kpiView(outcome: ReportFetchOutcome<unknown>): ReportBlockView {
if (outcome.ok) return { kind: "ready" };
const { status, serverMessage, details } = outcome.failure;
if (status === 422) {
const candidates = customerIdCandidates(details);
// ★`customer_ids` が読めた 422 だけを誘導にする★ —
// パラメータ不正の 422(`{ week: ["..."] }`)は普通のエラー表示のまま
if (candidates !== null) {
return {
kind: "select-customer",
message: serverMessage,
candidateCustomerIds: candidates,
};
}
}
return { kind: "error", message: serverMessage };
}
/**
* 4本の結果から「どのブロックをどう出すか」を決める。
*
* @param outcomes 4本ぶんの結果(`toFetchOutcome()` で作る)。★中身の型は見ない★ので `unknown` で足りる
*/
export function resolveReportBlocks(
outcomes: ReportBlockOutcomes
): ReportBlocksView {
// ★順序に意味がある★(`message` の採用順 = 画面の並び順)
const ordered = [
outcomes.kpi,
outcomes.facilityPerformance,
outcomes.continuousProblems,
outcomes.errorBreakdown,
];
const allFailed = ordered.every((outcome) => !outcome.ok);
let message: string | null = null;
for (const outcome of ordered) {
if (!outcome.ok && outcome.failure.serverMessage !== null) {
message = outcome.failure.serverMessage;
break;
}
}
return {
allFailed,
message,
kpi: kpiView(outcomes.kpi),
facilityPerformance: plainView(outcomes.facilityPerformance),
continuousProblems: plainView(outcomes.continuousProblems),
errorBreakdown: plainView(outcomes.errorBreakdown),
};
}