Newer
Older
client_dwatch / lib / csv.ts
/**
 * CSV の共通部品(セルの整形・文書の組み立て)。
 *
 * ★清掃詳細ログ(`lib/clean-logs/csv.ts`)とレポートの施設別パフォーマンス
 * (`lib/reports/facility-performance-csv.ts`)が同じ関門を通るように、ここへ集めてある★(D-163)。
 * 数式インジェクション対策(D-125)を CSV ごとに書き直すと、片方だけ直して
 * もう片方が抜ける。`csvCell()` は `lib/clean-logs/csv.ts` から★コメントごと★移した。
 */
import { displayRate } from "@/lib/judge";

/**
 * 数式として解釈されうる先頭文字。
 *
 * ★表計算ソフト(Excel / Google スプレッドシート)は、セルの先頭が
 * `=` `+` `-` `@` だとその中身を★数式★として解釈する★。
 * これがいわゆるCSVインジェクションで、CSVを開いた人の手元で意図しない処理が動く。
 *
 * ★TAB(0x09) と CR(0x0D) も入れてある★ — OWASP の CSV Injection の対策は
 * この2つも先頭文字として挙げている。取り込み時に前後の空白が落とされた結果、
 * ★次の文字(`=` 等)が先頭に来る★ため、上の4文字と同じ危険がある。
 * - TAB始まりは★下の引用判定(`/[",\n\r]/`)に引っかからない★ので、ここで
 *   前置しないと★何の加工も受けずに素通りする★
 * - CR始まりは引用はされるが、★表計算ソフトは引用符を外してから中身を評価する★ので
 *   引用だけでは止まらない。`'` の前置が要る
 *
 * ★値の「途中」にある TAB / CR は対象外★ — 数式として解釈されるのは先頭だけで、
 * 途中まで加工すると正常な値まで書き換えてしまう(過剰防御)。
 */
const FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"];

/**
 * CSVの1セルを整形する。
 *
 * 処理は2段階で、★この順序に意味がある★。
 *
 * 1. ★数式インジェクション対策★ — 先頭が `FORMULA_PREFIXES` のいずれか
 *    (`=` `+` `-` `@` `TAB` `CR`)なら `'` を前置して
 *    「文字列である」と表計算ソフトに伝える。
 *    ★対策を入れた理由★(指示書AE 段階2-4 / D-125):
 *    CSVに出る値のうち施設名・機種名・シリアルは★メインDB由来★で、
 *    そこへ書いているのは★TeamWorks のデータ収集処理★(`CLAUDE.md` §2)。
 *    つまり★DFA側では中身を保証できない★。P1の利用者はDFA社内のみだが、
 *    保証できない値をそのまま数式として実行させる理由もないため、
 *    `csvCell()` の内側だけで完結する対策を入れてある。
 *
 * 2. `"` / `,` / 改行を含むなら全体を `"` で囲み、`"` を `""` に置換する(RFC 4180)。
 *
 * ★1→2 の順でないと、付けた `'` が引用符の外に出て効かなくなる★。
 */
export function csvCell(value: string): string {
  const guarded = FORMULA_PREFIXES.some((p) => value.startsWith(p)) ? `'${value}` : value;
  if (/[",\n\r]/.test(guarded)) {
    return `"${guarded.replaceAll('"', '""')}"`;
  }
  return guarded;
}

/**
 * CSV の率のセル(四捨五入の整数+`%`)。★値が無い(null)ときは空欄★。
 *
 * ★空欄にする理由★(ユーザー指示 2026-10-06「値がないとこは空欄で」。D-164):
 * 画面用の `displayRate()` は null で `-` を返す。それをそのまま CSV に出すと、
 * 数式インジェクション対策(`csvCell()`。D-125)が先頭の `-` に `'` を前置して `'-` になり、
 * ★Excel で開くと `'-` がそのまま見えていた★。CSV では「値が無い」を空のセルで表す。
 *
 * ★丸めは表示のみ★(CLAUDE.md §4.3)— 判定には使わない。値があるときは `displayRate()` と同じ文字列。
 * ★清掃詳細ログとレポートの施設別パフォーマンスの両方の CSV がこれを使う★(片方だけ直して抜けないように)。
 */
export function csvRate(rate: number | null): string {
  return rate === null ? "" : displayRate(rate);
}

/**
 * CSV 文書全体を組み立てる(UTF-8 BOM付き・CRLF区切り・最後の行も CRLF で終える)。
 *
 * ★データ行は全セルを例外なく `csvCell()` に通す★(D-125)。
 *   呼び出し側は「生の文字列を並べるだけ」にして、列ごとに安全かどうかを判断させない。
 *   「この列は固定文言だから安全」という判断を列ごとにすると、後から列を足した人が
 *   同じ判断を迫られて漏れる(`displayRate()` の `-` が実例。`lib/clean-logs/csv.ts`。
 *   ★いまは率の列は `csvRate()` で null を空欄にしている★(D-164)が、関門は引き続き全セルに通す)。
 *
 * ★ヘッダ行はエスケープしない★ — 見出しはコードに書いた固定の日本語で、
 *   清掃詳細ログの元の実装(`CLEAN_LOG_CSV_HEADER.join(",")`)と出力を1バイトも変えないため(D-163)。
 *
 * 先頭のBOMは Excel が UTF-8 と判別するために必要(清掃詳細ログの route.ts から移設したもの)。
 * 0件のときは `BOM + ヘッダ + CRLF` だけになる。
 */
export function csvDocument(
  header: readonly string[],
  rows: readonly (readonly string[])[]
): string {
  const lines = [header.join(","), ...rows.map((r) => r.map(csvCell).join(","))];
  return `${lines.join("\r\n")}\r\n`;
}