>, row_count: int, blank_count: int} */ public function import(UploadedFile $file): array { $fields = collect(config('labor_expense_table.fields', [])) ->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence' && blank($field['formula'] ?? null)); if ($fields->isEmpty()) { throw ValidationException::withMessages([ 'import' => '人工费用表没有可导入的录入列。', ]); } $lookup = []; foreach ($fields as $field) { $lookup[$this->normalizeHeader($field['key'])] = $field['key']; $lookup[$this->normalizeHeader($field['label'])] = $field['key']; } $spreadsheet = null; try { $readerType = match (mb_strtolower($file->getClientOriginalExtension())) { 'xlsx' => 'Xlsx', 'xls' => 'Xls', 'csv' => 'Csv', default => throw new \RuntimeException('Unsupported workbook format.'), }; $reader = IOFactory::createReader($readerType); if (method_exists($reader, 'setReadDataOnly')) { $reader->setReadDataOnly(true); } $spreadsheet = $reader->load($file->getRealPath()); [$sheetRows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup); } catch (Throwable) { throw ValidationException::withMessages([ 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', ]); } finally { if ($spreadsheet) { $spreadsheet->disconnectWorksheets(); } } if ($sheetRows === null || $columns === []) { throw ValidationException::withMessages([ 'import' => '未找到当前人工费用表的可录入列,请使用页面“导出全部字段”生成的文件作为模板。', ]); } $fieldMap = $fields->keyBy('key'); $rows = []; $blankCount = 0; foreach (array_slice($sheetRows, $headerRow + 1, null, true) as $rowIndex => $row) { $record = []; $hasValue = false; foreach ($columns as $columnIndex => $fieldKey) { $field = $fieldMap[$fieldKey]; $value = $this->normalizeCell( $row[$columnIndex] ?? null, $field['kind'] ?? 'text', (int) $rowIndex + 1, $field['label'] ?? $fieldKey, ); $record[$fieldKey] = $value; $hasValue = $hasValue || $value !== ''; } if (! $hasValue) { $blankCount++; continue; } $rows[] = $record; if (count($rows) > self::MAX_DATA_ROWS) { throw ValidationException::withMessages([ 'import' => '单次最多导入 ' . number_format(self::MAX_DATA_ROWS) . ' 行,请拆分文件后重试。', ]); } } if ($rows === []) { throw ValidationException::withMessages([ 'import' => '表格中没有可导入的数据行。', ]); } return [ 'rows' => $rows, 'row_count' => count($rows), 'blank_count' => $blankCount, ]; } /** * @return array{0: array>|null, 1: int|null, 2: array} */ private function bestSheet(mixed $spreadsheet, array $lookup): array { $best = [null, null, []]; $bestScore = 0; foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { $rows = $worksheet->toArray(null, false, false, false); foreach (array_slice($rows, 0, 30, true) as $rowIndex => $row) { $columns = []; foreach ($row as $columnIndex => $heading) { $key = $this->normalizeHeader($heading); if ($key !== '' && isset($lookup[$key])) { $columns[$columnIndex] = $lookup[$key]; } } $score = count(array_unique(array_values($columns))); if ($score > $bestScore) { $best = [$rows, (int) $rowIndex, $columns]; $bestScore = $score; } } } return $best; } private function normalizeCell(mixed $value, string $kind, int $line, string $label): mixed { if ($value === null) { return ''; } $text = trim((string) $value); if ($text === '') { return ''; } if (! in_array($kind, ['number', 'percent'], true)) { return $text; } $isPercentText = $kind === 'percent' && str_ends_with($text, '%'); $number = str_replace([',', ',', '¥', '¥', '%'], '', $text); if (! is_numeric($number)) { throw ValidationException::withMessages([ 'import' => "第 {$line} 行“{$label}”不是有效数字。", ]); } $normalized = (float) $number; return $isPercentText ? $normalized / 100 : $normalized; } private function normalizeHeader(mixed $value): string { return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()*::]+/u', '', trim((string) $value))); } }