'company_name', 'label' => '抬头', 'type' => 'text'], ['field' => 'tag', 'label' => '标签', 'type' => 'text'], ['field' => 'projected_sales', 'label' => '预计9月销量', 'type' => 'amount'], ['field' => 'invoiced_to_buyer', 'label' => '开票-给买家开票', 'type' => 'amount'], ['field' => 'internal_invoiced', 'label' => '开票-内部开票', 'type' => 'amount'], ['field' => 'required_input_cost', 'label' => '所需进项成本', 'type' => 'amount'], ['field' => 'receipt_from_supplier', 'label' => '收票-供应商', 'type' => 'amount'], ['field' => 'receipt_from_suofeiya', 'label' => '收票-索菲亚', 'type' => 'amount'], ['field' => 'receipt_from_gujia', 'label' => '收票-顾家', 'type' => 'amount'], ['field' => 'internal_transfer_1', 'label' => '收票-内部流转(来源)1', 'type' => 'amount'], ['field' => 'internal_transfer_2', 'label' => '收票-内部流转(来源)2', 'type' => 'amount'], ['field' => 'internal_transfer_3', 'label' => '收票-内部流转(来源)3', 'type' => 'amount'], ['field' => 'receipt_total', 'label' => '收票-合计', 'type' => 'amount'], ['field' => 'remaining_gap', 'label' => '剩余缺口', 'type' => 'amount'], ['field' => 'invoicing_ratio', 'label' => '开票比例', 'type' => 'text'], ]; private const HEADER_ROWS = 3; private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $databases) { $this->connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** * @return array{imported:int,skipped:int,source_file:string} */ public function import(string $path): array { $realPath = realpath($path); if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { throw new RuntimeException('内部流转工作簿不存在或不可读:' . $path); } if (! Schema::connection($this->connection->getName())->hasTable(self::TABLE)) { throw new RuntimeException('内部流转数据表尚未创建,请先执行数据库迁移。'); } [$rows, $skipped] = $this->readRows($realPath); if ($rows === []) { throw new RuntimeException('表格中没有可导入的数据行,请检查“' . self::SHEET_TITLE . '”工作表。'); } $now = Carbon::now(); $sourceFile = basename($realPath); $this->connection->transaction(function () use ($rows, $now, $sourceFile): void { // 该报表是一份整体替换的月度数据集,重新导入时先清空上一版。 $this->connection->table(self::TABLE)->delete(); foreach (array_chunk($rows, 200) as $chunk) { $this->connection->table(self::TABLE)->insert(array_map( static fn (array $row): array => $row + [ 'source_file' => $sourceFile, 'imported_at' => $now, 'created_at' => $now, 'updated_at' => $now, ], $chunk, )); } }); return [ 'imported' => count($rows), 'skipped' => $skipped, 'source_file' => $sourceFile, ]; } /** * @return array{0: list>, 1: int} */ private function readRows(string $path): array { $spreadsheet = IOFactory::load($path); $sheet = $this->resolveSheet($spreadsheet->getAllSheets()); $grid = $sheet->toArray(null, true, false, false); $this->expandMergedCells($grid, $sheet); $headerRow = $this->resolveHeaderRow($grid); $columnMap = $this->resolveColumnMap($grid, $headerRow); $missing = array_values(array_diff( array_column(self::COLUMNS, 'field'), array_values($columnMap), )); if ($missing !== []) { $labels = implode('、', array_map( static fn (string $field): string => (string) (collect(self::COLUMNS)->firstWhere('field', $field)['label'] ?? $field), $missing, )); throw new RuntimeException('内部流转表头缺少列:' . $labels . '。'); } $types = array_column(self::COLUMNS, 'type', 'field'); $rows = []; $skipped = 0; $sortOrder = 0; foreach (array_slice($grid, $headerRow + self::HEADER_ROWS) as $line) { $values = []; foreach ($columnMap as $columnIndex => $field) { $values[$field] = $this->normalizeValue($line[$columnIndex] ?? null, $types[$field] ?? 'text'); } $title = trim((string) ($values['company_name'] ?? '')); if ($title === '' || $title === '合计') { if ($title !== '' || collect($values)->filter(static fn ($value): bool => $value !== null && $value !== '')->isNotEmpty()) { $skipped++; } continue; } $values['company_name'] = $title; $values['sort_order'] = ++$sortOrder; $rows[] = $values; } return [$rows, $skipped]; } /** * @param list $sheets */ private function resolveSheet(array $sheets): Worksheet { foreach ($sheets as $sheet) { if ($sheet->getTitle() === self::SHEET_TITLE) { return $sheet; } } throw new RuntimeException('未找到“' . self::SHEET_TITLE . '”工作表。'); } /** * @param list> $grid */ private function expandMergedCells(array &$grid, Worksheet $sheet): void { foreach ($sheet->getMergeCells() as $range) { try { [[$startColumn, $startRow], [$endColumn, $endRow]] = Coordinate::rangeBoundaries($range); } catch (\Throwable) { continue; } $value = $grid[$startRow - 1][$startColumn - 1] ?? null; for ($row = $startRow; $row <= $endRow; $row++) { for ($column = $startColumn; $column <= $endColumn; $column++) { $grid[$row - 1][$column - 1] = $value; } } } } /** * @param list> $grid */ private function resolveHeaderRow(array $grid): int { foreach (array_slice($grid, 0, 10, true) as $index => $line) { if (collect($line)->contains(fn ($value): bool => $this->normalizeHeader($value) === '抬头')) { return (int) $index; } } throw new RuntimeException('未找到以“抬头”开头的工作簿表头。'); } /** * 按三行表头的标签路径匹配目标字段,允许列位置变化。 * * @param list> $grid * @return array 列下标 => 字段名 */ private function resolveColumnMap(array $grid, int $headerRow): array { $map = []; $columnCount = 0; foreach (array_slice($grid, $headerRow, self::HEADER_ROWS) as $line) { $columnCount = max($columnCount, count($line)); } for ($column = 0; $column < $columnCount; $column++) { $path = []; for ($row = $headerRow; $row < $headerRow + self::HEADER_ROWS; $row++) { $value = $this->normalizeHeader($grid[$row][$column] ?? null); if ($value !== '') { $path[] = $value; } } $field = $this->matchField($path); if ($field !== null) { $map[$column] = $field; } } return $map; } /** * @param list $path */ private function matchField(array $path): ?string { if ($path === []) { return null; } $leaf = (string) end($path); return match (true) { $leaf === '抬头' => 'company_name', $leaf === '标签' => 'tag', preg_match('/^预计\d+月销量$/u', $leaf) === 1 => 'projected_sales', $leaf === '给买家开票' => 'invoiced_to_buyer', $leaf === '内部开票' => 'internal_invoiced', $leaf === '所需进项成本' => 'required_input_cost', $leaf === '供应商' => 'receipt_from_supplier', $leaf === '索菲亚' => 'receipt_from_suofeiya', $leaf === '顾家' => 'receipt_from_gujia', $leaf === '合计' => 'receipt_total', $leaf === '剩余缺口' => 'remaining_gap', $leaf === '开票比例' => 'invoicing_ratio', in_array($leaf, ['1', '2', '3'], true) && in_array('内部流转(来源)', $path, true) => 'internal_transfer_' . $leaf, default => null, }; } private function normalizeHeader(mixed $value): string { if ($value === null || is_bool($value) || is_array($value)) { return ''; } $text = trim((string) $value); $text = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], $text); return $text; } private function normalizeValue(mixed $value, string $type): ?string { if ($value === null) { return null; } if ($type === 'text') { if (is_float($value) && floor($value) === $value) { return (string) (int) $value; } $text = trim((string) $value); return $text === '' ? null : $text; } if (is_int($value) || is_float($value)) { return $this->decimal($value); } $text = trim((string) $value); if ($text === '' || in_array($text, ['/', '—', '-', 'N/A'], true)) { return null; } $normalized = str_replace([',', ','], '', $text); return is_numeric($normalized) ? $this->decimal((float) $normalized) : null; } private function decimal(float $value): string { $formatted = rtrim(rtrim(number_format($value, 6, '.', ''), '0'), '.'); return $formatted === '' || $formatted === '-' ? '0' : $formatted; } }