校验错误行(Excel 行号,1 起) */ public array $errors = []; /** 成功写入的商品数 */ public int $created = 0; /** @var array 本次自动创建的供应商名称 */ public array $suppliersCreated = []; /** * maatwebsite 入口:读取首个工作表全部行(第 1 行为列头) * * @param Collection> $rows */ public function collection(Collection $rows): void { $header = $rows->first(); if (! $this->isValidHeader($header)) { $this->errors[] = ['row' => 1, 'message' => '列头与导入模板不一致,请下载最新模板后重试']; return; } $dataRows = $rows->slice(1)->values(); if ($dataRows->count() > self::MAX_ROWS) { $this->errors[] = ['row' => 2, 'message' => '单次最多导入 ' . self::MAX_ROWS . ' 行,当前 ' . $dataRows->count() . ' 行,请拆分后导入']; return; } $categories = ProductCategoryModel::all(); $leafIds = $this->leafCategoryIds($categories); $parsed = []; foreach ($dataRows as $index => $row) { $excelRow = $index + 2; // Excel 行号(1 起,含列头行) $cells = array_map(static fn ($cell) => is_string($cell) ? trim($cell) : $cell, $row->toArray()); // 整行空白视为空行跳过 if (implode('', array_map(static fn ($cell) => (string) $cell, $cells)) === '') { continue; } $result = $this->parseRow($cells, $categories, $leafIds); if ($result['errors'] !== []) { foreach ($result['errors'] as $message) { $this->errors[] = ['row' => $excelRow, 'message' => $message]; } } else { $parsed[] = $result['data']; } } if ($this->errors !== []) { return; // 整表校验:有错一行不写 } if ($parsed === []) { $this->errors[] = ['row' => 2, 'message' => '表格中没有可导入的数据行']; return; } $this->writeRows($parsed); } /** * 解析并校验一行数据;全部合法时返回写入字段 * * @param array $cells * @param Collection $categories * @param array $leafIds * @return array{data: array, errors: array} */ private function parseRow(array $cells, Collection $categories, array $leafIds): array { [$name, $categoryName, $supplierName, $market, $spec, $unit, $costPrice, $sort, $stock, $shelfLife, $statusText, $remark] = array_pad(array_slice($cells, 0, 12), 12, null); $errors = []; $name = (string) $name; if ($name === '') { $errors[] = '品名不能为空'; } elseif (mb_strlen($name) > 100) { $errors[] = '品名最长 100 个字符'; } $categoryId = 0; $categoryName = (string) $categoryName; if ($categoryName === '') { $errors[] = '分类不能为空'; } else { $categoryId = $this->resolveCategoryId($categoryName, $categories, $leafIds); if ($categoryId === 0) { $errors[] = '分类「' . $categoryName . '」不存在或不是末级分类(多个同名末级分类时请使用「父分类/子分类」格式)'; } } $supplierName = (string) $supplierName; if (mb_strlen($supplierName) > 100) { $errors[] = '供应商名称最长 100 个字符'; } $market = (string) $market; if (mb_strlen($market) > 50) { $errors[] = '市场最长 50 个字符'; } $spec = (string) $spec; if (mb_strlen($spec) > 100) { $errors[] = '规格/包规最长 100 个字符'; } $unit = (string) $unit === '' ? '斤' : (string) $unit; if (mb_strlen($unit) > 20) { $errors[] = '单位最长 20 个字符'; } $cost = $this->parseDecimal($costPrice, 0, 99999999); if ($cost === null) { $errors[] = '成本价必须为不小于 0 的数字'; } $sort = $this->parseInteger($sort); if ($sort === null) { $errors[] = '排序必须为不小于 0 的整数'; } $stock = $this->parseInteger($stock); if ($stock === null) { $errors[] = '库存必须为不小于 0 的整数'; } $shelfLife = $this->parseInteger($shelfLife); if ($shelfLife === null) { $errors[] = '保质期必须为不小于 0 的整数'; } $status = $this->parseStatus($statusText); if ($status === null) { $errors[] = '状态只能填「上架」或「下架」'; } $remark = (string) $remark; if (mb_strlen($remark) > 255) { $errors[] = '备注最长 255 个字符'; } if ($errors !== []) { return ['data' => [], 'errors' => $errors]; } return [ 'data' => [ 'category_id' => $categoryId, 'supplier_id' => 0, // 写入阶段按 supplier_name 解析(匹配或自动创建) 'supplier_name' => $supplierName, // 写入阶段解析,不入 product 表 'market' => $market, 'name' => $name, 'spec' => $spec, 'unit' => $unit, 'image_ids' => '', 'content' => '', 'sort' => $sort, 'shelf_life' => $shelfLife, 'stock' => $stock, 'status' => $status, 'cost_price' => $cost, 'remark' => $remark, ], 'errors' => [], ]; } /** * 事务写入:自动创建缺失供应商,批量插入商品 * * @param array> $parsed */ private function writeRows(array $parsed): void { DB::transaction(function () use ($parsed) { // 供应商按名称匹配,不存在自动创建(supplier_id=0 且 supplier_name 非空 = 待创建) $supplierIds = SupplierModel::pluck('id', 'name'); foreach (collect($parsed)->pluck('supplier_name')->filter()->unique() as $name) { if (! isset($supplierIds[$name])) { $supplier = SupplierModel::create(['name' => $name]); $supplierIds[$name] = $supplier->id; $this->suppliersCreated[] = $name; } } $now = now(); $payload = array_map(static function (array $row) use ($supplierIds, $now) { if ($row['supplier_name'] !== '') { $row['supplier_id'] = (int) $supplierIds[$row['supplier_name']]; } unset($row['supplier_name']); $row['created_at'] = $now; $row['updated_at'] = $now; return $row; }, $parsed); ProductModel::insert($payload); $this->created = count($payload); }); } /** * 分类名称/路径 → 末级分类ID(0 = 解析失败) * 支持「父分类/子分类」路径逐级匹配;单名称时要求末级分类唯一 * * @param Collection $categories * @param array $leafIds */ private function resolveCategoryId(string $name, Collection $categories, array $leafIds): int { // 需 u 修饰符:多字节分隔符(/)按字节解析会切碎中文 $segments = array_values(array_filter(array_map( static fn ($segment) => trim($segment), preg_split('#[//\\\\]#u', $name) ?: [] ), static fn ($segment) => $segment !== '')); if ($segments === []) { return 0; } if (count($segments) > 1) { // 路径逐级匹配 $parentId = 0; $matched = null; foreach ($segments as $segment) { $matched = $categories->first( static fn (ProductCategoryModel $category) => $category->name === $segment && (int) $category->parent_id === $parentId ); if ($matched === null) { return 0; } $parentId = (int) $matched->id; } return isset($leafIds[(int) $matched->id]) ? (int) $matched->id : 0; } // 单名称:仅在末级分类中唯一时命中 $leafMatches = $categories->filter( static fn (ProductCategoryModel $category) => $category->name === $segments[0] && isset($leafIds[(int) $category->id]) ); if ($leafMatches->count() !== 1) { return 0; } return (int) $leafMatches->first()->id; } /** * 末级分类ID集合(无任何子分类) * * @param Collection $categories * @return array */ private function leafCategoryIds(Collection $categories): array { $parentIds = []; foreach ($categories as $category) { if ((int) $category->parent_id > 0) { $parentIds[(int) $category->parent_id] = true; } } $leafIds = []; foreach ($categories as $category) { if (! isset($parentIds[(int) $category->id])) { $leafIds[(int) $category->id] = true; } } return $leafIds; } /** * 列头校验:与导出/模板列头完全一致(允许尾部多余空单元格) */ private function isValidHeader(?Collection $header): bool { if ($header === null) { return false; } $cells = array_map(static fn ($cell) => trim((string) $cell), $header->toArray()); foreach (ProductExport::HEADERS as $index => $expected) { if (($cells[$index] ?? '') !== $expected) { return false; } } return true; } /** * 解析十进制数字(空串取默认值;越界/非数字返回 null) */ private function parseDecimal(mixed $value, float $min, float $max): ?string { if ($value === null || $value === '') { $value = 0; } if (! is_numeric($value)) { return null; } $number = (float) $value; if ($number < $min || $number > $max) { return null; } return number_format($number, 2, '.', ''); } /** * 解析非负整数(空串取 0;非整数/负数返回 null) */ private function parseInteger(mixed $value): ?int { if ($value === null || $value === '') { return 0; } if (! is_numeric($value)) { return null; } $number = (float) $value; if ($number < 0 || $number !== (float) (int) $number) { return null; } return (int) $number; } /** * 解析状态(空串默认上架;接受 上架/下架/1/0) */ private function parseStatus(mixed $value): ?int { $text = trim((string) $value); return match ($text) { '', '上架', '1' => ProductModel::STATUS_ON, '下架', '0' => ProductModel::STATUS_OFF, default => null, }; } }