Newer
Older
client_dwatch / lib / facilities / resolve-by-master.ts
/**
 * 分類(ホールディングス/屋号)→ 施設ID の解決(指示書AK 段階3 §6-1)。
 *
 * ------------------------------------------------------------------------
 * ★★戻り値の契約は `resolveFacilityIdsByName()` と完全に同じ★★
 * ------------------------------------------------------------------------
 * | 状況 | 戻り値 |
 * |---|---|
 * | `groupId` も `customerId` も未指定 | ★`null`★(=条件なし・全施設) |
 * | 指定されたが該当0件 | ★`[]`★(=結果は0件) |
 * | 該当あり | 施設IDの配列 |
 *
 * ★★`null` と `[]` を取り違えないこと★★(★罠1 / 落とし穴1★)—
 *   `where` 側は `facility_ids !== null` で分岐する約束(`lib/db/index.ts`)なので、
 *   ★0件ヒットで `null` を返すと条件ごと消えて**全件表示**に化けます★。
 *   ★例外もテスト失敗も出ません。★
 *
 * ------------------------------------------------------------------------
 * ★★同時指定は AND(積集合)★★
 * ------------------------------------------------------------------------
 * `d-watch-api.yaml` の `GroupId` / `CustomerId` の description に書いた契約
 * (段階2 で明記済み)= ★「両方を満たす施設」★。★どちらか片方を捨てないこと★。
 * ★屋号がホールディングスに属していなければ、両方指定の結果は `[]` になる★
 * (それが正しい。`null` にしないこと)。
 *
 * ------------------------------------------------------------------------
 * ★★施設名の検索とは事情が違う★★(指示書AK §2-3)
 * ------------------------------------------------------------------------
 * 施設名がアプリ側照合なのは ★中点を無視する比較が MariaDB に無いから★
 * (`lib/facilities/resolve-by-name.ts` 冒頭の実測記録)。
 * ★こちらは整数の完全一致★なので、その制約はありません。
 * ★真似ているのは「`null` / `[]` の戻り値の契約」と「`in` に渡す形」だけ★です。
 *
 * ★★施設ごとにループでクエリを撃たないこと★★(★落とし穴14★)—
 *   施設マスタは実測12件なので ★1回読んでメモリで解決する★(§6-3)。
 */
import type { MasterIdFilters } from "@/lib/api/master-id";
import { db } from "@/lib/db";
import type { FacilityGroupRef } from "@/lib/types";

/**
 * 分類の条件を施設IDの配列に解決する★純粋関数★(DBアクセスを混ぜない)。
 *
 * @param refs 施設マスタの全行(`db.facilityGroupIndex()` の戻り値)
 * @param filters `parseMasterIdFilters()` が検証済みの `groupId` / `customerId`
 * @returns
 *   - ★★`null`★★ … どちらも未指定。= ★分類で絞らない★
 *   - ★★`[]`★★   … 絞ったが ★1件も当たらなかった★。= ★結果は0件★
 *   - それ以外 … 当たった施設IDの配列
 *
 * ★★どちらも未指定のときに `[]` を返さないこと★★ — `[]` は「0件ヒット」の意味なので、
 *   ★何も絞っていないのに一覧が空になります★。
 *
 * ★`group_id` の `null`(ホールディングスなし)は `groupId` 指定に当たらない★ —
 *   API の `group_id` は1以上しか受け付けない(`parseMasterIdParam()` が 0 を 422 にする)ので、
 *   ★「所属なしの施設だけを引く」経路はそもそも存在しません★(指示書AK §4-1 #3)。
 */
export function facilityIdsMatchingMasters(
  refs: readonly FacilityGroupRef[],
  filters: MasterIdFilters
): number[] | null {
  const { groupId, customerId } = filters;
  // ★★`!== null` で分岐すること★★ — `groupId || customerId` と書くと、
  //   将来 0 が入り込んだときに「未指定」に化ける(`parseMasterIdParam()` が
  //   0 を弾いているのは入口だけで、この関数の契約ではない)
  if (groupId === null && customerId === null) return null;
  return refs
    .filter(
      (ref) =>
        // ★★AND で畳むこと★★ — 片方が未指定ならその条件だけを素通しする
        (groupId === null || ref.group_id === groupId) &&
        (customerId === null || ref.customer_id === customerId)
    )
    .map((ref) => ref.id);
}

/**
 * 分類の条件を施設IDの配列に解決する(★API ルートから呼ぶ★)。
 *
 * ★★どちらも未指定のときは DB を読まずに `null` を返す★★ — 読んでも結果が変わらないので、
 *   ★「分類を選んでいない検索」で余計な往復を増やさない★
 *   (`resolveFacilityIdsByName()` が空文字でそうしているのと同じ)。
 *
 * ★★解決用の読み取りは `deleted_at` で絞らない★★(★罠7★)—
 *   理由は `loadFacilityGroupIndex()`(`lib/db/facility-group-source.ts`)の docstring。
 *   ★`db.facilities()` を流用しないこと★。
 */
export async function resolveFacilityIdsByMasters(
  filters: MasterIdFilters
): Promise<number[] | null> {
  if (filters.groupId === null && filters.customerId === null) return null;
  return facilityIdsMatchingMasters(await db.facilityGroupIndex(), filters);
}