Newer
Older
client_dwatch / lib / db / facility-group-source.ts
/**
 * ★分類(ホールディングス/屋号)→ 施設ID の解決★に使う施設マスタの読み取り
 * (指示書AK 段階3 §6-3 / ★罠7★ / ★落とし穴14★)。
 *
 * ------------------------------------------------------------------------
 * ★★`db.facilities()` を流用しないのは `loadFacilityNameIndex()` と同じ理由★★
 * ------------------------------------------------------------------------
 * `prismaSource.facilities()` は ★`where: { deleted_at: null }`★ を付けています
 * (施設マスタ画面は論理削除された施設を出さない、という別の要件)。
 * 一方 `cleanLogs()` の `include: { facility: true }` には ★deleted_at の条件がありません★=
 * ★★いまの清掃詳細ログには、論理削除された施設のログも出ています★★。
 *
 * ★★だからこの読み取りも `deleted_at` で絞りません★★(★罠7★。
 *   詳しい理由は `loadFacilityNameIndex()`(`lib/db/facility-name-source.ts`)の docstring)。
 *   絞ると ★その施設のログが屋号で絞った瞬間に静かに消えます★
 *   (★件数が減るだけで例外もテスト失敗も出ない★)。
 *   ★★施設名の解決(`facilityNameIndex()`)と扱いを揃えること★★ —
 *   片方だけ絞ると、★施設名とグループを併用したときだけ結果が変わる★という
 *   いちばん気づきにくい食い違いになります。
 *
 * ------------------------------------------------------------------------
 * ★★`group_id` は `customers` 側にしかない★★
 * ------------------------------------------------------------------------
 * `facilities.customer_id` → `customers.group_id` の2段です(`prisma/schema.prisma`)。
 * ★リレーションが張られている★(`facilities_customer_id_foreign`)ので、
 * ★入れ子の `select` で1回のクエリにまとめられます★。
 *
 * ★★施設ごとにループでクエリを撃たないこと★★(★落とし穴14★)—
 *   ★施設マスタは実測12件★なので ★1回読んでメモリで解決する★(指示書AK §6-3)。
 *
 * ★★`customers` を別クエリで引いて突き合わせる形に**しない**こと★★ —
 *   そちらにすると「屋号側の `deleted_at` をどうするか」という判断が新たに1つ増え、
 *   ★論理削除された屋号に属する施設が静かに落ちる★経路ができます。
 *   入れ子の `select` なら ★施設の行がある限り屋号の行も必ず付いてくる★(FK 必須列)。
 *
 * ------------------------------------------------------------------------
 * ★★解決そのものはここでやらない★★
 * ------------------------------------------------------------------------
 * 当たり外れを決めるのは `lib/facilities/resolve-by-master.ts` の純粋関数です
 * (`loadFacilityNameIndex()` と `resolve-by-name.ts` の分担と同じ。規則を2箇所に散らさない)。
 */
import { groupIdFromDb } from "@/lib/format/group-id";
import type { FacilityGroupRef } from "@/lib/types";

/**
 * `facilities` から読む列(★これ以上増やさないこと★。解決に要るのは id と所属だけ)。
 *
 * ★`facility_name` を足さないこと★ — 施設名の照合は `facilityNameIndex()` の担当で、
 *   ★あちらの読み取りとこちらを1本にまとめない★(用途が増えると `where` を足したく
 *   なったときに片方の要件を壊す)。
 */
export interface FacilityGroupRow {
  id: bigint;
  customer_id: bigint;
  /** ★`customers.group_id` は NOT NULL で、0 = ホールディングスなしのセンチネル★(D-011) */
  customer: { group_id: bigint };
}

/**
 * この関数が必要とする Prisma の最小限の形(★テストからスタブを渡せるようにするため★)。
 * ★`PrismaClient` をそのまま要求しないこと★ — 生成済みクライアントが無い環境でも
 * 単体テストが走るようにしてある(`lib/db/facility-name-source.ts` と同じ作法)。
 */
export interface FacilityGroupSourcePrisma {
  facility: {
    findMany(args: {
      select: {
        id: true;
        customer_id: true;
        customer: { select: { group_id: true } };
      };
    }): Promise<FacilityGroupRow[]>;
  };
}

/**
 * 分類の解決に使う行だけを、★列を絞って★全件読む。
 *
 * ★★`where` を足さないこと★★ — 上の「罠7」。
 *   ★テスト(`lib/db/__tests__/facility-group-source.test.ts`)が
 *   「渡した引数のキーが `select` だけであること」を固定しています★。
 *
 * ★★`group_id` の 0 → `null` はここで済ませる★★(D-011 / 指示書AK §4-4 / ★落とし穴3★)—
 *   ★変換を知っているのは `groupIdFromDb()` だけ★。★`=== 0` をこのファイルにも
 *   解決側にも書かないこと★。`BigInt` のままだと `0n === 0` が `false` で静かに外れるので、
 *   先に `Number()` に落としてから渡す(`prismaSource.customers()` と同じ)。
 */
export async function loadFacilityGroupIndex(
  prisma: FacilityGroupSourcePrisma
): Promise<FacilityGroupRef[]> {
  const rows = await prisma.facility.findMany({
    select: { id: true, customer_id: true, customer: { select: { group_id: true } } },
  });
  // ★BigInt を残すと `NextResponse.json` が例外を投げる(`lib/db/index.ts` と同じ作法)★
  return rows.map((row) => ({
    id: Number(row.id),
    customer_id: Number(row.customer_id),
    group_id: groupIdFromDb(Number(row.customer.group_id)),
  }));
}