Files
xin-procurement/tests/Feature/BillExportTest.php
T
2026-10-30 10:51:09 +08:00

360 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Exports\BillBatchExport;
use App\Exports\BillExport;
use App\Models\BillModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use Maatwebsite\Excel\Facades\Excel;
/**
* 门店账单导出:单账单合并明细 / 批量按门店分工作表 / 一级分类过滤 /
* 费用行取账单自身字段 / 权限拦截 / 参数校验 / 小程序端本店口径
*/
class BillExportTest extends ProcurementTestCase
{
private static int $billSeq = 0;
/** 造一张账单(默认配送费 10、筐 2×5 + 托盘 1×20 = 附加 30、售后 0、总额 140,未支付) */
private function makeBill(StoreModel $store, array $attributes = []): BillModel
{
$seq = ++self::$billSeq;
return BillModel::create(array_merge([
'bill_no' => '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;
}
// 一级分类列=商品根分类名
if ($rowA[2] !== '蔬菜' || $rowB[2] !== '蔬菜') {
return false;
}
// 包规列=包规+单位合并;单价列=每件价÷每件斤数+单价单位(A:2.37÷10=0.23元/斤)
if ($rowA[3] !== '10斤/箱' || $rowA[4] !== '0.23元/斤') {
return false;
}
// 散装无法折算 → 单价按每件原价显示(B:5.00元/斤)
if ($rowB[3] !== '散装筐' || $rowB[4] !== '5.00元/斤') {
return false;
}
// 账单内同商品合并:数量 16、金额 38.00
if ((int) $rowA[5] !== 16 || (float) $rowA[7] !== 38.0) {
return false;
}
if ((int) $rowB[5] !== 4 || (float) $rowB[7] !== 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][5] !== 20 || (float) $summary[0][7] !== 58.0) {
return false;
}
if ($summary[1][0] !== '配送费' || (float) $summary[1][7] !== 10.0) {
return false;
}
if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][7] !== 30.0) {
return false;
}
if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][7] !== -8.0) {
return false;
}
return (float) $summary[4][7] === 90.0;
}
);
}
/** 批量导出:同一门店多张账单合并在一张工作表,不同门店拆分工作表 */
public function test_batch_export_groups_bills_by_store(): void
{
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$storeA = StoreModel::factory()->create(['name' => '门店A']);
$storeB = StoreModel::factory()->create(['name' => '门店B']);
$product = ProductModel::factory()->create(['category_id' => $root->id]);
$billA1 = $this->makeBill($storeA);
$billA2 = $this->makeBill($storeA);
$billB = $this->makeBill($storeB);
$this->makeItem($billA1, $product, 2, '2.00');
$this->makeItem($billA2, $product, 3, '3.00');
$this->makeItem($billB, $product, 4, '4.00');
$this->actingAsSysUser();
Excel::fake();
$query = http_build_query(['ids' => [$billB->id, $billA2->id, $billA1->id]]);
$this->get('/recon/bill/batch-export?' . $query)->assertOk();
Excel::assertDownloaded(
'门店账单_批量导出.xlsx',
static function (BillBatchExport $export) use ($billA1, $billA2, $billB): bool {
$sheets = collect($export->sheets());
if ($sheets->count() !== 2) {
return false;
}
$sheetA = $sheets->first(static fn (BillExport $sheet): bool => $sheet->title() === '门店A');
$sheetB = $sheets->first(static fn (BillExport $sheet): bool => $sheet->title() === '门店B');
if ($sheetA === null || $sheetB === null) {
return false;
}
$rowsA = $sheetA->collection()->values();
$billTitlesA = $rowsA->filter(static fn (array $row): bool => str_contains((string) $row[0], '门店账单 '));
if ($rowsA->count() !== 21 || $billTitlesA->count() !== 2) {
return false;
}
if (! str_contains((string) $rowsA[0][0], $billA1->bill_no)
|| ! str_contains((string) $rowsA[11][0], $billA2->bill_no)) {
return false;
}
$rowsB = $sheetB->collection()->values();
return $rowsB->count() === 10 && str_contains((string) $rowsB[0][0], $billB->bill_no);
}
);
}
/** 批量导出:必须选择已存在的账单,且一次最多 100 张 */
public function test_batch_export_validates_selected_ids(): void
{
$this->actingAsSysUser();
$this->getJson('/recon/bill/batch-export')->assertJsonPath('success', false);
$this->getJson('/recon/bill/batch-export?ids[]=99999')->assertJsonPath('success', false);
$this->getJson('/recon/bill/batch-export?' . http_build_query(['ids' => range(1, 101)]))
->assertJsonPath('success', false);
}
/** 一级分类过滤:子分类商品归入根分类,其他分类商品忽略;费用行仍全额计入 */
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 || $detail[2] !== '蔬菜') {
return false;
}
$summary = $rows->slice(5)->values();
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00
if ((float) $summary[0][7] !== 20.0) {
return false;
}
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0 || (float) $summary[3][7] !== 0.0) {
return false;
}
return (float) $summary[4][7] === 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_batch_export_scoped_to_own_store(): void
{
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$storeA = StoreModel::factory()->create(['name' => '门店A']);
$storeB = StoreModel::factory()->create(['name' => '门店B']);
$product = ProductModel::factory()->create(['category_id' => $root->id]);
$billA1 = $this->makeBill($storeA);
$billA2 = $this->makeBill($storeA);
$billB = $this->makeBill($storeB);
$this->makeItem($billA1, $product, 2, '2.00');
$this->makeItem($billA2, $product, 3, '3.00');
$this->actingAsMiniStore($storeA);
Excel::fake();
$query = http_build_query(['ids' => [$billA1->id, $billA2->id]]);
$this->get('/mini/bill/batch-export?' . $query)->assertOk();
Excel::assertDownloaded(
'门店账单_批量导出.xlsx',
static function (BillBatchExport $export): bool {
$sheets = $export->sheets();
return count($sheets) === 1
&& $sheets[0]->title() === '门店A'
&& $sheets[0]->collection()->count() === 21;
}
);
$otherStoreQuery = http_build_query(['ids' => [$billA1->id, $billB->id]]);
$this->get('/mini/bill/batch-export?' . $otherStoreQuery)
->assertJsonPath('success', false);
$this->get('/mini/bill/batch-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);
}
}