Newer
Older
client_dwatch / lib / clean-logs / csv.ts
/**
 * GET /clean-logs/export のCSV組み立て。
 *
 * ルート(`app/api/clean-logs/export/route.ts`)から切り出してある。
 * 理由は★テストを書くため★で、ルートの `route.ts` は Next.js の App Router が
 * HTTPメソッド以外の export を想定していないため、ここに置いてから import する。
 * ★行の組み立て(`buildCleanLogCsv()`)も同じ理由でここに置く★ —
 * 「全列が `csvCell()` を通っているか」はセル単体のテストでは確かめられない。
 */
import { csvDocument, csvRate } from "@/lib/csv";
import {
  CLEAN_LOG_UNSCHEDULED_LABEL,
  CLEAN_RESULT_PENDING_LABEL,
  cleanLogRowView,
} from "@/lib/format/clean-result";
import { formatHm, formatYmdSlash } from "@/lib/format/date";
import type { CleanLogItem } from "@/lib/types";

/**
 * ★`csvCell()` の本体は `lib/csv.ts` へ移した★(D-163。レポートの施設別パフォーマンス CSV と共有するため)。
 * 既存の import 先(`@/lib/clean-logs/csv`)を壊さないよう、ここから再エクスポートする。
 */
export { csvCell } from "@/lib/csv";

/** ヘッダ行の見出し(モックの列順に合わせる) */
export const CLEAN_LOG_CSV_HEADER = [
  "施設名",
  "機種",
  "シリアル",
  "日付",
  "予定時刻",
  // ★事前チェックは「1回目 / 2回目」と「何分前か」の4列★(D-178。ユーザー判断 2026-10-16)—
  //   列は分数ではなく順番で割り当てる(早い方が1回目)ので、見出しに分数を書かず、分数は隣の列に数値で出す。
  //   ★旧: 「1h前ST」「30分前ST」★(設定で分数が変わると見出しと中身が食い違うため改めた)
  "事前チェック1回目",
  "1回目(分前)",
  "事前チェック2回目",
  "2回目(分前)",
  "出発",
  // ★画面(CleanLogTable)と同じ位置★ — `出発`(✓/✗)も `清掃結果` も出所は同じ
  // `clean_task_reports`(行の有無と `status`。指示書AQ §1-1 / §1-2)なので、
  // 隣に並べてその場で読めるようにする。★中身は `status` の日本語の表示名(例 `清掃正常終了`)★
  // (第10回 N-2。D-155 項2)。★6 は清掃キャンセル、7 以上は「不明(7)」★。★CSV は言語を受け取らないので
  // 見出しと同じく日本語固定★(指示書AS D11)。
  // 列順を画面と変えると、突き合わせる人が毎回並べ替えることになる(指示書AO 段階3)。
  "清掃結果",
  "完了率",
  "判定",
] as const;

/**
 * 清掃詳細ログのCSV本体を組み立てる(UTF-8 BOM付き・CRLF区切り)。
 *
 * ★全列を例外なく `csvCell()` に通す★
 *   「この列は固定文言だから安全」という判断を列ごとにすると、後から列を足した人が
 *   同じ判断を迫られて漏れる。実際 `displayRate()` は未実施(`completion_rate: null`)で
 *   ★`-` を返す★(`lib/judge/grade.ts`)ので、安全に見える列が
 *   数式インジェクションの入口になっていた。関門を1つにしておけば、
 *   列の追加は「生の文字列を並べる」だけで済む。
 *   ★`csvCell()` を通すのは `csvDocument()`(`lib/csv.ts`)の中★(D-163。ここでは包まない)。
 *
 * ★完了率は `csvRate()`(null は空欄)★(ユーザー指示 2026-10-06「値がないとこは空欄で」。D-164)。
 *   `displayRate()` の `-` を出すと関門が `'-` にして、Excel で `'-` と見えていた。
 *   ★清掃結果列(`task_status` が null)は値の欠けではなく表示名なので、ここでは変えない★。
 */
export function buildCleanLogCsv(rows: CleanLogItem[]): string {
  return csvDocument(
    CLEAN_LOG_CSV_HEADER,
    rows.map((r) => {
      // ★結果待ち(D-173)★ — 見え方は画面と同じ `cleanLogRowView()` で決める(ユーザー判断 2026-10-07)。
      //   結果待ちは 出発・清掃結果が「結果待ち」、★完了率・判定は空のセル★(「-」を入れない。D-164)
      const view = cleanLogRowView(r);
      return [
        r.facility_name,
        // ★null(清掃エリアに有効なロボットがいない。指示書BB U2・D-182)は空のセル★(`'-` にしない。D-164 と同じ理由)
        r.model_name ?? "",
        r.serial_number ?? "",
        // ★日付は年つき `YYYY/MM/DD`(指示書BB A4・D-182。旧: `M/D`。画面の `formatMd()` は変えない)★
        // ★予定外タスクの行(指示書BG §2-1)★ — 日付は `scheduled_at`(= `started_at`)の日付、予定時刻は「予定なし」(ユーザー指示 2026-11-04。旧: 「予定外」)。
        //   事前チェックの4列は null なので下で空のセルになる
        formatYmdSlash(r.scheduled_at),
        r.unscheduled ? CLEAN_LOG_UNSCHEDULED_LABEL : formatHm(r.scheduled_at),
        // ★null(事前チェックの予定が無い)は空のセル★(ユーザー指示 2026-10-07。D-171)。
        //   ★画面の「-」を入れないこと★ — `csvCell()` の数式対策で `'-` になる(D-164 と同じ理由)
        r.status_1h ?? "",
        // ★分数は数値のまま(例 60)。null(その回の予定が無い)は空のセル★(D-178)
        r.status_1h_minutes === null ? "" : String(r.status_1h_minutes),
        r.status_30m ?? "",
        r.status_30m_minutes === null ? "" : String(r.status_30m_minutes),
        view.departed === "pending"
          ? CLEAN_RESULT_PENDING_LABEL
          : view.departed === "ok"
            ? "出発"
            : "未出発",
        view.resultLabel,
        csvRate(view.completionRate),
        view.gradeShown ? r.grade : "",
      ];
    })
  );
}