'蔬菜', '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, 'market' => '新发地', ]); $meat = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '20.00', 'spec' => '20斤/箱', 'supplier_id' => $supplierB->id, 'market' => '岳各庄', ]); $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', 'spec' => '10斤/箱']); 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; } // 列头含市场/单价与门店列(市场列在供应商后,门店列从索引 12 起) $header = $rows[3]; if ($header[4] !== '市场' || $header[8] !== '单价' || $header[12] !== $storeA->name || $header[13] !== $storeB->name) { return false; } // 蔬菜行:市场 新发地、数量 2+3=5、金额 25、单价 5÷10=0.50、门店列 2/3 $vegRow = $rows->firstWhere(2, $veg->name); if ($vegRow === null || $vegRow[4] !== '新发地' || (float) $vegRow[9] !== 5.0 || (float) $vegRow[11] !== 25.0 || (float) $vegRow[8] !== 0.5 || (float) $vegRow[12] !== 2.0 || (float) $vegRow[13] !== 3.0) { return false; } // 无订货商品行:数量 0、单价按成本价÷包规显示(7÷10=0.70)、门店列 0 $extraRow = $rows->firstWhere(2, $extra->name); if ($extraRow === null || (float) $extraRow[9] !== 0.0 || (float) $extraRow[8] !== 0.7 || (float) $extraRow[12] !== 0.0) { return false; } // 合计行:总数量 6、总金额 45、门店列合计 3/3 $total = $rows->last(); if ($total[2] !== '合计') { return false; } return (float) $total[9] === 6.0 && (float) $total[11] === 45.0 && (float) $total[12] === 3.0 && (float) $total[13] === 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[6] === 3 && (float) $total[8] === 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', '该门店在此采购单中无采购商品'); } /** 供应商采购明细导出:按「供应商×市场」拆分工作表,模板列序=品名/汇总/市场/各门店明细 */ public function test_export_suppliers_multi_sheet(): void { [$purchase, $veg, $meat, $storeA, $storeB, $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, $storeA, $storeB): 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 件;门店列=有该供应商明细的两家门店 $rowsA = $sheets[0]->collection()->values(); if ($rowsA[2] !== ['品名', '汇总', '市场', $storeA->name, $storeB->name]) { return false; } $vegRow = $rowsA->firstWhere(0, $veg->name); if ($vegRow === null || (int) $vegRow[1] !== 5 || $vegRow[2] !== '新发地' || (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) { return false; } $totalA = $rowsA->last(); if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) { return false; } // 供应商乙·岳各庄:肉 1 件;门店列仅门店A $rowsB = $sheets[1]->collection()->values(); if ($rowsB[2] !== ['品名', '汇总', '市场', $storeA->name]) { return false; } $meatRow = $rowsB->firstWhere(0, $meat->name); return $meatRow !== null && (int) $meatRow[1] === 1 && $meatRow[2] === '岳各庄' && (int) $meatRow[3] === 1; } ); } /** 供应商采购明细导出:同一供应商多个市场拆分为多个工作表(空市场归入「未设置」) */ public function test_export_suppliers_split_by_market(): void { $supplier = SupplierModel::factory()->create(); $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); $vegA = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00', 'supplier_id' => $supplier->id, 'market' => '新发地', ]); $vegB = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '6.00', 'supplier_id' => $supplier->id, 'market' => '岳各庄', ]); $vegC = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => '7.00', 'supplier_id' => $supplier->id, 'market' => '', ]); $this->actingAsMiniStore($store); $this->postJson('/mini/order', ['items' => [ ['product_id' => $vegA->id, 'quantity' => 1], ['product_id' => $vegB->id, 'quantity' => 1], ['product_id' => $vegC->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); $purchase = PurchaseOrderModel::first(); Excel::fake(); $this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplier->id}")->assertOk(); Excel::assertDownloaded( $purchase->purchase_no . '_供应商采购明细_' . $supplier->name . '.xlsx', static function (PurchaseSupplierExport $export) use ($supplier, $vegA, $vegB, $vegC): bool { $sheets = $export->sheets(); $titles = array_map(static fn ($sheet) => $sheet->title(), $sheets); sort($titles); if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) { return false; } // 每个工作表仅含本市场商品 foreach ($sheets as $sheet) { $rows = $sheet->collection()->values(); $names = $rows->slice(3, -1)->map(static fn ($row) => $row[0])->values()->all(); $expected = match (true) { str_ends_with($sheet->title(), '新发地') => [$vegA->name], str_ends_with($sheet->title(), '岳各庄') => [$vegB->name], default => [$vegC->name], }; if ($names !== $expected) { return false; } } return true; } ); } /** 供应商采购明细导出:单供应商导出(工作表名=供应商·市场) + 无明细供应商拒绝 */ 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', '该供应商在此采购单中无采购明细'); } }