商品导入导出
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exports\ProductExport;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\SupplierModel;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
/**
|
||||
* 商品 Excel 导入/导出:
|
||||
* 导出 = 全量/分类过滤/模板模式/权限拦截;
|
||||
* 导入 = 新增+供应商自动创建/整表校验零写入/分类路径消歧/列头校验/权限拦截
|
||||
*/
|
||||
class ProductImportExportTest extends ProcurementTestCase
|
||||
{
|
||||
/**
|
||||
* 生成真实 xlsx 上传文件(行为行数组,首行为列头)
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $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,
|
||||
'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[9] !== '上架') {
|
||||
return false;
|
||||
}
|
||||
if ((float) $row[5] !== 2.5 || (int) $row[6] !== 3 || (int) $row[7] !== 100 || (int) $row[8] !== 2) {
|
||||
return false;
|
||||
}
|
||||
if ($row[10] !== '新鲜直达') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$offRow = $rows->firstWhere(0, '下架商品');
|
||||
return $offRow !== null && $offRow[9] === '下架' && $offRow[2] === '';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 按末级分类过滤导出 */
|
||||
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_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->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(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'));
|
||||
|
||||
// 单名称歧义 + 非末级分类 → 报错零写入
|
||||
$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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user