Newer
Older
client_dwatch / lib / facilities / query.ts
/**
 * GET /facilities(F-08 施設ロボットマスタ)で使うクエリ処理。
 * 仕様の正は `d-watch-api.yaml` の /facilities。
 *
 * ★フィルタ・並べ替え・ページングをここ(純粋関数)に置く理由★
 *   `db.facilities()`(`lib/db/index.ts`)は全件を返すだけで、絞り込みを持たない。
 *   DBはTeamWorks管理でDFAはスキーマを変更しない(CLAUDE.md §2)ため、
 *   絞り込みはアプリ側の責務になる。ルート側に直接書くとテストから呼べなくなるので、
 *   DBアクセスを含まない純粋関数として切り出す(`lib/ds-devices/query.ts` と同じ形)。
 */
import { parseMasterIdFilters } from "@/lib/api/master-id";
import type { QueryParseResult } from "@/lib/api/query-result";
import { PAGINATION } from "@/lib/config";
import { includesFoldName, includesFoldPlain } from "@/lib/format/search-text";
import type { Facility, ListResponse } from "@/lib/types";

export interface FacilityQuery {
  /** 施設名 or 都道府県 or 住所 or シリアル or メーカー or 機種(OR・部分一致) */
  q: string;
  facilityName: string;
  serial: string;
  address: string;
  /**
   * ホールディングス(`groups.id`)で絞り込み。★`null` = 絞らない★(指示書AK 段階2)。
   * ★`customerId` と同時に指定されたら AND★(`d-watch-api.yaml` の `GroupId`)。
   */
  groupId: number | null;
  /** 顧客(`customers.id`)で絞り込み。★`null` = 絞らない★ */
  customerId: number | null;
  page: number;
  perPage: number;
}

function toInt(value: string | null, fallback: number): number {
  if (!value) return fallback;
  const n = Number.parseInt(value, 10);
  return Number.isNaN(n) ? fallback : n;
}

/**
 * クエリパラメータをパースする。
 *
 * ★★戻り値が `QueryParseResult` になった(指示書AK 段階2)★★
 *   ★以前は素の `FacilityQuery` を返していた★ — q / facility_name / serial / address は
 *   自由文字列で不正値という概念が無く、page / per_page は丸めるだけだったため、
 *   このエンドポイントには 422 になりうる入力が1つも無かった。
 *   ★`group_id` / `customer_id` が最初の「不正値がありうるパラメータ」になった★ので、
 *   旧 JSDoc の指示どおり `QueryParseResult<FacilityQuery>` に変え、
 *   ★YAML の /facilities にも 422 と2つのパラメータを追記した★(`CLAUDE.md` §6-1)。
 *
 * 文字列は前後の空白を除去する(`parseDsDeviceQuery` の q と同じ。
 * 検索欄からのコピペで前後に空白が入ると、除去しない実装では0件になってしまう)。
 *
 * ★検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる★
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 */
export function parseFacilityQuery(
  searchParams: URLSearchParams
): QueryParseResult<FacilityQuery> {
  const masters = parseMasterIdFilters(searchParams);
  if (!masters.ok) return masters;

  const page = Math.max(1, toInt(searchParams.get("page"), 1));
  const perPage = Math.min(
    PAGINATION.maxPerPage,
    Math.max(1, toInt(searchParams.get("per_page"), PAGINATION.defaultPerPage))
  );

  return {
    ok: true,
    query: {
      q: searchParams.get("q")?.trim() ?? "",
      facilityName: searchParams.get("facility_name")?.trim() ?? "",
      serial: searchParams.get("serial")?.trim() ?? "",
      address: searchParams.get("address")?.trim() ?? "",
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      page,
      perPage,
    },
  };
}

/**
 * 施設名 / 都道府県 / 住所 / シリアル / メーカー / 機種 の **OR** 部分一致(YAMLのqの定義通り)。
 * 大文字小文字は区別しない。
 *
 * ★照合はフィールドごとに強さを変える(指示書AG D-2)★
 *   - 施設名・都道府県 … `includesFoldName`(記号も無視する)。
 *     `ドン・キホーテ` の中点は表記ゆれで、桁位置の意味を持たない。
 *     都道府県の値(`東京都`/`神奈川県`)には記号が無いので実質 no-op だが、
 *     「名前を探す欄」として施設名と同じ規則にしておく。
 *   - 住所・シリアル・メーカー・機種 … `includesFoldPlain`(大文字小文字のみ)。
 *     ★住所のハイフンは番地の区切り、シリアルのハイフンは桁の境界★なので、
 *     除くと `23` が `1-2-3` に、`10001` が `PUDU-CC1-0001` に誤ヒットする
 *     (実例は `lib/format/search-text.ts` の冒頭)。
 *
 * ★`robots` が空配列でも落ちないこと★(未設置の施設は robots: [] で登録される。
 * `lib/fixtures/facilities.ts` の id:12)。`some()` は空配列に対して false を返すので、
 * 「ロボット情報では一致しない施設」として自然に扱われる。
 */
