Legacy PHP 匯出 Excel:從踩坑到自動化的真實歷程


在 Legacy PHP 專案中,「匯出 Excel」大概是被低估最多的功能。聽起來很簡單——查資料、寫儲存格、輸出檔案。但實際做起來,你會遇到中文亂碼、HTML 殘留、公式差一格、身分證外洩……每一個都是血淚教訓。

這篇整理了六個從真實專案中踩過的 Excel 匯出坑,以及如何用 Claude Code 加速修復。


背景:20+ 個匯出檔案的歷史包袱

專案中有超過 20 個 PHP 檔案使用 PHPExcel(後來的 PhpSpreadsheet)處理 Excel 匯出。大部分寫於多年前,使用舊的 Excel5.xls)格式。問題是:

  • 不同時期不同人寫的,風格不統一
  • 有些直接用 header() 輸出 HTML 假裝是 Excel
  • 有些用 PHPExcel 正規產生
  • 中文處理方式各不相同

坑一:Excel5 升級 Excel2007——做了、revert、再做

問題

舊的 .xls 格式(Excel5)有 65,536 列限制,某些報表已經超過了。需要升級到 .xlsx(Excel2007)。

聽起來只要改兩行:

// 改 writer 類型
$writer = PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel2007');

// 改 Content-Type 和副檔名
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="report.xlsx"');

實際踩到的坑

改完部署後,中文全部變亂碼。原因是 Excel2007 格式對字元編碼更嚴格,舊資料中的控制字元\x00-\x1F)在 Excel5 下被默默忽略,在 Excel2007 下直接讓檔案損壞。

// ✅ 需要額外處理
$value = special_decode_string($rawValue);
$value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value);

時間線

  1. Day 1:改了,部署
  2. Day 2:使用者回報亂碼,revert
  3. 一週後:加上字元清理,重新部署,成功

教訓:格式升級不只是改 writer 類型。每個字串值都可能藏有看不見的控制字元。

Claude Code 的角色

> Excel5 升級 Excel2007 後中文亂碼,幫我查可能的原因

Claude Code 指出 Excel2007(基於 XML)不允許控制字元,建議用 preg_replace 清理。這個知識點你要自己 Google 會花不少時間。


坑二:  滲入 Excel 儲存格

問題

匯出的 Excel 儲存格中出現了   文字(不是空格,是字面的  )。

根因

資料來自 HTML 富文字編輯器,儲存時包含 HTML 標籤。匯出時用 strip_tags() 移除標籤,但   不是標籤,不會被移除:

// ❌ strip_tags 不處理 HTML entities
$content = strip_tags($htmlContent);
// "<p>Hello&nbsp;World</p>" → "Hello&nbsp;World"

更麻煩的是,有些 &nbsp 沒有分號(是的,HTML 允許這樣):

<!-- 兩種都會出現 -->
Hello&nbsp;World
Hello&nbsp World

修復

// ✅ 先處理 HTML entities,再 strip_tags
$content = str_replace(
    ['</div>', '<br>', '&nbsp;', '&nbsp'],
    ["\n",     "\n",   ' ',      ' '],
    $htmlContent
);
$content = strip_tags($content);
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');

處理順序很重要:先替換已知的 entities(&nbsp;),再 strip_tags,最後 html_entity_decode 處理剩餘的。

Claude Code 的角色

> 匯出 Excel 有 &nbsp; 亂碼,資料來自 HTML 編輯器

Claude Code 一次指出三個問題:strip_tags 不處理 entities、&nbsp 有帶分號和不帶分號兩種、處理順序需要先 replace 再 strip。


坑三:COUNTA 公式差一筆——23 查到 22

問題

畫面上查詢結果顯示 23 筆,匯出的 Excel 報表底部合計卻顯示 22 筆。

根因

寫入 Excel 時用一個 $row 變數追蹤行號。迴圈結束後,$row 指向最後一筆資料的下一行。寫 COUNTA 公式時:

// ❌ $row 已經多加了 1,再 -1 就少了最後一筆
$countFormula = "=COUNTA(D5:D" . ($row - 1) . ")";

假設資料從第 5 行到第 27 行(23 筆),迴圈結束時 $row = 28。公式變成 =COUNTA(D5:D27),看起來對?不對——因為在某些地方 $row 在迴圈結束後又被加了 1(用來寫空白分隔行),實際 $row = 29$row - 1 = 28……這種 off-by-one 在複雜的匯出邏輯中極其難追。

修復

// ✅ 直接用最後一筆資料的行號
$countFormula = "=COUNTA(D5:D" . $row . ")";

為什麼 Claude Code 有優勢

這種 Bug 需要追蹤 $row 在整個檔案中的每一次修改。在 300 行的匯出檔案裡,$row++ 可能出現在五六個地方。Claude Code 會把每一次 $row 變化列出來,算出最終值。


