Files
fdesk/app/common/utils/excel/ExcelReader.php
T

293 lines
11 KiB
PHP
Raw Normal View History

2025-11-11 16:46:22 +08:00
<?php
namespace app\common\utils\excel;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class ExcelReader
{
/**
* (可选)环境自检:避免因缺少扩展导致读取失败
* - simplexml_load_string 存在性(ext-simplexml)
* - ext-zip
*/
public static function assertRequirements(): void
{
if (!function_exists('simplexml_load_string')) {
throw new \RuntimeException('缺少 PHP 扩展:ext-simplexml。请安装/启用后再试。');
}
if (!extension_loaded('zip')) {
throw new \RuntimeException('缺少 PHP 扩展:ext-zip。请安装/启用后再试。');
}
// 可按需继续检查 ext-mbstring/ext-dom 等
}
/**
* 读取指定 sheet,按某一行作为表头
* 返回:['headers'=>[], '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;
}
}