export function filterFacilitiesByQ(items: Facility[], q: string): Facility[] {
  if (!q) return items;
  return items.filter(
    (item) =>
      includesFoldName(item.facility_name, q) ||
      includesFoldName(item.prefecture, q) ||
      includesFoldPlain(item.address, q) ||
      item.robots.some(
        (robot) =>
          includesFoldPlain(robot.serial_number, q) ||
          includesFoldPlain(robot.maker, q) ||
          includesFoldPlain(robot.model, q)
      )
  );
}

/**
 * 個別パラメータ(facility_name / serial / address)による絞り込み。
 *
 * ★個別パラメータ同士は AND、q との関係も AND(OR になるのは q の内部だけ)★
 *   — YAML の /facilities の q の description の通り。
 *   空文字のパラメータは「指定なし」として素通しする。
 *
 * ★serial は `robots[]` を横断し、1台でも一致すればその施設をヒットとする★
 *   施設は複数台を持ちうる(P1は1清掃エリア=1ロボットだが、施設単位では複数台ありうる。
 *   `lib/fixtures/facilities.ts` の id:7 は3台)。先頭の1台だけを見る実装にすると、
 *   2台目以降のシリアルで検索した運用担当が「登録されていない」と誤解する。
 *
 * ★個別パラメータも q と完全に同じ照合規則にする★
 *   YAML が明示しているのは q のみだが、シリアル(例: 8111R5628060011)は英大文字を含み、
 *   区別する実装だと「8111r」と打った時だけ0件になる。同じ画面の検索が
 *   入力欄によって当たったり当たらなかったりするほうが事故になるため揃える。
 *   ★記号の扱いも同様に揃える★ — facility_name は `includesFoldName`、
 *   address / serial は `includesFoldPlain`(q の側と1文字も違わない。指示書AG D-2)。
 *
 * ★この個別パラメータはどの画面からも送られていない(API直叩き専用)★
 *   それでも API 契約上は存在するので、q だけ直して放置すると
 *   「同じAPIのパラメータ間で挙動が違う」状態が新規に生まれる(指示書AG 罠10)。
 */
export function filterFacilitiesByFields(
  items: Facility[],
  query: Pick<FacilityQuery, "facilityName" | "serial" | "address">
): Facility[] {
  const { facilityName, serial, address } = query;

  return items.filter((item) => {
    if (facilityName && !includesFoldName(item.facility_name, facilityName)) {
      return false;
    }
    if (address && !includesFoldPlain(item.address, address)) return false;
    if (
      serial &&
      !item.robots.some((robot) =>
        includesFoldPlain(robot.serial_number, serial)
      )
    ) {
      return false;
    }
    return true;
  });
}

/**
 * id の昇順。
 *
 * ★安定化のためだけの規則で、運用上の優先度を表すものではない★
 *   (`sortDsDevices` の serial_number 昇順と同じ考え方)。
 *   このマスタは読み取り専用の台帳で、「異常を上に出す」といった優先度が存在しない。
 *   一方で並べ替えを一切しないと、`db.facilities()` の返す順(fixtures の記述順・
 *   DB接続後は実行計画次第)に画面が左右され、リロードのたびに行が入れ替わって見える。
 *   id は不変で重複しないため、これだけで順序が一意に決まる。
 */
export function sortFacilities(items: Facility[]): Facility[] {
  return [...items].sort((a, b) => a.id - b.id);
}

/**
 * ページングを適用してレスポンス本体を組み立てる。
 *
 * ★`meta.total` は「フィルタ後・ページング前」の件数★
 *   画面のページャはこの値で総ページ数を出す。ページング後の件数(items.length)を
 *   入れると、2ページ目以降が存在しないことになってページ送りができなくなる。
 *   範囲外のページ(例: 3ページしかないのに page=99)でも total は全体の件数のまま返し、
 *   items だけが空になる。
 *
 * 組み立てまでをここに置くのは、この「total の意味」をルートではなくテストで
 * 固定できるようにするため(ルートは DB アクセスを含むため純粋関数として呼べない)。
 */
export function paginateFacilities(
  items: Facility[],
  page: number,
  perPage: number
): ListResponse<Facility> {
  const total = items.length;
  const start = (page - 1) * perPage;
  return {
    items: items.slice(start, start + perPage),
    meta: { total, page, per_page: perPage },
  };
}