Files
xin-procurement/tests/Feature/ExportTest.php
T
2026-08-20 15:39:39 +08:00

351 lines
16 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\PurchaseOrderExport;
use App\Exports\PurchaseStoreExport;
use App\Exports\PurchaseSupplierExport;
use App\Models\CustomerLevelModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use Maatwebsite\Excel\Facades\Excel;
/**
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
* 权限拦截 / 中文文件名 RFC 5987 编码
*/
class ExportTest extends ProcurementTestCase
{
/**
* 构造含蔬果 + 肉禽两类商品的采购单(下单 → 汇总生成)
*/
private function buildPurchaseWithItems(): PurchaseOrderModel
{
$vegRoot = ProductCategoryModel::create(['name' => '蔬菜', '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']);
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、参考零售价留空、门店列 0
$extraRow = $rows->firstWhere(2, $extra->name);
if ($extraRow === null
|| (float) $extraRow[9] !== 0.0 || $extraRow[8] !== ''
|| (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', '该门店在此采购单中无采购商品');
}
/** 供应商采购明细导出:多供应商合并一个 XLSX(工作表名=供应商名),按供应商聚合 */
public function test_export_suppliers_multi_sheet(): void
{
[$purchase, $veg, $meat, , , $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): 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 件、金额 5×5=25(市场列在品名后)
$rowsA = $sheets[0]->collection()->values();
if ($rowsA[2] !== ['序号', '品名', '市场', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) {
return false;
}
$vegRow = $rowsA->firstWhere(1, $veg->name);
if ($vegRow === null || $vegRow[2] !== '新发地' || (int) $vegRow[6] !== 5 || (float) $vegRow[8] !== 25.0) {
return false;
}
$totalA = $rowsA->last();
if ($totalA[1] !== '合计' || (int) $totalA[6] !== 5 || (float) $totalA[8] !== 25.0) {
return false;
}
// 供应商乙:肉 1 件、金额 20
$rowsB = $sheets[1]->collection()->values();
$meatRow = $rowsB->firstWhere(1, $meat->name);
return $meatRow !== null && $meatRow[2] === '岳各庄' && (int) $meatRow[6] === 1 && (float) $meatRow[8] === 20.0;
}
);
}
/** 供应商采购明细导出:单供应商导出 + 无明细供应商拒绝 */
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', '该供应商在此采购单中无采购明细');
}
}