/**
* 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) ||
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 &&
!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);
}