坑四:身分證字號外洩——匯出檔忘了遮蔽

問題

電子發票匯出功能直接把完整的身分證字號寫進 Excel。依規定,匯出檔中身分證後四碼應該用 **** 遮蔽。

修復

// ❌ 原本直接使用完整身分證
$memo = $data['id_no'] . '-' . $data['level'];

// ✅ 後四碼遮蔽
$maskedId = substr($data['id_no'], 0, -4) . '****';
$memo = $maskedId . '-' . $data['level'];

看起來很簡單,但問題是你要先知道有這個問題。在 20 幾個匯出檔案中,哪些有身分證欄位?哪些已經遮蔽、哪些沒有?

Claude Code 的角色

> 掃描所有匯出 Excel 的 PHP 檔案,找出包含身分證字號但沒有遮蔽的地方

Claude Code 一次掃完所有匯出檔案,列出每一個使用 id_no 欄位的位置和處理方式。


坑五:從 HTML 列印改成 Excel 匯出——減了 500 行

問題

某個報表原本用 HTML <table> 產生,配合 @media print CSS 列印。但使用者要求「可以在 Excel 裡編輯」。原始的 HTML 列印版有 740 行 PHP。

做法

Claude Code 建議的策略:用 Excel 範本檔 + 資料填入,取代從零建立儲存格。

// ✅ 載入預製的 .xlsx 範本
$spreadsheet = IOFactory::load('templates/report_template.xlsx');
$sheet = $spreadsheet->getActiveSheet();

// 填入資料
$row = 5; // 範本中資料起始行
foreach ($records as $record) {
    $sheet->setCellValue("A{$row}", $record['date']);
    $sheet->setCellValue("B{$row}", $record['name']);
    $sheet->setCellValue("C{$row}", $record['amount']);
    $row++;
}

// 輸出
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save('php://output');

範本檔用 Excel 先做好格式(欄寬、表頭、框線、字型),PHP 只負責填資料。

結果:740 行 HTML 列印 → 300 行 Excel 匯出。格式穩定,使用者可以編輯。


坑六:匯入功能的逐行回饋——讓使用者知道哪行出錯

問題

批次匯入功能(從 Excel 匯入資料到系統)的第一版只告訴使用者「匯入完成」或「匯入失敗」。使用者不知道 500 筆裡哪幾筆有問題。

改進

Claude Code 幫忙設計了逐行回饋機制:

$importLogs = [];

foreach ($excelRows as $i => $row) {
    try {
        // 驗證 + 寫入
        $existing = findByIdNo($row['id_no']);
        if ($existing) {
            $importLogs[] = [
                'row' => $i,
                'status' => 'skip',
                'message' => "Excel 姓名:{$row['name']} ↔ 系統已存在:{$existing['name']}"
            ];
            continue;
        }
        insertRecord($row);
        $importLogs[] = ['row' => $i, 'status' => 'success', 'message' => ''];
    } catch (Exception $e) {
        $importLogs[] = ['row' => $i, 'status' => 'error', 'message' => $e->getMessage()];
    }
}

// 存入 session,重導後顯示
$_SESSION['import_logs'] = $importLogs;

前端用顏色標示:✓ 成功(綠)/ ⊘ 略過(黃)/ ✗ 失敗(紅),附上原因。


Excel 匯出的踩坑清單

從這些案例整理出的檢查清單:

編碼與格式

  • 使用 Excel2007 (.xlsx) 格式,不要用 Excel5 (.xls)
  • 所有字串值清理控制字元:preg_replace('/[\x00-\x1F\x7F]/u', '', $value)
  • HTML entities 在寫入前轉換:先 str_replace 已知 entities,再 html_entity_decode
  • strip_tags() 要在 entity 處理之後,不是之前

公式與計算

  • 行號追蹤要明確:迴圈結束後 $row 指向哪裡?
  • 合計公式的範圍要驗證:匯出後實際打開 Excel 確認

個資保護

  • 身分證字號後四碼遮蔽
  • 聯絡電話考慮部分遮蔽
  • 掃描所有匯出檔案確認沒有遺漏

架構建議

  • 優先使用範本檔(.xlsx),不要從零建立
  • 匯入功能要有逐行回饋
  • 大量資料使用串流寫入,避免記憶體爆掉

結語

Excel 匯出看起來是最「無聊」的功能,但它是使用者每天都在用的東西。匯出錯一格,可能就是帳對不上、報表送錯。

Claude Code 在這件事上最有價值的地方是全局掃描——當你有 20 幾個匯出檔案,每個的寫法都不太一樣,人力逐一檢查非常耗時。讓 Claude Code 一次掃完,列出所有不一致的地方,再逐一修正,效率差非常多。