Newer
Older
client_dwatch / lib / schedules / query.ts
/**
 * GET /schedules で使うクエリ処理(F-09 スケジュール管理・読み取り専用)。
 * 仕様の正は `d-watch-api.yaml` の /schedules。
 *
 * `db.schedules()` は全件を返すだけなので、絞り込み・並べ替え・ページングはここで組み立てる
 * (`lib/ds-devices/query.ts` と同じ分担。`lib/db/` はデータ取得だけを担う)。
 *
 * ------------------------------------------------------------------------
 * ★実DB(`prismaSource`)実装時の注意★
 * ------------------------------------------------------------------------
 * 現在の `db.schedules()` は fixtures を返すだけで、Prisma 実装は未着手。
 * 下記はいずれも「型エラーもテスト失敗も出ないまま静かに壊れる」箇所なので、
 * `prismaSource.schedules()` を書く人は必ず読むこと。
 *
 * 1. ★論理が反転している★
 *    DB は `CleanSchedule.inactive`(`prisma/schema.prisma`。`Int @default(0)` で **0 = 有効**)、
 *    API/TS は `active: boolean`。名前が似ているので
 *    `active: Boolean(row.inactive)` と書きたくなるが、**それでは全件反転する**
 *    (有効なスケジュールが「無効」として並ぶ)。正しくは `active: row.inactive === 0`。
 *
 * 2. ★カラム名が違う★
 *    DB は `weeks`(`VarChar(7)`)、API/TS は `weekdays`。単純な spread では移せない。
 *
 * 3. ★時刻は `HHMM` の4桁文字列★
 *    `start_time` は 0000〜2359 だが、`end_time` の「時」は **24〜29 を取りうる**
 *    (`"2830"` = 翌日 4:30。日跨ぎ清掃を表現するため)。`HH:MM` として素朴にパースすると壊れる。
 *    API は D-060 に従い `"翌04:30"` の**整形済み文字列**で返す。
 *    なお整形は `db.schedules()`(データ層)が済ませてから `Schedule` を返す約束で、
 *    このクエリ層は一切関与しない(ここは事実の書き残しのみ)。
 *
 * 4. ★`weekdays_label` は DB に存在しない★
 *    表示用ラベル(「毎日」等)は**サーバ側で `weeks` から生成する**必要がある。
 *    DB から素直に select しただけでは埋まらない。
 */
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 { Schedule } from "@/lib/types";

export interface ScheduleQuery {
  /** 施設名 or シリアルの部分一致(この中だけが OR) */
  q: string;
  /** 施設名の部分一致 */
  facilityName: string;
  /** シリアルの部分一致 */
  serial: 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;
}

/**
 * クエリパラメータをパースする。
 *
 * page / per_page は不正値でも失敗させず、既定値・上限へ丸める
 * (`parseDsDeviceQuery` / `parseCleanLogQuery` と同じ丸め方。
 *  ページャの URL を手で編集した程度で 422 を返さないため)。
 *
 * ★group_id / customer_id を読む(指示書AK 段階2)★
 *   検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 *   ★このファイルには D-053 の「無視する」コメントが元から無かった★ —
 *   YAML の /schedules にもこの2つの宣言が無かったので、今回 YAML にも足した
 *   (`CLAUDE.md` §6-1「API契約を変えたくなったら、先に YAML を直す」)。
 *
 * ★戻り値を `QueryParseResult` にしてあった理由がここで効く★
 *   「後から絞り込み条件が増えたときに **ルート側を書き換えずに** 422 を返せる」
 *   ように用意されていた器に、★実際に最初の 422(group_id / customer_id)が入った★。
 */
export function parseScheduleQuery(
  searchParams: URLSearchParams
): QueryParseResult<ScheduleQuery> {
  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() ?? "",
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      page,
      perPage,
    },
  };
}

