> $rows */ private function makeUploadFile(array $rows): UploadedFile { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); foreach ($rows as $index => $row) { $sheet->fromArray($row, null, 'A' . ($index + 1)); } $path = tempnam(sys_get_temp_dir(), 'imp') . '.xlsx'; (new Xlsx($spreadsheet))->save($path); $spreadsheet->disconnectWorksheets(); return new UploadedFile( $path, 'import.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true ); } /** 创建 根分类 + 末级子分类,返回末级分类 */ private function makeLeafCategory(string $rootName = '蔬菜', string $leafName = '叶菜类'): ProductCategoryModel { $root = ProductCategoryModel::create(['name' => $rootName, 'parent_id' => 0, 'sort' => 0, 'status' => 1]); return ProductCategoryModel::create(['name' => $leafName, 'parent_id' => $root->id, 'sort' => 0, 'status' => 1]); } // ==================== 导出 ==================== /** 导出全量商品:列头 + 分类路径 + 供应商名 + 状态中文 */ public function test_export_downloads_all_products(): void { $this->freezeTime(); $leaf = $this->makeLeafCategory(); $supplier = SupplierModel::create(['name' => '张三蔬菜批发']); ProductModel::factory()->create([ 'category_id' => $leaf->id, 'supplier_id' => $supplier->id, 'market' => '新发地', 'name' => '上海青', 'spec' => '10斤/箱', 'unit' => '斤', 'cost_price' => '2.50', 'sort' => 3, 'stock' => 100, 'shelf_life' => 2, 'status' => ProductModel::STATUS_ON, 'remark' => '新鲜直达', ]); ProductModel::factory()->off()->create(['category_id' => $leaf->id, 'name' => '下架商品']); $this->actingAsSysUser(); Excel::fake(); $this->get('/product/goods/export')->assertOk(); Excel::assertDownloaded( '商品列表_' . now()->format('Ymd_His') . '.xlsx', static function (ProductExport $export): bool { $rows = $export->collection()->values(); // 列头 + 2 件商品 if ($rows->count() !== 3) { return false; } if ($rows[0] !== ProductExport::HEADERS) { return false; } $row = $rows->firstWhere(0, '上海青'); if ($row === null) { return false; } // [品名, 分类路径, 供应商, 市场, 规格, 单位, 成本价, 排序, 库存, 保质期, 状态, 备注] if ($row[1] !== '蔬菜/叶菜类' || $row[2] !== '张三蔬菜批发' || $row[3] !== '新发地' || $row[10] !== '上架') { return false; } if ((float) $row[6] !== 2.5 || (int) $row[7] !== 3 || (int) $row[8] !== 100 || (int) $row[9] !== 2) { return false; } if ($row[11] !== '新鲜直达') { return false; } $offRow = $rows->firstWhere(0, '下架商品'); return $offRow !== null && $offRow[10] === '下架' && $offRow[2] === '' && $offRow[3] === ''; } ); } /** 按末级分类过滤导出 */ public function test_export_filters_by_category(): void { $this->freezeTime(); $leafA = $this->makeLeafCategory('蔬菜', '叶菜类'); $leafB = $this->makeLeafCategory('水果', '浆果类'); ProductModel::factory()->create(['category_id' => $leafA->id, 'name' => '菠菜']); ProductModel::factory()->create(['category_id' => $leafB->id, 'name' => '草莓']); $this->actingAsSysUser(); Excel::fake(); $this->get('/product/goods/export?category_id=' . $leafA->id)->assertOk(); Excel::assertDownloaded( '商品列表_' . now()->format('Ymd_His') . '.xlsx', static function (ProductExport $export): bool { $rows = $export->collection()->values(); return $rows->count() === 2 && $rows[1][0] === '菠菜' && $rows[1][1] === '蔬菜/叶菜类'; } ); } /** template=1 输出导入模板:列头 + 示例行 */ public function test_export_template_mode(): void { $this->actingAsSysUser(); Excel::fake(); $this->get('/product/goods/export?template=1')->assertOk(); Excel::assertDownloaded( '商品导入模板.xlsx', static function (ProductExport $export): bool { $rows = $export->collection()->values(); return $rows->count() === 2 && $rows[0] === ProductExport::HEADERS && $rows[1][0] === '西红柿'; } ); } /** 无 product.goods.export 权限点 → 拦截 */ public function test_export_requires_permission(): void { // 先建占位用户:每个测试方法内首个系统用户自增 id=1,超管旁路会绕过 abilities 校验 $this->actingAsSysUser(); $this->actingAsSysUser(['product.goods.query']); $response = $this->get('/product/goods/export'); $this->assertFalse($response->json('success'), '缺少导出权限点应被拦截'); } /** 列表按市场模糊筛选 */ public function test_list_filters_by_market(): void { ProductModel::factory()->create(['name' => '大白菜X', 'market' => '新发地']); ProductModel::factory()->create(['name' => '土豆X', 'market' => '岳各庄']); ProductModel::factory()->create(['name' => '无市场商品X', 'market' => '']); $this->actingAsSysUser(); $response = $this->getJson('/product/goods?market=新发'); $response->assertJsonPath('success', true); $names = collect($response->json('data.data'))->pluck('name'); $this->assertContains('大白菜X', $names); $this->assertNotContains('土豆X', $names); $this->assertNotContains('无市场商品X', $names); } // ==================== 导入 ==================== /** 正常导入:新增商品 + 供应商自动创建 + 默认值(单位斤/状态上架) */ public function test_import_creates_products_and_auto_supplier(): void { $leaf = $this->makeLeafCategory(); $this->actingAsSysUser(); $file = $this->makeUploadFile([ ProductExport::HEADERS, ['上海青', '叶菜类', '新供应商A', '新发地', '10斤/箱', '', 2.5, 3, 100, 2, '下架', '备注信息'], ['西红柿', '蔬菜/茄果类', '', '', '散装', '箱', 1.8, '', '', '', '', ''], ]); // 第二行用到了不存在的「茄果类」路径 → 先补建该分类 ProductCategoryModel::create(['name' => '茄果类', 'parent_id' => $leaf->parent_id, 'sort' => 1, 'status' => 1]); $response = $this->post('/product/goods/import', ['file' => $file]); $response->assertJsonPath('success', true); $response->assertJsonPath('data.created', 2); $productA = ProductModel::where('name', '上海青')->first(); $this->assertNotNull($productA); $this->assertSame($leaf->id, $productA->category_id); $this->assertSame('新发地', $productA->market); $this->assertSame('斤', $productA->unit, '单位留空应默认斤'); $this->assertSame(ProductModel::STATUS_OFF, $productA->status); $this->assertSame('2.50', (string) $productA->cost_price); $supplier = SupplierModel::where('name', '新供应商A')->first(); $this->assertNotNull($supplier, '供应商不存在应自动创建'); $this->assertSame($supplier->id, $productA->supplier_id); $productB = ProductModel::where('name', '西红柿')->first(); $this->assertNotNull($productB); $this->assertSame('', $productB->market, '市场留空应为空字符串'); $this->assertSame(0, $productB->supplier_id, '供应商留空应为 0(不创建)'); $this->assertSame(ProductModel::STATUS_ON, $productB->status, '状态留空应默认上架'); $this->assertSame(0, $productB->sort); } /** 供应商名称已存在 → 直接匹配,不重复创建 */ public function test_import_matches_existing_supplier(): void { $leaf = $this->makeLeafCategory(); $existing = SupplierModel::create(['name' => '老供应商']); $this->actingAsSysUser(); $file = $this->makeUploadFile([ ProductExport::HEADERS, ['黄瓜', '叶菜类', '老供应商', '', '散装', '斤', 1.2, 0, 0, 0, '上架', ''], ]); $this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true); $this->assertSame(1, SupplierModel::where('name', '老供应商')->count(), '已存在供应商不应重复创建'); $this->assertSame($existing->id, ProductModel::where('name', '黄瓜')->value('supplier_id')); } /** 错误行(缺品名/分类不存在/成本价非数字)→ 全部不导入,供应商也不创建 */ public function test_import_errors_abort_all_rows(): void { $this->makeLeafCategory(); $this->actingAsSysUser(); $file = $this->makeUploadFile([ ProductExport::HEADERS, ['正常商品', '叶菜类', '供应商X', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ['', '叶菜类', '', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ['坏商品A', '不存在的分类', '', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ['坏商品B', '叶菜类', '', '', '散装', '斤', 'abc', 0, 0, 0, '上架', ''], ]); $response = $this->post('/product/goods/import', ['file' => $file]); $response->assertJsonPath('success', false); $errors = $response->json('data.errors'); $this->assertCount(3, $errors); $this->assertSame(3, $errors[0]['row']); $this->assertStringContainsString('品名', $errors[0]['message']); $this->assertSame(4, $errors[1]['row']); $this->assertStringContainsString('分类', $errors[1]['message']); $this->assertSame(5, $errors[2]['row']); $this->assertStringContainsString('成本价', $errors[2]['message']); $this->assertSame(0, ProductModel::count(), '有错误行时整表不应写入'); $this->assertSame(0, SupplierModel::where('name', '供应商X')->count(), '有错误行时供应商不应创建'); } /** 分类解析:路径消歧命中;单名称歧义/非末级 → 报错 */ public function test_import_category_path_resolution(): void { $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $fruitRoot = ProductCategoryModel::create(['name' => '水果', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); $vegLeaf = ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]); ProductCategoryModel::create(['name' => '叶菜', 'parent_id' => $fruitRoot->id, 'sort' => 0, 'status' => 1]); $this->actingAsSysUser(); // 路径写法命中蔬菜/叶菜 $file = $this->makeUploadFile([ ProductExport::HEADERS, ['菠菜', '蔬菜/叶菜', '', '岳各庄', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ]); $this->post('/product/goods/import', ['file' => $file])->assertJsonPath('success', true); $this->assertSame($vegLeaf->id, ProductModel::where('name', '菠菜')->value('category_id')); $this->assertSame('岳各庄', ProductModel::where('name', '菠菜')->value('market')); // 单名称歧义 + 非末级分类 → 报错零写入 $file = $this->makeUploadFile([ ProductExport::HEADERS, ['歧义商品', '叶菜', '', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ['非末级商品', '蔬菜', '', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ]); $response = $this->post('/product/goods/import', ['file' => $file]); $response->assertJsonPath('success', false); $this->assertCount(2, $response->json('data.errors')); $this->assertSame(1, ProductModel::count(), '本次应零写入(仅剩上一条菠菜)'); } /** 列头与模板不一致 → 拒绝导入 */ public function test_import_rejects_invalid_header(): void { $this->actingAsSysUser(); $file = $this->makeUploadFile([ ['名称', '类别', '供应商'], ['上海青', '叶菜类', ''], ]); $response = $this->post('/product/goods/import', ['file' => $file]); $response->assertJsonPath('success', false); $this->assertSame(1, $response->json('data.errors.0.row')); $this->assertSame(0, ProductModel::count()); } /** 无 product.goods.import 权限点 → 拦截 */ public function test_import_requires_permission(): void { $this->actingAsSysUser(); $this->actingAsSysUser(['product.goods.query']); $file = $this->makeUploadFile([ ProductExport::HEADERS, ['上海青', '叶菜类', '', '', '散装', '斤', 1.0, 0, 0, 0, '上架', ''], ]); $response = $this->post('/product/goods/import', ['file' => $file]); $this->assertFalse($response->json('success'), '缺少导入权限点应被拦截'); $this->assertSame(0, ProductModel::count()); } }