CaiWuXiTong/modules/EcommerceFinance/Services/PayablesWorkbookStreamReader.php
苏木 6a331b00ee fix: 保留供应商对账双方全部原始数据行
修复 Excel 表头后首条数据被跳过,以及匹配层误删我方同金额行。

- 双方原始行不再被修改或静默删除
- 页面展示双方原始件数和金额
- 亿之航账单验证为 16 行、395 件、32,282.50 元
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-08 14:20:33 +08:00

551 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 (true) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
if (! $reader->read()) {
return;
}
continue;
}
$rowNumber = (int) ($reader->getAttribute('r') ?: $rowNumber + 1);
if ($stopAfterRow !== null && $rowNumber > $stopAfterRow) {
return;
}
if ($skipUntilRow !== null && $rowNumber <= $skipUntilRow) {
// 停在下一行的开始节点,避免 next() 后再次 read() 越过首条数据。
$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;
}
}