Files
xin-procurement/tests/Feature/BillExportTest.php
T
2026-11-01 16:17:12 +08:00

397 lines
18 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\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;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/**
* 门店账单导出:单账单合并明细 / 批量按门店分工作表 / 一级分类过滤 /
* 费用行取账单自身字段 / 权限拦截 / 参数校验 / 小程序端本店口径
*/
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_export_title_is_merged_across_all_columns(): void
{
$bill = $this->makeBill(StoreModel::factory()->create());
$sheet = (new Spreadsheet())->getActiveSheet();
(new BillExport($bill))->styles($sheet);
$this->assertArrayHasKey('A1:H1', $sheet->getMergeCells());
}
/** 批量导出:同一门店多账单的同商品与费用合并为一张表,不同门店拆分工作表 */
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,
'spec' => '10',
'unit' => '斤/箱',
'price_unit' => '元/斤',
]);
$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 ($billB, $product): 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();
$headersA = $rowsA->filter(static fn (array $row): bool => $row[0] === '序号');
if ($rowsA->count() !== 10 || $headersA->count() !== 1) {
return false;
}
if (! str_contains((string) $rowsA[0][0], '合并导出(2张)')
|| ! str_contains((string) $rowsA[1][0], '账单数量:2')) {
return false;
}
// 同商品合并:数量 2+3=5、重量 20+30=50、金额 4+9=13;加权单价 2.60,折算 0.26元/斤
$detail = $rowsA[4];
if ($detail[1] !== $product->name || $detail[4] !== '0.26元/斤'
|| (int) $detail[5] !== 5 || (float) $detail[6] !== 50.0 || (float) $detail[7] !== 13.0) {
return false;
}
$summary = $rowsA->slice(5)->values();
if ((int) $summary[0][5] !== 5 || (float) $summary[0][7] !== 13.0) {
return false;
}
// 配送费 10×2=20;附加 30×2=60(筐 4 / 托盘 2);总金额 13+20+60=93
if ((float) $summary[1][7] !== 20.0 || (float) $summary[2][7] !== 60.0
|| ! str_contains((string) $summary[2][0], '筐 4 个 / 周转托盘 2 个')) {
return false;
}
if ((float) $summary[4][7] !== 93.0) {
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() === 10;
}
);
$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);
}
}