Files
xin-procurement/tests/Feature/BillExportTest.php
T
2026-08-29 13:54:06 +08:00

262 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
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;
/**
* 门店账单合并导出:跨账单按商品合并明细(加权平均单价)/ 一级分类过滤 /
* 配送费与附加金额全额汇总 / ids 校验 / 权限拦截 / 分类下无明细拒绝
*/
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_across_bills(): void
{
$this->freezeTime();
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$store = StoreModel::factory()->create();
$productA = ProductModel::factory()->create(['category_id' => $root->id]);
$productB = ProductModel::factory()->create(['category_id' => $root->id]);
$bill1 = $this->makeBill($store);
$bill2 = $this->makeBill($store, ['after_sale' => '-8.00']);
// 同一商品跨账单:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37
$this->makeItem($bill1, $productA, 10, '2.00');
$this->makeItem($bill2, $productA, 6, '3.00');
$this->makeItem($bill2, $productB, 4, '5.00');
$this->actingAsSysUser();
Excel::fake();
$this->get("/recon/bill/export?ids={$bill1->id},{$bill2->id}")->assertOk();
Excel::assertDownloaded(
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
static function (BillExport $export) use ($productA, $productB): bool {
$rows = $export->collection()->values();
// 标题1 + 范围2 + 空行1 + 列头1 + 明细2 + 合计5 = 12 行
if ($rows->count() !== 12) {
return false;
}
$details = $rows->slice(5, 2)->values();
$rowA = $details->firstWhere(1, $productA->name);
$rowB = $details->firstWhere(1, $productB->name);
if ($rowA === null || $rowB === null) {
return false;
}
// 跨账单同商品合并:数量 16、加权单价 2.37、金额 38.00
if ((float) $rowA[4] !== 2.37 || (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(7)->values();
// 合计:数量 20、商品金额 58.00;配送费 20.00;附加 60.00(筐 4 / 托盘 2);售后 -8.00;总金额 130.00
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) {
return false;
}
if ($summary[1][0] !== '配送费合计' || (float) $summary[1][7] !== 20.0) {
return false;
}
if (! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个') || (float) $summary[2][7] !== 60.0) {
return false;
}
if (! str_contains((string) $summary[3][0], '售后金额合计') || (float) $summary[3][7] !== -8.0) {
return false;
}
return (float) $summary[4][7] === 130.0;
}
);
}
/** 一级分类过滤:子分类商品归入根分类,其他分类商品忽略;费用行仍全额汇总 */
public function test_export_filters_by_root_category(): void
{
$this->freezeTime();
$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/export?ids={$bill->id}&category_id={$vegRoot->id}")->assertOk();
Excel::assertDownloaded(
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
static function (BillExport $export) use ($veg): bool {
$rows = $export->collection()->values();
// 范围行标注所选分类
if (! str_contains((string) $rows[2][0], '分类:蔬菜')) {
return false;
}
// 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略
$detail = $rows->slice(5, 1)->values()->first();
if ($detail === null || $detail[1] !== $veg->name) {
return false;
}
$summary = $rows->slice(6)->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/export?ids={$bill->id}&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/export?ids={$bill->id}");
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
}
/** ids 缺失 / 超 100 张 / 账单不存在 → 拒绝 */
public function test_export_invalid_ids_rejected(): void
{
$this->actingAsSysUser();
$this->get('/recon/bill/export')
->assertJsonPath('success', false);
$this->get('/recon/bill/export?ids=' . implode(',', range(1, 101)))
->assertJsonPath('success', false);
$this->get('/recon/bill/export?ids=99999')
->assertJsonPath('success', false);
}
/** 小程序端导出:强制本店过滤,混入他店账单整批拒绝 */
public function test_mini_export_scoped_to_own_store(): void
{
$this->freezeTime();
$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->makeItem($billB, $product, 5, '20.00');
$this->actingAsMiniStore($storeA);
// 本店账单正常导出
Excel::fake();
$this->get("/mini/bill/export?ids={$billA->id}")->assertOk();
Excel::assertDownloaded(
'门店账单_' . now()->format('Ymd_His') . '.xlsx',
static function (BillExport $export): bool {
$rows = $export->collection()->values();
// 明细仅本店账单的 1 行(他店账单未混入)
return $rows->count() === 11;
}
);
// 混入他店账单 → 张数不匹配整批拒绝
$this->get("/mini/bill/export?ids={$billA->id},{$billB->id}")
->assertJsonPath('success', false);
// 仅他店账单 → 本店口径查无账单
$this->get("/mini/bill/export?ids={$billB->id}")
->assertJsonPath('success', false);
}
}