'source', '成品配件' => 'part', '成品运费' => 'part', '交易单号' => 'transaction_no', '快递单号' => 'express_no', '条形码' => 'barcode', '店铺名称' => 'shop_name', '提供成本运费' => 'freight_amount', ]; private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费']; /** 可选列:文件里有就导入,没有就留空(老文件兼容)。 */ private const OPTIONAL_HEADERS = ['快递单号', '条形码', '店铺名称']; /** 表头别名:源文件的列名写法不统一(成品配件 / 成品运费;店铺名称 / 店铺)。 */ private const HEADER_ALIASES = [ '成品配件' => ['成品配件', '成品运费'], '店铺名称' => ['店铺名称', '店铺'], ]; private const CHUNK = 2000; public function __construct( private readonly DatabaseManager $databases, private readonly ?XlsxSheetReader $reader = null, ) {} /** * @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array,seconds:float} */ public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array { if (! is_file($path)) { throw new InvalidArgumentException('找不到要导入的文件:' . $path); } $target = $this->databases->connection(); $startedAt = microtime(true); $now = now(); if ($replace) { $target->table('courier_freight_rows')->where('company_id', $companyId)->delete(); } $inserted = 0; $skipped = 0; $amount = 0.0; $sources = []; $buffer = []; $flush = function () use (&$buffer, &$inserted, $target): void { foreach (array_chunk($buffer, self::CHUNK) as $chunk) { $target->table('courier_freight_rows')->insert($chunk); $inserted += count($chunk); } $buffer = []; }; foreach ($this->lines($path) as $line) { $sourceValue = $this->text($line['来源'] ?? null, 64); $transactionNo = $this->text($line['交易单号'] ?? null, 191); if ($sourceValue === null && $transactionNo === null) { $skipped++; continue; } $value = $this->number($line['提供成本运费'] ?? null); $sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1; $amount += $value; $buffer[] = [ 'company_id' => $companyId, 'source' => $sourceValue ?? '', 'part' => $this->text($line['成品配件'] ?? null, 191), 'transaction_no' => $transactionNo ?? '', 'express_no' => $this->text($line['快递单号'] ?? null, 64), 'barcode' => $this->text($line['条形码'] ?? null, 191), 'shop_name' => $this->text($line['店铺名称'] ?? null, 191), 'freight_amount' => number_format($value, 4, '.', ''), 'created_at' => $now, 'updated_at' => $now, ]; if (count($buffer) >= self::CHUNK * 4) { $flush(); } } $flush(); arsort($sources); return [ 'file' => basename($path), 'rows' => $inserted + $skipped, 'inserted' => $inserted, 'skipped' => $skipped, 'amount' => number_format($amount, 2, '.', ''), 'sources' => $sources, 'seconds' => round(microtime(true) - $startedAt, 1), ]; } /** @return \Generator> */ private function lines(string $path): \Generator { if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('无法读取文件:' . $path); } try { $header = null; while (($cells = fgetcsv($handle)) !== false) { if ($header === null) { $cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? '')); $header = array_map(static fn ($value): string => trim((string) $value), $cells); $this->assertHeader($header); continue; } $line = []; foreach ($header as $index => $name) { $line[$this->canonicalName($name)] = (string) ($cells[$index] ?? ''); } yield $line; } } finally { fclose($handle); } return; } if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') { throw new InvalidArgumentException('只支持 xlsx、csv。'); } $columns = array_map( static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name, self::HEADERS, ); $optionalColumns = array_map( static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name, self::OPTIONAL_HEADERS, ); yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns, $optionalColumns); } /** 把表头别名折回规范列名。 */ private function canonicalName(string $header): string { foreach (self::HEADER_ALIASES as $canonical => $names) { if (in_array($header, $names, true)) { return $canonical; } } return $header; } /** @param list $header */ private function assertHeader(array $header): void { foreach (self::HEADERS as $required) { $names = self::HEADER_ALIASES[$required] ?? [$required]; if (array_intersect($names, $header) === []) { throw new InvalidArgumentException('文件缺少列:' . $required); } } } private function text(mixed $value, int $limit): ?string { if ($value === null) { return null; } $text = trim((string) (is_scalar($value) ? $value : '')); return $text === '' ? null : mb_substr($text, 0, $limit); } private function number(mixed $value): float { if ($value === null || $value === '') { return 0.0; } if (is_numeric($value)) { return (float) $value; } $text = str_replace([',', ' ', '¥', '¥'], '', (string) $value); return is_numeric($text) ? (float) $text : 0.0; } }