CaiWuXiTong/app/Business/XlsxSheetReader.php
renshen 0bf1ae67af 导入订单售后与商品推广月度明细 (#578)
新增 2026-06 月度订单售后及商品推广导入能力
2026-10-26 11:09:22 +08:00

340 lines
12 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\Business;
use Generator;
use RuntimeException;
use ZipArchive;
/**
* xlsx 工作表流式读取器(按表头取列)。
*
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
*
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
*/
class XlsxSheetReader
{
private const READ_CHUNK = 262144;
/**
* 逐行读取导出文件(跳过表头行)。
*
* @param list<string|list<string>> $columns 需要取出的列(缺列会报错)。给字符串就是精确匹配表头;
* 给数组表示同一列有多个可选表头(取第一个命中的,
* 返回键用数组里的第一个名字)。
* @param list<string|list<string>> $optionalColumns 可选列:文件里有就取,没有就跳过(不报错)。
* @return Generator<int, array<string, string>>
*/
public function rows(string $path, array $columns, array $optionalColumns = []): Generator
{
if (! is_file($path)) {
throw new RuntimeException('订单导出文件不存在:' . $path);
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
}
$stream = $zip->getStream($this->worksheetPath($zip));
if ($stream === false) {
$zip->close();
throw new RuntimeException('订单导出文件里没有可读的工作表。');
}
try {
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns, $optionalColumns);
} finally {
fclose($stream);
$zip->close();
}
}
/**
* @param resource $stream
* @param list<string> $shared
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return Generator<int, array<string, string>>
*/
private function parseSheet($stream, array $shared, array $columns, array $optionalColumns): Generator
{
$state = [
'row' => [],
'column' => -1,
'type' => null,
'value' => '',
'inValue' => false,
'rowNumber' => 0,
'wanted' => [],
'pending' => [],
];
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name, array $attributes) use (&$state): void {
$name = self::localName($name);
switch ($name) {
case 'row':
$state['row'] = [];
$state['column'] = -1;
break;
case 'c':
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
$state['type'] = $attributes['t'] ?? null;
$state['value'] = '';
break;
case 'v':
case 't':
$state['inValue'] = true;
break;
}
},
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
$name = self::localName($name);
switch ($name) {
case 'v':
case 't':
$state['inValue'] = false;
break;
case 'c':
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
break;
case 'row':
$state['rowNumber']++;
if ($state['rowNumber'] === 1) {
$state['wanted'] = self::wantedColumns($state['row'], $columns, $optionalColumns);
break;
}
$values = [];
foreach ($state['wanted'] as $index => $column) {
$values[$column] = $state['row'][$index] ?? '';
}
$state['pending'][] = $values;
break;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
if ($state['inValue']) {
$state['value'] .= $data;
}
});
$first = true;
try {
while (! feof($stream)) {
$chunk = fread($stream, self::READ_CHUNK);
if ($chunk === false || $chunk === '') {
break;
}
if ($first) {
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
$first = false;
}
if (xml_parse($parser, $chunk, false) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
if (xml_parse($parser, '', true) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
} finally {
xml_parser_free($parser);
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
/**
* 表头行 -> [列下标 => 列名],并校验需要的列都在;可选列缺了不算错。
*
* @param array<int, string> $header
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return array<int, string>
*/
private static function wantedColumns(array $header, array $columns, array $optionalColumns = []): array
{
$byName = [];
foreach ($header as $index => $name) {
$name = trim($name);
if ($name !== '' && ! isset($byName[$name])) {
$byName[$name] = $index;
}
}
$wanted = [];
foreach ($columns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched === null) {
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
}
$wanted[$byName[$matched]] = $names[0];
}
foreach ($optionalColumns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched !== null && ! isset($wanted[$byName[$matched]])) {
$wanted[$byName[$matched]] = $names[0];
}
}
return $wanted;
}
/**
* @param list<string> $shared
*/
private static function cellValue(?string $type, string $value, array $shared): string
{
if ($type === 's') {
return $shared[(int) $value] ?? '';
}
return trim($value);
}
/** 单元格引用(如 AV12)-> 0 基列下标。 */
private static function columnIndex(?string $reference, int $fallback): int
{
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
return $fallback;
}
$letters = strtoupper($matches[1]);
$index = 0;
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index - 1;
}
private function worksheetPath(ZipArchive $zip): string
{
$workbook = $zip->getFromName('xl/workbook.xml');
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
if (is_string($workbook) && is_string($relationships)
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
$path = ltrim($target[1], '/');
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
return $name;
}
}
return 'xl/worksheets/sheet1.xml';
}
/**
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
*
* @return list<string>
*/
private function sharedStrings(ZipArchive $zip): array
{
$xml = $zip->getFromName('xl/sharedStrings.xml');
if (! is_string($xml) || $xml === '') {
return [];
}
$out = [];
$index = -1;
$buffer = '';
$inside = false;
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
$name = self::localName($name);
if ($name === 'si') {
$index++;
$buffer = '';
} elseif ($name === 't') {
$inside = true;
}
},
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
$name = self::localName($name);
if ($name === 't') {
$inside = false;
} elseif ($name === 'si') {
$out[] = $buffer;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
if ($inside) {
$buffer .= $data;
}
});
try {
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
} finally {
xml_parser_free($parser);
}
return $out;
}
private static function localName(string $name): string
{
$separator = strrpos($name, ':');
return $separator === false ? $name : substr($name, $separator + 1);
}
}