Newer
Older
client_dwatch / lib / ds-devices / query.ts
/**
 * GET /ds-devices で使うクエリ処理。
 * 仕様の正は `d-watch-api.yaml` の /ds-devices。ソート順は D-055。
 */
import { parseMasterIdFilters } from "@/lib/api/master-id";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { PAGINATION } from "@/lib/config";
import { includesFoldName, includesFoldPlain } from "@/lib/format/search-text";
import type { DsDevice, DsDeviceStatusFilter } from "@/lib/types";

const STATUS_FILTERS: readonly DsDeviceStatusFilter[] = ["all", "error", "ok"];

export interface DsDeviceQuery {
  q: string;
  status: DsDeviceStatusFilter;
  /**
   * ホールディングス(`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;
}

/**
 * クエリパラメータをパースする。
 * 不正な値があれば `{ ok: false }` を返す(`error` / `status` をそのまま422応答に使う。
 * どのパラメータが不正かは `error.error.details` に入る)。
 * ★group_id / customer_id を読む(指示書AK 段階2。D-053 の「受け取っても無視する」は解除)★
 *   検証は `parseMasterIdFilters()`(`lib/api/master-id.ts`)の1本に任せる。
 *   ★`Number.parseInt()` で書き直さないこと★(`1abc` が `1` として通る。D-123)。
 *
 * page / per_page は不正値でも失敗させず、既定値・上限へ丸める(/clean-logs と同じ挙動)。
 */
export function parseDsDeviceQuery(
  searchParams: URLSearchParams
): QueryParseResult<DsDeviceQuery> {
  const statusRaw = searchParams.get("status") ?? "all";
  if (!STATUS_FILTERS.includes(statusRaw as DsDeviceStatusFilter)) {
    return fail(422, "validation_failed", "statusの値が不正です", {
      status: [`${STATUS_FILTERS.join(" / ")} のいずれかを指定してください`],
    });
  }

  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() ?? "",
      status: statusRaw as DsDeviceStatusFilter,
      groupId: masters.query.groupId,
      customerId: masters.query.customerId,
      page,
      perPage,
    },
  };
}

/**
 * 施設名・シリアルの部分一致(YAMLのqの定義通り)。大文字小文字を区別しない。
 *
 * ★照合は `lib/format/search-text.ts` の1本に寄せる(指示書AG D-1)★
 *   施設名は記号(中点・長音・空白・ハイフン類)も無視する = `includesFoldName`。
 *   ★シリアルは大文字小文字のみ★ = `includesFoldPlain`
 *   (D-S制御盤のシリアルは `DS-001` 形式。記号を除くと桁の境界が消える。D-2)。
 */
export function filterDsDevicesByQ(items: DsDevice[], q: string): DsDevice[] {
  if (!q) return items;
  return items.filter(
    (item) =>
      includesFoldName(item.facility_name, q) ||
      includesFoldPlain(item.serial_number, q)
  );
}

/** error = "error"(YAMLの定義通り。通信断は「異常」そのものなので別値を持たない。D-065) */
export function filterDsDevicesByStatus(
  items: DsDevice[],
  status: DsDeviceStatusFilter
): DsDevice[] {
  return items.filter((item) => {
    switch (status) {
      case "error":
        return item.connection_status === "error";
      case "ok":
        return item.connection_status === "ok";
      case "all":
        return true;
    }
  });
}

const CONNECTION_STATUS_ORDER: Record<DsDevice["connection_status"], number> = {
  error: 0,
  ok: 1,
};

/**
 * 異常→正常の順。同順位は serial_number の昇順で安定させる(D-055)。
 * ★安定化のためだけの規則で、運用上の優先度を表すものではない★
 */
export function sortDsDevices(items: DsDevice[]): DsDevice[] {
  return [...items].sort((a, b) => {
    const order =
      CONNECTION_STATUS_ORDER[a.connection_status] -
      CONNECTION_STATUS_ORDER[b.connection_status];
    if (order !== 0) return order;
    return a.serial_number < b.serial_number
      ? -1
      : a.serial_number > b.serial_number
        ? 1
        : 0;
  });
}

export interface DsDeviceSummary {
  total: number;
  ok: number;
  /** "error" の件数(YAMLの定義通り。通信断は「異常」に含まれる。D-065) */
  error: number;
}

/**
 * デバイスの現況サマリ。
 *
 * ★summary はフリートの部分集合(日付・グループ・会社)で絞る。
 *   閲覧の絞り込み(q・status・type)では絞らない。★
 *   グループ・会社マスタ導入時は、ここに絞り込みを追加する(現在は D-053 で未実装)。
 *   この区別を崩すと、F-03 で起きた「既定表示で解消済みが常に0」と同じ不具合になる(D-054)。
 */
export function summarizeDsDevices(items: DsDevice[]): DsDeviceSummary {
  const ok = items.filter((item) => item.connection_status === "ok").length;
  const error = items.filter((item) => item.connection_status === "error").length;
  const total = items.length;
  return { total, ok, error };
}