'ZD' . str_pad((string) $seq, 12, '0', STR_PAD_LEFT), 'purchase_id' => PurchaseOrderModel::factory()->create()->id, 'store_id' => $store->id, 'bill_date' => '2026-08-10', 'product_amount' => '100.00', 'delivery_fee' => '10.00', 'box_num' => 2, 'tray_num' => 1, 'box_price' => '5.00', 'tray_price' => '20.00', 'added_amount' => '30.00', 'after_sale' => '0.00', 'total_amount' => '140.00', 'status' => BillModel::STATUS_UNPAID, ], $attributes)); } /** 造一条账单明细(快照品名/包规/单位取自商品档案) */ private function makeItem(BillModel $bill, ProductModel $product, int $quantity, string $price): StoreOrderItemModel { return StoreOrderItemModel::factory()->create([ 'bill_id' => $bill->id, 'store_id' => $bill->store_id, 'product_id' => $product->id, 'category_id' => $product->category_id, 'product_name' => $product->name, 'product_spec' => $product->spec, 'unit' => $product->unit, 'price' => $price, 'quantity' => $quantity, 'weight' => $quantity * 10, 'amount' => bcmul($price, (string) $quantity, 2), ]); } /** 单独导出:账单内同商品合并(数量累加、单价加权平均),费用行取账单自身字段; * 包规列=包规+单位合并;单价列=每件价÷包规斤数 折算零售价+单价单位(散装无法折算按原价) */ public function test_export_merges_items_within_bill(): void { $root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $store = StoreModel::factory()->create(); $productA = ProductModel::factory()->create(['category_id' => $root->id, 'spec' => '10', 'unit' => '斤/箱']); $productB = ProductModel::factory()->create(['category_id' => $root->id, 'spec' => '散装', 'unit' => '筐']); $bill = $this->makeBill($store, ['after_sale' => '-8.00']); // 同一商品两条明细:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37 $this->makeItem($bill, $productA, 10, '2.00'); $this->makeItem($bill, $productA, 6, '3.00'); $this->makeItem($bill, $productB, 4, '5.00'); $this->actingAsSysUser(); Excel::fake(); $this->get("/recon/bill/{$bill->id}/export")->assertOk(); Excel::assertDownloaded( '门店账单_' . $bill->bill_no . '.xlsx', static function (BillExport $export) use ($bill, $productA, $productB): bool { $rows = $export->collection()->values(); // 标题1 + 范围1 + 空行1 + 列头1 + 明细2 + 合计5 = 11 行 if ($rows->count() !== 11) { return false; } // 标题行含账单号 if (! str_contains((string) $rows[0][0], $bill->bill_no)) { return false; } $details = $rows->slice(4, 2)->values(); $rowA = $details->firstWhere(1, $productA->name); $rowB = $details->firstWhere(1, $productB->name); if ($rowA === null || $rowB === null) { return false; } // 包规列=包规+单位合并;单价列=每件价÷每件斤数+单价单位(A:2.37÷10=0.23元/斤) if ($rowA[2] !== '10斤/箱' || $rowA[3] !== '0.23元/斤') { return false; } // 散装无法折算 → 单价按每件原价显示(B:5.00元/斤) if ($rowB[2] !== '散装筐' || $rowB[3] !== '5.00元/斤') { return false; } // 账单内同商品合并:数量 16、金额 38.00 if ((int) $rowA[4] !== 16 || (float) $rowA[6] !== 38.0) { return false; } if ((int) $rowB[4] !== 4 || (float) $rowB[6] !== 20.0) { return false; } $summary = $rows->slice(6)->values(); // 合计:数量 20、商品金额 58.00;配送费 10.00;附加 30.00(筐 2 / 托盘 1);售后 -8.00;总金额 90.00 if ((int) $summary[0][4] !== 20 || (float) $summary[0][6] !== 58.0) { return false; } if ($summary[1][0] !== '配送费' || (float) $summary[1][6] !== 10.0) { return false; } if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][6] !== 30.0) { return false; } if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][6] !== -8.0) { return false; } return (float) $summary[4][6] === 90.0; } ); } /** 一级分类过滤:子分类商品归入根分类,其他分类商品忽略;费用行仍全额计入 */ public function test_export_filters_by_root_category(): void { $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $vegChild = ProductCategoryModel::create(['name' => '叶菜类', 'parent_id' => $vegRoot->id, 'sort' => 0, 'status' => 1]); $meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); $store = StoreModel::factory()->create(); $veg = ProductModel::factory()->create(['category_id' => $vegChild->id]); $meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]); $bill = $this->makeBill($store); $this->makeItem($bill, $veg, 10, '2.00'); $this->makeItem($bill, $meat, 5, '20.00'); $this->actingAsSysUser(); Excel::fake(); $this->get("/recon/bill/{$bill->id}/export?category_id={$vegRoot->id}")->assertOk(); Excel::assertDownloaded( '门店账单_' . $bill->bill_no . '.xlsx', static function (BillExport $export) use ($veg): bool { $rows = $export->collection()->values(); // 范围行标注所选分类 if (! str_contains((string) $rows[1][0], '分类:蔬菜')) { return false; } // 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略 $detail = $rows->slice(4, 1)->values()->first(); if ($detail === null || $detail[1] !== $veg->name) { return false; } $summary = $rows->slice(5)->values(); // 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00 if ((float) $summary[0][6] !== 20.0) { return false; } if ((float) $summary[1][6] !== 10.0 || (float) $summary[2][6] !== 30.0 || (float) $summary[3][6] !== 0.0) { return false; } return (float) $summary[4][6] === 60.0; } ); } /** 分类过滤后无明细 → 拒绝(真实下载链路,导出类抛业务异常) */ public function test_export_empty_category_rejected(): void { $vegRoot = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $meatRoot = ProductCategoryModel::create(['name' => '肉禽蛋', 'parent_id' => 0, 'sort' => 1, 'status' => 1]); $store = StoreModel::factory()->create(); $meat = ProductModel::factory()->create(['category_id' => $meatRoot->id]); $bill = $this->makeBill($store); $this->makeItem($bill, $meat, 5, '20.00'); $this->actingAsSysUser(); $this->get("/recon/bill/{$bill->id}/export?category_id={$vegRoot->id}") ->assertJsonPath('success', false); } /** 无 recon.bill.export 权限点 → 拦截 */ public function test_export_requires_export_permission(): void { $store = StoreModel::factory()->create(); $bill = $this->makeBill($store); // 先建一个占位用户:每个测试方法内首个系统用户自增 id=1, // SysAccessToken::can() 对 tokenable_id==1(超管)放行全部权限,会绕过 abilities 校验 $this->actingAsSysUser(); // 仅持有查询权限的用户 $this->actingAsSysUser(['recon.bill.query']); $response = $this->get("/recon/bill/{$bill->id}/export"); $this->assertFalse($response->json('success'), '缺少权限点应被拦截'); } /** 账单不存在 → 拒绝 */ public function test_export_bill_not_found_rejected(): void { $this->actingAsSysUser(); $this->get('/recon/bill/99999/export') ->assertJsonPath('success', false); } /** 小程序端导出:强制本店过滤,他店账单拒绝 */ public function test_mini_export_scoped_to_own_store(): void { $root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $storeA = StoreModel::factory()->create(); $storeB = StoreModel::factory()->create(); $product = ProductModel::factory()->create(['category_id' => $root->id]); $billA = $this->makeBill($storeA); $billB = $this->makeBill($storeB); $this->makeItem($billA, $product, 10, '2.00'); $this->actingAsMiniStore($storeA); // 本店账单正常导出 Excel::fake(); $this->get("/mini/bill/{$billA->id}/export")->assertOk(); Excel::assertDownloaded( '门店账单_' . $billA->bill_no . '.xlsx', static function (BillExport $export): bool { $rows = $export->collection()->values(); // 标题1 + 范围1 + 空行1 + 列头1 + 明细1 + 合计5 = 10 行 return $rows->count() === 10; } ); // 他店账单 → 本店口径查无账单 $this->get("/mini/bill/{$billB->id}/export") ->assertJsonPath('success', false); } }