Files
fdesk/app/common/utils/excel/ExcelReader.php
T
mzl92 80fbf6c35a Merge branch 'develop'
# Conflicts:
#	config/route.php
2025-11-11 16:46:22 +08:00

293 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}