diff --git a/app/Console/Commands/ImportAfterSalesCostRows.php b/app/Console/Commands/ImportAfterSalesCostRows.php index 687f84a7..c6f35a0d 100644 --- a/app/Console/Commands/ImportAfterSalesCostRows.php +++ b/app/Console/Commands/ImportAfterSalesCostRows.php @@ -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 */ + 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 $shared @return array */ + 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> $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);