['平台', '平台名称', '渠道'], 'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'], 'memo' => ['业务备注', '财务备注', '二次备注', '业务备注关键词', '账务备注', '备注'], 'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'], 'invoice' => ['票据口径', '票据', '票据类型', '发票'], 'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'], 'feeMemo' => ['费用备注', '费用说明', '费用备注说明'], 'basis' => ['方向基准', '基准'], 'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'], 'dSub' => ['借方二级'], 'cAcct' => ['贷方科目'], 'cSub' => ['贷方二级'], ]; private const INVOICE_MAP = [ '0' => '不开票', '0.00' => '不开票', '0%' => '不开票', '0.06' => '0.06', '6%' => '0.06', '06' => '0.06', '可开6%专票' => '0.06', '开6%专票' => '0.06', '不开票' => '不开票', '不开票(无票)' => '不开票', '不开票(无票)' => '不开票', '无票' => '不开票', '-' => '-', '—' => '-', '不涉及' => '-', ]; /** * @return array{rows:array>,header_row:int,sheet:string} */ public function parse(UploadedFile $file): array { $extension = mb_strtolower((string) $file->getClientOriginalExtension()); try { $readerType = match ($extension) { 'xlsx' => 'Xlsx', 'xls' => 'Xls', 'csv' => 'Csv', default => throw new \RuntimeException('Unsupported workbook format.'), }; $reader = IOFactory::createReader($readerType); if ($readerType === 'Csv') { $reader->setInputEncoding('UTF-8'); } $spreadsheet = $reader->load($file->getRealPath()); } catch (Throwable) { throw ValidationException::withMessages([ 'import' => '读不出这个文件,请确认是 xlsx / xls / csv,且没有损坏。', ]); } try { $best = null; foreach ($spreadsheet->getWorksheetIterator() as $sheet) { $grid = $sheet->toArray(null, true, false, false); $grid = array_values(array_filter($grid, static fn (array $row): bool => $row !== [])); if ($grid === []) { continue; } $headerRow = $this->locateHeader($grid); $score = $headerRow === null ? -1 : count(array_filter($this->mapHeader($grid[$headerRow]))); if ($score <= 0) { continue; } if ($best === null || $score > $best['score']) { $best = ['score' => $score, 'grid' => $grid, 'header_row' => $headerRow, 'sheet' => $sheet->getTitle()]; } } } finally { $spreadsheet->disconnectWorksheets(); } if ($best === null) { throw ValidationException::withMessages([ 'import' => '没找到表头。至少要有一列「平台」和一列「账单项目」,以及借方、贷方两组科目列。', ]); } $columns = $this->mapHeader($best['grid'][$best['header_row']]); $rows = []; foreach (array_slice($best['grid'], $best['header_row'] + 1) as $gridRow) { $row = []; foreach ($columns as $field => $index) { if ($index === null) { $row[$field] = ''; continue; } $value = $gridRow[$index] ?? ''; $row[$field] = trim((string) (is_scalar($value) ? $value : '')); } if ($row['platform'] === '' && $row['item'] === '') { continue; } $row['invoice'] = $this->normalizeInvoice($row['invoice']); $row = $this->inferBasis($row); $rows[] = $row; } return ['rows' => $rows, 'header_row' => $best['header_row'], 'sheet' => $best['sheet']]; } /** @param array $row */ private function locateHeader(array $grid): ?int { foreach ($grid as $index => $row) { if (count(array_filter($this->mapHeader($row))) >= 3) { return $index; } } return null; } /** * @param array $header * @return array */ private function mapHeader(array $header): array { $cells = []; foreach ($header as $index => $value) { $cells[$index] = $this->normalizeHeader((string) (is_scalar($value) ? $value : '')); } $columns = []; foreach (self::ALIASES as $field => $aliases) { $columns[$field] = null; foreach ($aliases as $alias) { $needle = $this->normalizeHeader($alias); $found = array_search($needle, $cells, true); if ($found !== false) { $columns[$field] = (int) $found; break; } } } // 旧版表头「借/贷(借)」出现两次、没有「贷方科目」:借方组命中的那列起,往后取 4 列。 $dAcct = $columns['dAcct']; if ($dAcct !== null && $columns['dSub'] === null && $columns['cAcct'] === null && $columns['cSub'] === null) { $columns['dSub'] = $dAcct + 1; $columns['cAcct'] = $dAcct + 2; $columns['cSub'] = $dAcct + 3; } elseif ($dAcct === null && $columns['cAcct'] !== null && $columns['dSub'] === null) { $columns['dAcct'] = max($columns['cAcct'] - 2, 0); $columns['dSub'] = max($columns['cAcct'] - 1, 0); $columns['cSub'] = $columns['cAcct'] + 1; } return $columns; } private function normalizeHeader(string $value): string { $value = str_replace(['(', ')', '(', ')', ' '], ['(', ')', '(', ')', ''], $value); return trim($value); } private function normalizeInvoice(string $value): string { $value = trim($value); if ($value === '') { return ''; } return self::INVOICE_MAP[$value] ?? $value; } /** @param array $row @return array */ private function inferBasis(array $row): array { $basis = trim((string) ($row['basis'] ?? '')); if ($basis === '') { $basis = AccountMappingStore::CATEGORY_BASIS[trim((string) $row['category'])] ?? ''; } $row['basis'] = $basis; return $row; } }