'bill_date', '订单编号' => 'order_no', '财务售价' => 'finance_selling_price', '财务运费' => 'finance_freight', '财务佣金' => 'finance_commission', '京喜差价' => 'jingxi_price_diff', '扣款' => 'deduction', '财务空包费' => 'finance_empty_package_fee', '财务打款金额' => 'finance_payment_amount', '笔数' => 'pieces', '备注' => 'remark', '正确店铺' => 'store', '负责人' => 'owner', '账户' => 'account', '类型' => 'type', '创建人' => 'creator', '标签' => 'tag', '所属利润中心' => 'profit_center', '负责平台' => 'responsible_platform', 'ID' => 'external_id', ]; private const MONEY_COLUMNS = [ 'finance_selling_price', 'finance_freight', 'finance_commission', 'jingxi_price_diff', 'deduction', 'finance_empty_package_fee', 'finance_payment_amount', ]; private const CHUNK = 1000; /** * @return array{rows:int,inserted:int,skipped:int,selling_price:string,payment_amount:string,date_min:?string,date_max:?string} */ public function import(string $path, int $companyId = 1, bool $replace = false, ?int $userId = null, string $source = 'planting-detail-import'): array { if (! is_file($path)) { throw new InvalidArgumentException('找不到要导入的文件:' . $path); } $grid = $this->read($path); if ($grid === []) { throw new InvalidArgumentException('文件里没有可读的数据行。'); } $headerIndex = $this->locateHeader($grid); if ($headerIndex === null) { throw new InvalidArgumentException('没找到表头:至少要有「日期」「订单编号」「财务打款金额」。'); } $map = $this->mapHeader($grid[$headerIndex]); if (! isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) { throw new InvalidArgumentException('表头缺少「日期」或「订单编号」或「财务打款金额」。'); } $now = now(); $rows = []; $skipped = 0; $sellingPrice = 0.0; $paymentAmount = 0.0; $dateMin = null; $dateMax = null; $inserted = 0; $connection = DB::connection(); if ($replace) { PlantingDetail::query()->where('company_id', $companyId)->forceDelete(); } $flush = function () use (&$rows, &$inserted, $connection): void { if ($rows !== []) { foreach (array_chunk($rows, self::CHUNK) as $chunk) { $connection->table('planting_details')->insert($chunk); $inserted += count($chunk); } $rows = []; } }; foreach (array_slice($grid, $headerIndex + 1) as $gridRow) { if (! $this->hasContent($gridRow)) { continue; } $date = $this->toDate($this->cell($gridRow, $map, 'bill_date')); $orderNo = $this->toText($this->cell($gridRow, $map, 'order_no')); if ($date === null || $orderNo === null) { $skipped++; continue; } $record = [ 'company_id' => $companyId, 'bill_date' => $date, 'order_no' => mb_substr($orderNo, 0, 100), 'pieces' => (int) ($this->toNumber($this->cell($gridRow, $map, 'pieces')) ?? 0), 'remark' => $this->toText($this->cell($gridRow, $map, 'remark')), 'store' => $this->toText($this->cell($gridRow, $map, 'store')), 'owner' => $this->toText($this->cell($gridRow, $map, 'owner')), 'account' => $this->toText($this->cell($gridRow, $map, 'account')), 'type' => $this->toText($this->cell($gridRow, $map, 'type')), 'creator' => $this->toText($this->cell($gridRow, $map, 'creator')), 'tag' => $this->toText($this->cell($gridRow, $map, 'tag')), 'profit_center' => $this->toText($this->cell($gridRow, $map, 'profit_center')), 'responsible_platform' => $this->toText($this->cell($gridRow, $map, 'responsible_platform')), // 源表存在一个单元格放多个 ID(换行分隔)的情况,这里保留到 500 字符。 'external_id' => $this->toText($this->cell($gridRow, $map, 'external_id'), 500), 'created_from' => $source, 'created_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; foreach (self::MONEY_COLUMNS as $column) { $value = $this->toNumber($this->cell($gridRow, $map, $column)) ?? 0.0; $record[$column] = number_format($value, 4, '.', ''); } $sellingPrice += (float) $record['finance_selling_price']; $paymentAmount += (float) $record['finance_payment_amount']; $dateMin = $dateMin === null || $date < $dateMin ? $date : $dateMin; $dateMax = $dateMax === null || $date > $dateMax ? $date : $dateMax; $rows[] = $record; if (count($rows) >= self::CHUNK * 4) { $flush(); } } $flush(); return [ 'rows' => $inserted + $skipped, 'inserted' => $inserted, 'skipped' => $skipped, 'selling_price' => number_format($sellingPrice, 4, '.', ''), 'payment_amount' => number_format($paymentAmount, 4, '.', ''), 'date_min' => $dateMin, 'date_max' => $dateMax, ]; } /** @return array> */ private function read(string $path): array { $extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($extension === 'csv') { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('无法读取文件。'); } $grid = []; $first = true; while (($cells = fgetcsv($handle)) !== false) { if ($first && isset($cells[0])) { $cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $cells[0]); $first = false; } $grid[] = $cells; } fclose($handle); return $grid; } if (! in_array($extension, ['xlsx', 'xls'], true)) { throw new InvalidArgumentException('只支持 xlsx、xls、csv。'); } try { $reader = IOFactory::createReader($extension === 'xlsx' ? 'Xlsx' : 'Xls'); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($path); } catch (Throwable $exception) { throw new InvalidArgumentException('读不出这个表格:' . $exception->getMessage()); } try { return $spreadsheet->getSheet(0)->toArray(null, true, false, false); } finally { $spreadsheet->disconnectWorksheets(); } } /** @param array> $grid */ private function locateHeader(array $grid): ?int { foreach ($grid as $index => $row) { $map = $this->mapHeader($row); if (isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) { return $index; } } return null; } /** @param array $row @return array */ private function mapHeader(array $row): array { $map = []; foreach ($row as $index => $value) { $label = trim((string) (is_scalar($value) ? $value : '')); if ($label === '') { continue; } $column = self::COLUMNS[$label] ?? null; if ($column !== null && ! isset($map[$column])) { $map[$column] = (int) $index; } } return $map; } /** @param array $row @param array $map */ private function cell(array $row, array $map, string $column): mixed { return isset($map[$column]) ? ($row[$map[$column]] ?? null) : null; } private function hasContent(array $row): bool { foreach ($row as $value) { if ($value !== null && trim((string) (is_scalar($value) ? $value : '')) !== '') { return true; } } return false; } private function toDate(mixed $value): ?string { if ($value === null || $value === '') { return null; } if (is_numeric($value) && (float) $value > 20000 && (float) $value < 60000) { try { return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d'); } catch (Throwable) { return null; } } $text = trim((string) $value); $timestamp = strtotime($text); return $timestamp === false ? null : date('Y-m-d', $timestamp); } private function toText(mixed $value, int $limit = 191): ?string { if ($value === null) { return null; } $text = trim((string) (is_scalar($value) ? $value : '')); return $text === '' ? null : mb_substr($text, 0, $limit); } private function toNumber(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (float) $value; } $text = str_replace([',', ' ', '¥', '¥'], '', (string) $value); return is_numeric($text) ? (float) $text : null; } }