订单售后费用导入:改为流式解析 xlsx,避免整本加载拖垮服务器 #422

Merged
renshen merged 1 commits from fix/renshen-import-streaming into main 2026-10-21 13:47:02 +08:00

View File

@ -5,8 +5,8 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use XMLReader;
use ZipArchive;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
@ -16,7 +16,7 @@ use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=300 : 每批写入行数}';
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=500 : 每批写入行数} {--dry-run : 只解析不入库}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
@ -39,25 +39,28 @@ class ImportAfterSalesCostRows extends Command
return self::FAILURE;
}
$this->info('读取文件:' . $path);
$this->info('流式读取文件:' . $path);
try {
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$sheet = $reader->load($path)->getActiveSheet();
} catch (\Throwable $exception) {
$this->error('读取失败:' . $exception->getMessage());
$sheetPath = $this->firstSheetPath($path);
if ($sheetPath === null) {
$this->error('不是有效的 xlsx(缺少工作表)。');
return self::FAILURE;
}
$rowIterator = $sheet->getRowIterator();
$rowIterator->rewind();
try {
$sharedStrings = $this->readSharedStrings($path);
} catch (\Throwable $exception) {
$this->error('读取共享字符串失败:' . $exception->getMessage());
return self::FAILURE;
}
$rawHeaders = [];
$first = true;
$columns = [];
$chunkSize = max((int) $this->option('chunk'), 50);
$first = true;
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$buffer = [];
$total = 0;
$written = 0;
@ -67,23 +70,34 @@ class ImportAfterSalesCostRows extends Command
$maxDate = null;
$importedAt = now()->toDateTimeString();
foreach ($rowIterator as $row) {
$cells = [];
foreach ($row->getCellIterator() as $cell) {
$cells[] = $cell->getValue();
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#' . $sheetPath)) {
$this->error('无法打开工作表:' . $sheetPath);
return self::FAILURE;
}
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$cells = $this->readRow($reader, $sharedStrings);
if ($first) {
$first = false;
$rawHeaders = array_map(static fn ($v) => self::normalizeHeader((string) $v), $cells);
foreach ($rawHeaders as $index => $header) {
foreach ($cells as $index => $header) {
$header = self::normalizeHeader((string) $header);
if ($header !== '' && isset($headers[$header])) {
$columns[$index] = $headers[$header];
}
}
if ($columns === []) {
$reader->close();
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
@ -132,31 +146,134 @@ class ImportAfterSalesCostRows extends Command
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
}
if (count($buffer) >= $chunkSize) {
if (! $dryRun && count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
if ($dryRun && $buffer !== []) {
$buffer = [];
}
}
if ($buffer !== []) {
$reader->close();
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s',
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$minDate ?? '-',
$maxDate ?? '-',
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
private function firstSheetPath(string $path): ?string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$sheet = null;
if ($zip->locateName('xl/worksheets/sheet1.xml') !== false) {
$sheet = 'xl/worksheets/sheet1.xml';
} else {
for ($i = 1; $i <= 30; $i++) {
$name = 'xl/worksheets/sheet' . $i . '.xml';
if ($zip->locateName($name) !== false) {
$sheet = $name;
break;
}
}
}
$zip->close();
return $sheet;
}
/** @return array<int,string> */
private function readSharedStrings(string $path): array
{
$strings = [];
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#xl/sharedStrings.xml')) {
return $strings;
}
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
$xml = $reader->readOuterXml();
$strings[] = html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
$reader->close();
return $strings;
}
/** @param array<int,string> $shared @return array<int,mixed> */
private function readRow(XMLReader $reader, array $shared): array
{
$cells = [];
$xml = $reader->readOuterXml();
if ($xml === '') {
return $cells;
}
$row = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($row === false) {
return $cells;
}
foreach ($row->c as $cell) {
$index = self::columnIndex((string) $cell['r']);
$type = (string) $cell['t'];
if ($type === 's') {
$value = $shared[(int) $cell->v] ?? '';
} elseif ($type === 'inlineStr') {
$value = (string) $cell->is->t;
} else {
$value = (string) $cell->v;
}
$cells[$index] = $value;
}
return $cells;
}
private static function columnIndex(string $reference): int
{
$letters = preg_replace('/[^A-Z]/', '', strtoupper($reference)) ?? '';
$index = 0;
foreach (str_split($letters) as $letter) {
$index = $index * 26 + (ord($letter) - 64);
}
return $index - 1;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
@ -196,7 +313,10 @@ class ImportAfterSalesCostRows extends Command
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
// Excel 序列日期(1899-12-30 起算),按本地时间格式化。
$timestamp = ((float) $value - 25569) * 86400;
return gmdate('Y-m-d H:i:s', (int) $timestamp);
}
$timestamp = strtotime((string) $value);