CaiWuXiTong/modules/EcommerceFinance/Services/PayablesWorkbookStreamReader.php
renshen bcf8ea8fb7 应付款终版导入:流式读取改用 zip:// 管道,解析内存 274MB→76MB (#362)
sharedStrings 与工作表 XML 改为 XMLReader 经 zip:// 流式解析;200k 行基准读取从 6.2s/274MB 降到 2.3s/76MB。
2026-10-17 00:05:05 +08:00

547 lines
18 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 Modules\EcommerceFinance\Services;
use Generator;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use SplFixedArray;
use XMLReader;
use ZipArchive;
/**
* 逐行读取导入工作簿,内存有界(20 万行级文件也能跑):
* - xlsx:ZipArchive + XMLReader 流式解析,共享字符串用「拼接串 + 偏移索引」紧凑存放;
* - csv:逐行 fgetcsv(自动去 BOM、非 UTF-8 按 GBK 转换);
* - xls:退回 PhpSpreadsheet(老格式少见)。
* 日期格式的单元格按 Y-m-d(含时分秒时为 Y-m-d H:i:s)输出,其余保持原值。
*/
class PayablesWorkbookStreamReader
{
/** @var array<int,string>|null 工作表内部路径(xlsx) */
private ?array $sheetPaths = null;
/** @var list<int>|null 日期样式的 style 索引 */
private ?array $dateStyles = null;
/** @var string|null 共享字符串拼接串 */
private ?string $sharedStrings = null;
/** @var SplFixedArray<int>|null 共享字符串偏移(第 i 个字符串的起始位置) */
private ?SplFixedArray $sharedStringOffsets = null;
public function __construct(
private readonly string $path,
private readonly string $extension,
) {
}
/** 新建读取器(按扩展名)。 */
public static function forFile(string $path, string $extension): self
{
return new self($path, strtolower($extension));
}
/** @return list<string> 工作表名称(xls 走 PhpSpreadsheet,仅取名称) */
public function sheetNames(): array
{
if ($this->extension === 'xlsx') {
return array_map(
static fn (array $sheet): string => $sheet['name'],
$this->xlsxSheets(),
);
}
if ($this->extension === 'csv') {
return ['Sheet1'];
}
$spreadsheet = IOFactory::createReader('Xls')->load($this->path);
try {
return $spreadsheet->getSheetNames();
} finally {
$spreadsheet->disconnectWorksheets();
}
}
/**
* 逐行读取指定工作表。
*
* @param int|null $skipUntilRow 跳过该行号之前的行(含),用于分片续读
* @param int|null $stopAfterRow 读到该行号后停止
* @return Generator<int,array<int,mixed>> 行号(1 基)=> 列序号(0 基)=> 值
*/
public function rows(int $sheetIndex = 0, ?int $skipUntilRow = null, ?int $stopAfterRow = null): Generator
{
yield from match ($this->extension) {
'xlsx' => $this->xlsxRows($sheetIndex, $skipUntilRow, $stopAfterRow),
'csv' => $this->csvRows($skipUntilRow, $stopAfterRow),
default => $this->xlsRows($sheetIndex, $skipUntilRow, $stopAfterRow),
};
}
/** @return Generator<int,array<int,mixed>> */
private function xlsxRows(int $sheetIndex, ?int $skipUntilRow, ?int $stopAfterRow): Generator
{
$path = $this->sheetPaths()[$sheetIndex] ?? null;
if ($path === null) {
return;
}
$reader = new XMLReader();
if (! $reader->open('zip://' . $this->path . '#' . $path, null, LIBXML_NONET)) {
throw new RuntimeException('无法打开工作表。');
}
try {
$dictionary = $this->xlsxSharedStrings();
$rowNumber = 0;
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$rowNumber = (int) ($reader->getAttribute('r') ?: $rowNumber + 1);
if ($stopAfterRow !== null && $rowNumber > $stopAfterRow) {
return;
}
if ($skipUntilRow !== null && $rowNumber <= $skipUntilRow) {
$reader->next(); // 跳过整行
continue;
}
yield $rowNumber => $this->readRow($reader, $dictionary);
}
} finally {
$reader->close();
}
}
/**
* 读一行里的单元格(当前节点位于 <row>)。
*
* @param array{0:string,1:SplFixedArray<int>} $dictionary
* @return array<int,mixed>
*/
private function readRow(XMLReader $reader, array $dictionary): array
{
$cells = [];
$depth = $reader->depth;
$empty = $reader->isEmptyElement;
if ($empty) {
return $cells;
}
while ($reader->read()) {
if ($reader->nodeType === XMLReader::END_ELEMENT && $reader->depth === $depth) {
break;
}
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'c' || $reader->isEmptyElement) {
continue;
}
$reference = (string) $reader->getAttribute('r');
$type = (string) $reader->getAttribute('t');
$styleIndex = (string) $reader->getAttribute('s');
$columnIndex = $reference !== '' ? $this->columnIndex($reference) : null;
$value = $this->readCellValue($reader, $type, $dictionary);
$columnIndex ??= count($cells);
if ($value === null || $value === '') {
continue;
}
$cells[$columnIndex] = $this->formatValue($value, $type, $styleIndex);
}
return $cells;
}
/**
* @param array{0:string,1:SplFixedArray<int>} $dictionary
*/
private function readCellValue(XMLReader $reader, string $type, array $dictionary): mixed
{
$value = null;
$depth = $reader->depth;
while ($reader->read()) {
if ($reader->nodeType === XMLReader::END_ELEMENT && $reader->depth === $depth) {
break;
}
if ($reader->nodeType !== XMLReader::ELEMENT) {
continue;
}
if ($reader->localName === 'v') {
$value = $reader->isEmptyElement ? '' : $reader->readString();
continue;
}
if ($reader->localName === 'is') {
$value = $this->readInlineString($reader);
continue;
}
if ($reader->localName === 't' && $type === 'inlineStr') {
$value = $reader->isEmptyElement ? '' : $reader->readString();
}
}
if ($type === 's') {
$index = (int) $value;
[$blob, $offsets] = $dictionary;
if ($index < 0 || $index + 1 >= $offsets->getSize()) {
return null;
}
return substr($blob, $offsets[$index], $offsets[$index + 1] - $offsets[$index]);
}
if ($type === 'str' || $type === 'inlineStr') {
return $value;
}
if ($type === 'b') {
return $value === '1' ? '1' : '0';
}
return $value;
}
/** 富文本 <is> 里可能有多个 <t>。 */
private function readInlineString(XMLReader $reader): string
{
$depth = $reader->depth;
$text = '';
while ($reader->read()) {
if ($reader->nodeType === XMLReader::END_ELEMENT && $reader->depth === $depth) {
break;
}
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 't' && ! $reader->isEmptyElement) {
$text .= $reader->readString();
}
}
return $text;
}
/** 日期样式的数字单元格转成日期字符串。 */
private function formatValue(mixed $value, string $type, string $styleIndex): mixed
{
if (! is_string($value) || $value === '' || $styleIndex === '' || ! is_numeric($value)) {
return $value;
}
if (! in_array((int) $styleIndex, $this->dateStyles(), true)) {
return $value;
}
$serial = (float) $value;
$date = ExcelDate::excelToDateTimeObject($serial);
return fmod($serial, 1.0) === 0.0 ? $date->format('Y-m-d') : $date->format('Y-m-d H:i:s');
}
/** @return Generator<int,array<int,mixed>> */
private function csvRows(?int $skipUntilRow, ?int $stopAfterRow): Generator
{
$handle = fopen($this->path, 'rb');
if ($handle === false) {
return;
}
try {
$delimiter = $this->detectDelimiter((string) fgets($handle));
rewind($handle);
$line = 0;
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
$line++;
if ($stopAfterRow !== null && $line > $stopAfterRow) {
return;
}
if ($skipUntilRow !== null && $line <= $skipUntilRow) {
continue;
}
$cells = [];
foreach ($row as $index => $value) {
$value = $this->normalizeEncoding((string) $value);
if ($index === 0) {
$value = preg_replace('/^\x{FEFF}/u', '', $value) ?? $value;
}
if ($value !== '') {
$cells[$index] = $value;
}
}
yield $line => $cells;
}
} finally {
fclose($handle);
}
}
/** @return Generator<int,array<int,mixed>> */
private function xlsRows(int $sheetIndex, ?int $skipUntilRow, ?int $stopAfterRow): Generator
{
$spreadsheet = IOFactory::createReader('Xls')->load($this->path);
try {
$sheet = $spreadsheet->getSheet($sheetIndex);
foreach ($sheet->getRowIterator() as $row) {
$line = $row->getRowIndex();
if ($stopAfterRow !== null && $line > $stopAfterRow) {
return;
}
if ($skipUntilRow !== null && $line <= $skipUntilRow) {
continue;
}
$cells = [];
foreach ($row->getCellIterator() as $cell) {
$value = $cell->getValue();
if ($value !== null && $value !== '') {
$cells[$this->columnIndex($cell->getColumn())] = $value;
}
}
yield $line => $cells;
}
} finally {
$spreadsheet->disconnectWorksheets();
}
}
/** @return list<int> 日期样式的 style 索引 */
private function dateStyles(): array
{
if ($this->dateStyles !== null) {
return $this->dateStyles;
}
if ($this->extension !== 'xlsx') {
return $this->dateStyles = [];
}
$zip = new ZipArchive();
if ($zip->open($this->path) !== true) {
return $this->dateStyles = [];
}
$styles = [];
try {
$stylesXml = $zip->getFromName('xl/styles.xml');
if ($stylesXml === false) {
return $this->dateStyles = [];
}
$reader = new XMLReader();
$reader->XML($stylesXml, null, LIBXML_NONET);
$customDateFormats = [];
$cellXfs = [];
$inCellXfs = false;
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'numFmt') {
$id = (int) $reader->getAttribute('numFmtId');
$code = (string) $reader->getAttribute('formatCode');
if ($id >= 164 && $this->looksLikeDateFormat($code)) {
$customDateFormats[$id] = true;
}
continue;
}
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'cellXfs') {
$inCellXfs = true;
continue;
}
if ($reader->nodeType === XMLReader::END_ELEMENT && $reader->localName === 'cellXfs') {
$inCellXfs = false;
continue;
}
if ($inCellXfs && $reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'xf') {
$cellXfs[] = (int) $reader->getAttribute('numFmtId');
}
}
foreach ($cellXfs as $index => $numFmtId) {
if (($numFmtId >= 14 && $numFmtId <= 22) || ($numFmtId >= 45 && $numFmtId <= 47) || isset($customDateFormats[$numFmtId])) {
$styles[] = $index;
}
}
$reader->close();
} finally {
$zip->close();
}
return $this->dateStyles = $styles;
}
private function looksLikeDateFormat(string $code): bool
{
$code = preg_replace('/"[^"]*"/', '', $code) ?? $code;
$code = preg_replace('/\[[^\]]*\]/', '', $code) ?? $code;
return (bool) preg_match('/[ymdhs]/i', str_replace(['AM/PM', 'am/pm'], '', $code));
}
/** @return list<array{name:string,path:string}> */
private function xlsxSheets(): array
{
if ($this->sheetPaths !== null) {
return $this->sheetPaths;
}
$zip = new ZipArchive();
if ($zip->open($this->path) !== true) {
throw new RuntimeException('无法打开工作簿。');
}
$sheets = [];
try {
$workbook = $zip->getFromName('xl/workbook.xml');
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
if ($workbook === false || $relationships === false) {
throw new RuntimeException('工作簿结构不完整。');
}
$targets = [];
$rels = new XMLReader();
$rels->XML($relationships, null, LIBXML_NONET);
while ($rels->read()) {
if ($rels->nodeType === XMLReader::ELEMENT && $rels->localName === 'Relationship') {
$targets[(string) $rels->getAttribute('Id')] = ltrim((string) $rels->getAttribute('Target'), '/');
}
}
$rels->close();
$book = new XMLReader();
$book->XML($workbook, null, LIBXML_NONET);
while ($book->read()) {
if ($book->nodeType !== XMLReader::ELEMENT || $book->localName !== 'sheet') {
continue;
}
$relId = (string) ($book->getAttributeNs('id', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships') ?? '');
$target = $targets[$relId] ?? ('worksheets/sheet' . (count($sheets) + 1) . '.xml');
$sheets[] = [
'name' => (string) $book->getAttribute('name'),
'path' => str_starts_with($target, 'xl/') ? $target : 'xl/' . $target,
];
}
$book->close();
} finally {
$zip->close();
}
return $this->sheetPaths = $sheets;
}
/** @return list<string> */
private function sheetPaths(): array
{
return array_column($this->xlsxSheets(), 'path');
}
/** @return array{0:string,1:SplFixedArray<int>} */
private function xlsxSharedStrings(): array
{
if ($this->sharedStrings !== null && $this->sharedStringOffsets !== null) {
return [$this->sharedStrings, $this->sharedStringOffsets];
}
$blob = '';
$reader = new XMLReader();
if (! @$reader->open('zip://' . $this->path . '#xl/sharedStrings.xml', null, LIBXML_NONET)) {
return [$this->sharedStrings = '', $this->sharedStringOffsets = new SplFixedArray(1)];
}
try {
$count = 0;
$offsets = new SplFixedArray(1024);
$offsets[0] = 0;
$depth = 0;
$collect = false;
$text = '';
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
$collect = true;
$text = '';
$depth = $reader->depth;
continue;
}
if (! $collect) {
continue;
}
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 't' && ! $reader->isEmptyElement) {
$text .= $reader->readString();
continue;
}
if ($reader->nodeType === XMLReader::END_ELEMENT && $reader->localName === 'si' && $reader->depth === $depth) {
$blob .= $text;
$count++;
if ($count + 1 > $offsets->getSize()) {
$offsets->setSize($offsets->getSize() * 2);
}
$offsets[$count] = strlen($blob);
$collect = false;
}
}
$offsets->setSize($count + 1);
} finally {
$reader->close();
}
return [$this->sharedStrings = $blob, $this->sharedStringOffsets = $offsets];
}
private function detectDelimiter(string $line): string
{
$counts = [
',' => substr_count($line, ','),
';' => substr_count($line, ';'),
"\t" => substr_count($line, "\t"),
];
arsort($counts);
$delimiter = (string) array_key_first($counts);
return $counts[$delimiter] > 0 ? $delimiter : ',';
}
private function normalizeEncoding(string $value): string
{
if ($value === '' || mb_check_encoding($value, 'UTF-8')) {
return $value;
}
return mb_convert_encoding($value, 'UTF-8', 'GBK');
}
private function columnIndex(string $reference): int
{
preg_match('/^([A-Z]+)/i', $reference, $matches);
$letters = strtoupper($matches[1] ?? 'A');
$index = 0;
foreach (str_split($letters) as $letter) {
$index = $index * 26 + (ord($letter) - 64);
}
return $index - 1;
}
}