argument('file'); if (! is_file($path)) { $this->error('文件不存在:' . $path); return self::FAILURE; } $headers = (array) config('order_after_sales_cost.headers', []); $numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', [])); if ($headers === []) { $this->error('缺少表头映射:config/order_after_sales_cost.php'); return self::FAILURE; } $this->info('流式读取文件:' . $path); $sheetPath = $this->firstSheetPath($path); if ($sheetPath === null) { $this->error('不是有效的 xlsx(缺少工作表)。'); return self::FAILURE; } try { $sharedStrings = $this->readSharedStrings($path); } catch (\Throwable $exception) { $this->error('读取共享字符串失败:' . $exception->getMessage()); return self::FAILURE; } $columns = []; $first = true; $chunkSize = max((int) $this->option('chunk'), 100); $dryRun = (bool) $this->option('dry-run'); $buffer = []; $total = 0; $written = 0; $skipped = 0; $shops = []; $minDate = null; $maxDate = null; $importedAt = now()->toDateTimeString(); $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; 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; } continue; } $total++; $payload = []; foreach ($columns as $index => $key) { $value = $cells[$index] ?? null; if (is_string($value)) { $value = trim($value); } if ($value === '' || $value === null) { continue; } if (isset($numberKeys[$key])) { $value = is_numeric($value) ? (float) $value : 0.0; } elseif (self::isDateKey($key)) { $value = self::dateValue($value); } $payload[$key] = $value; } $shop = trim((string) ($payload['shop_name'] ?? '')); if ($shop === '' || empty($payload['product_code'])) { $skipped++; continue; } $buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt); $shops[$shop] = true; $orderTime = $payload['order_time'] ?? null; if (is_string($orderTime) && $orderTime !== '') { $minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate; $maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate; } if (! $dryRun && count($buffer) >= $chunkSize) { $written += $this->flush($buffer); $buffer = []; $this->output->write("\r写入 " . $written . ' 行'); } if ($dryRun && $buffer !== []) { $buffer = []; } } $reader->close(); if (! $dryRun && $buffer !== []) { $written += $this->flush($buffer); } $this->newLine(2); $this->info(sprintf( '完成:读取 %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 { DB::table('ops_after_sales_cost_rows')->upsert( $rows, ['row_key'], [ 'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code', 'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source', 'combine_type', 'payload', 'imported_at', 'updated_at', ], ); return count($rows); } private static function normalizeHeader(string $header): string { $header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header); $header = preg_replace('/\s+/u', ' ', $header) ?? $header; return trim($header); } private static function isDateKey(string $key): bool { return in_array($key, [ 'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time', 'confirmation_time', 'warehouse_entry_time', ], true); } private static function dateValue(mixed $value): ?string { if ($value instanceof \DateTimeInterface) { return $value->format('Y-m-d H:i:s'); } if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) { // Excel 序列日期(1899-12-30 起算),按本地时间格式化。 $timestamp = ((float) $value - 25569) * 86400; return gmdate('Y-m-d H:i:s', (int) $timestamp); } $timestamp = strtotime((string) $value); return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); } }