[], 'rows'=>[]] * - $headerRow:从 1 开始计数(Excel 行号) * - $startRow:数据起始行(默认 headerRow+1) * - 注意:$filePath 必须是“本地文件路径”;若为 URL,请改用 readSheetFromUrl() */ public static function readSheet(string $filePath, string $sheetName, int $headerRow = 1, ?int $startRow = null): array { self::assertRequirements(); if (!is_file($filePath)) { throw new \RuntimeException("File not found: {$filePath}"); } $reader = IOFactory::createReaderForFile($filePath); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($filePath); $sheet = $spreadsheet->getSheetByName($sheetName); if (!$sheet) { $names = implode(' / ', $spreadsheet->getSheetNames()); $spreadsheet->disconnectWorksheets(); throw new \RuntimeException("Sheet '{$sheetName}' not found. Available: {$names}"); } $highestRow = $sheet->getHighestDataRow(); $highestCol = $sheet->getHighestDataColumn(); $highestColIndex = Coordinate::columnIndexFromString($highestCol); // 读取表头(去除可能的 UTF-8 BOM) $headers = []; for ($col = 1; $col <= $highestColIndex; $col++) { $coord = Coordinate::stringFromColumnIndex($col) . $headerRow; $cell = $sheet->getCell($coord); $raw = $cell ? $cell->getCalculatedValue() : null; $text = is_null($raw) ? '' : trim((string)$raw); // 去掉 BOM if ($col === 1) { $text = preg_replace('/^\xEF\xBB\xBF/u', '', $text); } $headers[$col] = $text === '' ? "COL_{$col}" : $text; } // 读取数据 $rows = []; $firstDataRow = $startRow ?? ($headerRow + 1); for ($row = $firstDataRow; $row <= $highestRow; $row++) { $record = []; $isEmpty = true; for ($col = 1; $col <= $highestColIndex; $col++) { $coord = Coordinate::stringFromColumnIndex($col) . $row; $cell = $sheet->getCell($coord); $value = $cell ? $cell->getCalculatedValue() : null; // 日期单元格转换(使用 PhpSpreadsheet 的日期检测) if ($cell && \PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell) && is_numeric($value)) { $dt = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); $value = $dt ? $dt->format('Y-m-d') : $value; } if (is_string($value)) { $value = trim($value); } if ($value !== null && $value !== '') { $isEmpty = false; } $record[$headers[$col]] = $value; } if (!$isEmpty) { $rows[] = $record; } } $spreadsheet->disconnectWorksheets(); return ['headers' => array_values($headers), 'rows' => $rows]; } /** * 从 URL 读取指定 sheet(内部先下载到临时文件) */ public static function readSheetFromUrl(string $url, string $sheetName, int $headerRow = 1, ?int $startRow = null): array { $tmp = self::downloadToTemp($url); try { return self::readSheet($tmp, $sheetName, $headerRow, $startRow); } finally { @unlink($tmp); } } /** * 按映射表将“源表头”=>“目标字段名”(snake_case) * $map 形如:['车架号' => 'vin', '实际采购价' => 'actual_purchase_price', ...] */ public static function remap(array $row, array $map): array { $out = []; foreach ($map as $from => $to) { $out[$to] = $row[$from] ?? null; } return $out; } /** 将一批行做 remap */ public static function remapRows(array $rows, array $map): array { $out = []; foreach ($rows as $row) { $out[] = self::remap($row, $map); } return $out; } /** * 将“金额字符串”解析成 float(适配多币种/多地区写法) * - 支持:¥ ¥ $ € £ …、AED/Dirham:dh / dhs / aed、阿语 د.إ * - 支持:中文/全角标点、阿拉伯小数/千分位、细空格、撇号千分位 * - 支持:括号负数 (xxx) 与显式负号 * - 返回 null 表示无法解析 */ public static function toDecimal($v): ?float { if ($v === null || $v === '') return null; if (is_numeric($v)) return (float)$v; $s = trim((string)$v); // 1) 统一常见 unicode 标点(全角/阿拉伯)与空格 $s = strtr($s, [ ',' => ',', '.' => '.', '٫' => '.', '٬' => ',', "\xC2\xA0" => ' ', // NBSP "\xE2\x80\xAF" => ' ', // NNBSP(窄不换行空格) ]); // 2) 括号负数,如 (dh1,234.56) $negative = false; if (preg_match('/^\s*\((.*)\)\s*$/u', $s, $m)) { $negative = true; $s = $m[1]; } // 3) 去币种/符号:AED/Dirham(dh/dhs/aed/د.إ)& 其它常见符号/字母 $s = preg_replace('/\b(dhs?|aed)\b/i', '', $s); // dh / dhs / aed $s = str_replace(['د.إ', 'د', 'إ'], '', $s); // 常见阿语形式简化处理 $s = preg_replace('/[A-Za-z¥¥$€£₽₺₩₹﷼]+/u', '', $s); // 其它字母/货币符号 // 4) 只保留 数字/点/逗号/减号/空格/撇号,然后去掉空格与撇号(千分位) $s = preg_replace('/[^\d\.,\-\s\']+/u', '', $s); $s = str_replace([' ', "'"], '', $s); // 5) 统一小数分隔符(判断最后出现的分隔符) $lastComma = strrpos($s, ','); $lastDot = strrpos($s, '.'); if ($lastComma !== false && $lastDot !== false) { // 同时存在,最后出现者视为小数点 if ($lastComma > $lastDot) { // 欧式 1.234,56 $s = str_replace('.', '', $s); // 去千分点 $s = substr_replace($s, '.', $lastComma, 1); // 最后一个逗号改为小数点 $s = str_replace(',', '', $s); // 其余逗号清空 } else { // 美式 1,234.56 $s = str_replace(',', '', $s); } } elseif ($lastComma !== false) { // 只有逗号 $digitsAfter = strlen($s) - $lastComma - 1; if (substr_count($s, ',') > 1) { // 多个逗号基本都是千分位 $s = str_replace(',', '', $s); } else { // 一个逗号:若逗号后 1~2 位,视为小数点,否则千分位 $s = ($digitsAfter >= 1 && $digitsAfter <= 2) ? str_replace(',', '.', $s) : str_replace(',', '', $s); } } elseif ($lastDot !== false && substr_count($s, '.') > 1) { // 多个点:保留最后一个为小数点,其他去掉(当作千分位) $digitsAfter = strlen($s) - $lastDot - 1; if ($digitsAfter >= 1 && $digitsAfter <= 2) { $s = preg_replace('/\.(?=.*\.)/', '', $s); // 移除除最后一个外的点 } else { $s = str_replace('.', '', $s); } } // 6) 处理显式负号 if (strpos($s, '-') !== false) { $negative = true; $s = str_replace('-', '', $s); } // 7) 最终校验并转 float if ($s === '' || !preg_match('/^\d+(\.\d+)?$/', $s)) { return null; // 无法解析 } $num = (float)$s; return $negative ? -$num : $num; } /** 规范化布尔:'是/否'、'Y/N'、'1/0'、'true/false' */ public static function toBool($v): ?int { if ($v === null || $v === '') return null; $s = mb_strtolower(trim((string)$v)); if (in_array($s, ['是','y','yes','true','1','√','✅'], true)) return 1; if (in_array($s, ['否','n','no','false','0','×','❌'], true)) return 0; return null; } /** 规范化日期(readSheet 已对 Excel 日期做过处理,这里再兜底) */ public static function toDate($v): ?string { if ($v === null || $v === '') return null; if (is_numeric($v)) { // Excel 序列号兜底 $ts = ExcelDate::excelToTimestamp($v); return $ts ? date('Y-m-d', $ts) : null; } // 简单清洗 $s = trim((string)$v); // 常见格式直接返回(如 2025-11-04 / 2025/11/04) if (preg_match('/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/', $s)) { return str_replace('/', '-', $s); } return $s; // 留给上层决定是否再严格校验 } /** * 把远程 URL 流式下载到临时文件;返回临时路径 */ protected static function downloadToTemp(string $url, ?string $ext = null): string { $ext = $ext ? ('.' . ltrim($ext, '.')) : ''; $tmp = sys_get_temp_dir() . '/excel_' . uniqid() . $ext; $fp = fopen($tmp, 'w'); if (!$fp) { throw new \RuntimeException("Cannot open temp file: {$tmp}"); } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 300, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'Webman-ExcelReader/1.0', ]); $ok = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); fclose($fp); if ($ok === false || $http >= 400) { @unlink($tmp); throw new \RuntimeException("Download failed: HTTP {$http} {$err}"); } return $tmp; } }