/**
 * 施設名・シリアルの部分一致(YAML の q の定義通り)。大文字小文字を区別しない。
 * ★q の内部だけが OR★(facility_name / serial とは AND。YAML の q の description)。
 *
 * ★照合は `lib/format/search-text.ts` の1本に寄せる(指示書AG D-1)★
 *   施設名は記号(中点・長音・空白・ハイフン類)も無視する = `includesFoldName`。
 *   ★シリアルは大文字小文字のみ★ = `includesFoldPlain`
 *   (記号を除くと桁の境界が消えて誤ヒットする。D-2。実例は search-text.ts 冒頭)。
 */
export function filterSchedulesByQ(items: Schedule[], q: string): Schedule[] {
  if (!q) return items;
  return items.filter(
    (item) =>
      includesFoldName(item.facility_name, q) ||
      // ★シリアルが `null`(清掃エリアに有効なロボットがいない。指示書BB U2・D-182)の行は当たらない★(表2 R8)
      (item.serial_number !== null && includesFoldPlain(item.serial_number, q))
  );
}

/**
 * q / facility_name / serial をすべて AND で適用する。
 *
 * ★`active` では絞り込まない★
 *   一覧は有効・無効の**両方**を返す。画面(F-09)は「有効」列で見せ分ける仕様で、
 *   API 側で無効行を落とすと「登録したはずのスケジュールが消えた」ように見える。
 *   YAML の /schedules にも active のクエリパラメータは無い。
 *
 * ★serial は横断検索しない★
 *   `Schedule` は 1 レコード 1 シリアル(P1 は 1清掃エリア = 1ロボット。CLAUDE.md §8)なので、
 *   施設内の他ロボットまで見に行く必要はない。
 *
 * ★個別パラメータは q と完全に同じ照合規則にする★(指示書AG D-1 / 罠10)
 *   この2つはどの画面からも送られていない(API直叩き専用)が、API 契約上は存在する。
 *   q だけ直して放置すると「同じAPIのパラメータ間で挙動が違う」状態が新規に生まれる。
 */
export function filterSchedules(
  items: Schedule[],
  query: ScheduleQuery
): Schedule[] {
  const byQ = filterSchedulesByQ(items, query.q);
  return byQ.filter((item) => {
    if (
      query.facilityName &&
      !includesFoldName(item.facility_name, query.facilityName)
    ) {
      return false;
    }
    if (
      query.serial &&
      !(item.serial_number !== null && includesFoldPlain(item.serial_number, query.serial))
    ) {
      return false;
    }
    return true;
  });
}

/**
 * id の昇順。
 *
 * F-09 は「登録済みスケジュールの台帳」であり、優先度で並べ替える画面ではない。
 * 曜日や時刻で並べると、同じ施設の行が離れて見比べにくくなるうえ、
 * 「翌04:30」のような整形済み文字列を持つ end_time では意図した順序にならない。
 * 登録順(= id 昇順)が一番安定して予測しやすいので、それを正とする。
 */
export function sortSchedules(items: Schedule[]): Schedule[] {
  return [...items].sort((a, b) => a.id - b.id);
}

/**
 * ページを切り出す。
 *
 * ★ルート側で `slice` を直書きせず関数に出している理由★
 *   兄弟API(ds-devices 等)はルート内で slice しているが、その形だと
 *   ページング境界(最終ページの端数・範囲外ページ)を検証するテストが
 *   「ルートと同じ計算をテスト側にも書き写す」ことになり、
 *   両方を同じように間違えたときに素通りする。ここに出しておけば
 *   テストが**本番と同じ関数**を叩ける。
 *
 * `page` が範囲外なら空配列を返す。総件数(`meta.total`)は絞り込み後の全件数であって
 * このページの件数ではないので、呼び出し側は `items.length` を total に使わないこと。
 */
export function paginateSchedules(
  items: Schedule[],
  page: number,
  perPage: number
): Schedule[] {
  const start = (page - 1) * perPage;
  return items.slice(start, start + perPage);
}