Newer
Older
client_dwatch / lib / facilities / resolve-by-name.ts
/**
 * 施設名 → 施設ID の解決(指示書AC 段階2 ★D-6★ / ★C-1★)。
 *
 * ------------------------------------------------------------------------
 * ★★なぜ「名前で絞る」のではなく「IDで絞る」のか★★(指示書AG の帰結。2026-09-13)
 * ------------------------------------------------------------------------
 * 施設名の検索は ★記号(中点・長音・空白・ハイフン類)を無視した部分一致★ です
 * (`lib/format/search-text.ts` の `includesFoldName`。指示書AG D-1〜D-5)。
 * ★`ドンキホーテ` と打って `ドン・キホーテ戸塚` が引けます★。
 *
 * ★★この照合を MariaDB 側で再現する手段がありません★★:
 *   - 中点を無視して比較する関数が MariaDB に無い
 *   - 用意するには生成カラム+索引=★スキーマ変更(`CLAUDE.md` §7 で禁止)★
 *   - 生SQL も禁止(指示書AC §E-1。D-122 の ESLint ルールが落とす)
 *
 * > ★★★「MySQL の ci(case-insensitive)照合順序なら中点も無視されるはず」は誤り。実測済み★★★
 * > `'ドン・キホーテ戸塚' LIKE '%ドンキホーテ%'` は
 * > ★`utf8mb4_unicode_ci` / `utf8mb4_general_ci` / `utf8mb4_0900_ai_ci` の**3種すべてで 0**★
 * > (中点入りの `'%ドン・キホーテ%'` なら 1)。
 * > ★★この段落を消さないこと★★ — 後任が「照合順序でできるはずだ」と戻ってくるのを
 * > 防ぐための実測記録です(指示書AG 段階5 / 罠5)。
 *
 * ★★だから照合はアプリ側(施設マスタ12件)で行い、当たった**施設IDの配列**を
 *   `where: { facility_id: { in: [...] } }` に渡します★★。
 *   ★清掃ログ本体(本番想定で約26万行)は SQL で絞ったまま★なので、
 *   ★むしろ日付だけで絞るより縮みます★。
 *
 * > ★★Prisma の `contains` を使う案に戻らないこと★★ — ★`contains` は `%` と `_` を
 * > エスケープしません★(実測: `contains: "%"` → 施設12件が全部ヒット。
 * > JS の `.includes("%")` なら0件)。★本案なら施設名を SQL に渡さないので、
 * > この問題自体が発生しません★。
 *
 * ★★照合規則をここに書き下さないこと★★ — 正は `includesFoldName` の1本です
 * (`CLAUDE.md` §4.6 が契約フラグに課したのと同じ規律)。
 */
import { db } from "@/lib/db";
import { includesFoldName } from "@/lib/format/search-text";
import type { FacilityNameRef } from "@/lib/types";

/**
 * 施設名の条件を施設IDの配列に解決する★純粋関数★(DBアクセスを混ぜない)。
 *
 * @returns
 *   - ★★`null`★★ … 施設名の条件が無い(★空文字★)。= ★施設で絞らない★
 *   - ★★`[]`★★   … 照合したが ★1件も当たらなかった★。= ★結果は0件★
 *   - それ以外 … 当たった施設IDの配列
 *
 * ★★空文字で `[]` を返さないこと★★ — `[]` は「0件ヒット」の意味なので、
 *   ★施設名を入力していないのに一覧が空になります★(`CleanLogDbQuery.facility_ids`
 *   の型コメント / 罠1)。
 *
 * ★`ドンキ` のように複数施設に当たりうる★ので戻り値は配列です
 * (`facility_id`(単数)にしないこと)。
 */
export function facilityIdsMatchingName(
  refs: readonly FacilityNameRef[],
  name: string
): number[] | null {
  if (name === "") return null;
  return refs
    .filter((ref) => includesFoldName(ref.facility_name, name))
    .map((ref) => ref.id);
}

/**
 * 施設名の条件を施設IDの配列に解決する(★API ルートから呼ぶ★)。
 *
 * ★★施設ごとにループでクエリを撃たないこと★★ — ★1クエリで全件引いてメモリで照合する★
 *   (施設マスタは実測12件。指示書AC D-6)。
 *
 * ★★空文字のときは DB を読まずに `null` を返す★★ — 読んでも結果が変わらないので、
 *   ★「施設名を入れていない検索」で余計な往復を増やさない★。
 *
 * ★★解決用の読み取りは `deleted_at` で絞らない★★(★罠7★)—
 *   理由は `loadFacilityNameIndex()`(`lib/db/facility-name-source.ts`)の docstring。
 *   ★`db.facilities()` を流用しないこと★。
 */
export async function resolveFacilityIdsByName(name: string): Promise<number[] | null> {
  if (name === "") return null;
  return facilityIdsMatchingName(await db.facilityNameIndex(), name);
}