'蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); $veg = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $vegRoot->id, 'cost_price' => '5.00']); $meat = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'category_id' => $meatRoot->id, 'cost_price' => '20.00']); $this->actingAsMiniStore($store); $this->postJson('/mini/order', ['items' => [ ['product_id' => $veg->id, 'quantity' => 2], ['product_id' => $meat->id, 'quantity' => 1], ]])->assertJsonPath('success', true); // 接单后生成采购单 StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); $this->actingAsSysUser(); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) ->assertJsonPath('success', true); return PurchaseOrderModel::first(); } /** xlsx 导出:正确 Content-Type + 中文文件名 RFC 5987 编码 */ public function test_export_xlsx_with_encoded_chinese_filename(): void { $purchase = $this->buildPurchaseWithItems(); $this->actingAsSysUser(); $response = $this->get("/purchase/order/{$purchase->id}/export"); $response->assertOk(); $this->assertStringContainsString( 'spreadsheetml', (string) $response->headers->get('Content-Type'), 'xlsx 应返回电子表格 Content-Type' ); $disposition = (string) $response->headers->get('Content-Disposition'); // Symfony 输出小写 utf-8''(RFC 5987),大小写不敏感断言 $this->assertMatchesRegularExpression("/filename\*=utf-8''/i", $disposition, '中文文件名应走 RFC 5987 编码'); $this->assertStringContainsString(rawurlencode('采购单'), $disposition); } /** 采购单不存在 → 拒绝 */ public function test_export_missing_purchase_rejected(): void { $this->actingAsSysUser(); $this->get('/purchase/order/99999/export') ->assertJsonPath('success', false); } /** 无 purchase.order.export 权限点 → 拦截 */ public function test_export_requires_export_permission(): void { $purchase = $this->buildPurchaseWithItems(); // 仅持有查询权限的用户 $this->actingAsSysUser(['purchase.order.query']); $response = $this->get("/purchase/order/{$purchase->id}/export"); $this->assertFalse($response->json('success'), '缺少权限点应被拦截'); } /** * 造含双门店/双供应商的采购单: * 门店A 订 蔬菜2件(供应商甲)+ 肉1件(供应商乙);门店B 订 蔬菜3件(供应商甲) * * @return array{0: PurchaseOrderModel, 1: ProductModel, 2: ProductModel, 3: StoreModel, 4: StoreModel, 5: SupplierModel, 6: SupplierModel} */ private function buildPurchaseWithSuppliers(): array { $supplierA = SupplierModel::factory()->create(); $supplierB = SupplierModel::factory()->create(); $level = CustomerLevelModel::factory()->create(); $storeA = StoreModel::factory()->create(['level_id' => $level->id]); $storeB = StoreModel::factory()->create(['level_id' => $level->id]); $veg = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00', 'spec' => '10斤/箱', 'supplier_id' => $supplierA->id, ]); $meat = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '20.00', 'spec' => '20斤/箱', 'supplier_id' => $supplierB->id, ]); $this->actingAsMiniStore($storeA); $this->postJson('/mini/order', ['items' => [ ['product_id' => $veg->id, 'quantity' => 2], ['product_id' => $meat->id, 'quantity' => 1], ]])->assertJsonPath('success', true); $this->actingAsMiniStore($storeB); $this->postJson('/mini/order', ['items' => [ ['product_id' => $veg->id, 'quantity' => 3], ]])->assertJsonPath('success', true); StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); $this->actingAsSysUser(); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) ->assertJsonPath('success', true); return [PurchaseOrderModel::first(), $veg, $meat, $storeA, $storeB, $supplierA, $supplierB]; } /** 商品明细导出:含系统全部商品行(无订货数量 0)+ 行列合计 + 参考零售价列 */ public function test_export_lists_all_catalog_products_with_totals_row(): void { [$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers(); // 无订货的上架商品也应出现在导出中 $extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '7.00']); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/export")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_采购单.xlsx', static function (PurchaseOrderExport $export) use ($veg, $meat, $extra, $storeA, $storeB): bool { $rows = $export->collection()->values(); // 标题1 + 范围1 + 空行1 + 列头1 + 商品3 + 合计1 = 8 行 if ($rows->count() !== 8) { return false; } // 列头含参考零售价与门店列 $header = $rows[3]; if ($header[7] !== '参考零售价' || $header[11] !== $storeA->name || $header[12] !== $storeB->name) { return false; } // 蔬菜行:数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3 $vegRow = $rows->firstWhere(2, $veg->name); if ($vegRow === null || (float) $vegRow[8] !== 5.0 || (float) $vegRow[10] !== 25.0 || (float) $vegRow[7] !== 0.5 || (float) $vegRow[11] !== 2.0 || (float) $vegRow[12] !== 3.0) { return false; } // 无订货商品行:数量 0、参考零售价留空、门店列 0 $extraRow = $rows->firstWhere(2, $extra->name); if ($extraRow === null || (float) $extraRow[8] !== 0.0 || $extraRow[7] !== '' || (float) $extraRow[11] !== 0.0) { return false; } // 合计行:总数量 6、总金额 45、门店列合计 3/3 $total = $rows->last(); if ($total[2] !== '合计') { return false; } return (float) $total[8] === 6.0 && (float) $total[10] === 45.0 && (float) $total[11] === 3.0 && (float) $total[12] === 3.0; } ); } /** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */ public function test_export_supplier_filter(): void { [$purchase, $veg, $meat, , , $supplierA] = $this->buildPurchaseWithSuppliers(); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/export?supplier_id={$supplierA->id}")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_采购单_' . $supplierA->name . '.xlsx', static function (PurchaseOrderExport $export) use ($veg, $meat, $supplierA): bool { $rows = $export->collection()->values(); // 范围行标注供应商 if (! str_contains((string) $rows[1][0], $supplierA->name)) { return false; } // 数据行(去头尾)只含供应商甲的蔬菜,不含供应商乙的肉 $names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all(); return in_array($veg->name, $names, true) && ! in_array($meat->name, $names, true); } ); } /** 门店购买详情导出:多门店合并一个 XLSX(工作表名=门店名),含合计行 */ public function test_export_stores_multi_sheet(): void { [$purchase, , , $storeA, $storeB] = $this->buildPurchaseWithSuppliers(); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportStores")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_门店购买详情.xlsx', static function (PurchaseStoreExport $export) use ($storeA, $storeB): bool { $sheets = $export->sheets(); if (count($sheets) !== 2) { return false; } $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); if ($titles !== [$storeA->name, $storeB->name]) { return false; } // 门店A 工作表:标题/空行/列头/明细2/合计 = 6 行 $rows = $sheets[0]->collection()->values(); if ($rows->count() !== 6) { return false; } if ($rows[2] !== ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额']) { return false; } // 合计:数量 2+1=3,金额 10+20=30 $total = $rows->last(); return $total[1] === '合计' && (int) $total[5] === 3 && (float) $total[7] === 30.0; } ); } /** 门店购买详情导出:单门店导出 + 无明细门店拒绝 */ public function test_export_stores_single_store(): void { [$purchase, , , , $storeB] = $this->buildPurchaseWithSuppliers(); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$storeB->id}")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_门店购买详情_' . $storeB->name . '.xlsx', static function (PurchaseStoreExport $export) use ($storeB): bool { $sheets = $export->sheets(); return count($sheets) === 1 && $sheets[0]->title() === $storeB->name; } ); } /** 门店购买详情导出:门店无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake) */ public function test_export_stores_without_items_rejected(): void { [$purchase] = $this->buildPurchaseWithSuppliers(); $otherStore = StoreModel::factory()->create(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$otherStore->id}") ->assertJsonPath('success', false) ->assertJsonPath('msg', '该门店在此采购单中无采购商品'); } /** 供应商采购明细导出:多供应商合并一个 XLSX(工作表名=供应商名),按供应商聚合 */ public function test_export_suppliers_multi_sheet(): void { [$purchase, $veg, $meat, , , $supplierA, $supplierB] = $this->buildPurchaseWithSuppliers(); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportSuppliers")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_供应商采购明细.xlsx', static function (PurchaseSupplierExport $export) use ($supplierA, $supplierB, $veg, $meat): bool { $sheets = $export->sheets(); if (count($sheets) !== 2) { return false; } $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); if ($titles !== [$supplierA->name, $supplierB->name]) { return false; } // 供应商甲:蔬菜 2+3=5 件、金额 5×5=25 $rowsA = $sheets[0]->collection()->values(); if ($rowsA[2] !== ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) { return false; } $vegRow = $rowsA->firstWhere(1, $veg->name); if ($vegRow === null || (int) $vegRow[5] !== 5 || (float) $vegRow[7] !== 25.0) { return false; } $totalA = $rowsA->last(); if ($totalA[1] !== '合计' || (int) $totalA[5] !== 5 || (float) $totalA[7] !== 25.0) { return false; } // 供应商乙:肉 1 件、金额 20 $rowsB = $sheets[1]->collection()->values(); $meatRow = $rowsB->firstWhere(1, $meat->name); return $meatRow !== null && (int) $meatRow[5] === 1 && (float) $meatRow[7] === 20.0; } ); } /** 供应商采购明细导出:单供应商导出 + 无明细供应商拒绝 */ public function test_export_suppliers_single(): void { [$purchase, , , , , , $supplierB] = $this->buildPurchaseWithSuppliers(); Excel::fake(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplierB->id}")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_供应商采购明细_' . $supplierB->name . '.xlsx', static function (PurchaseSupplierExport $export) use ($supplierB): bool { $sheets = $export->sheets(); return count($sheets) === 1 && $sheets[0]->title() === $supplierB->name; } ); } /** 供应商采购明细导出:供应商无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake) */ public function test_export_suppliers_without_items_rejected(): void { [$purchase] = $this->buildPurchaseWithSuppliers(); $otherSupplier = SupplierModel::factory()->create(); $this->actingAsSysUser(); $this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$otherSupplier->id}") ->assertJsonPath('success', false) ->assertJsonPath('msg', '该供应商在此采购单中无采购明细'); } }