461 lines
21 KiB
PHP
461 lines
21 KiB
PHP
<?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;
|
||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||
|
||
/**
|
||
* 采购单导出(仅 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', 'spec' => '10斤/箱']);
|
||
|
||
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、单价按成本价÷包规显示(7÷10=0.70)、门店列 0
|
||
$extraRow = $rows->firstWhere(2, $extra->name);
|
||
if ($extraRow === null
|
||
|| (float) $extraRow[9] !== 0.0 || (float) $extraRow[8] !== 0.7
|
||
|| (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_hides_detail_columns_by_default(): void
|
||
{
|
||
[$purchase] = $this->buildPurchaseWithSuppliers();
|
||
|
||
$export = new PurchaseOrderExport($purchase);
|
||
$sheet = (new Spreadsheet())->getActiveSheet();
|
||
$export->styles($sheet);
|
||
|
||
// 序号A/分类B/包规F/单位G/成本H/单价I/实际称重K/金额L 默认隐藏
|
||
foreach (['A', 'B', 'F', 'G', 'H', 'I', 'K', 'L'] as $column) {
|
||
$this->assertFalse($sheet->getColumnDimension($column)->getVisible(), "列 {$column} 应默认隐藏");
|
||
}
|
||
// 品名/供应商/市场/数量与门店列保持可见
|
||
foreach (['C', 'D', 'E', 'J', 'M', 'N'] as $column) {
|
||
$this->assertTrue($sheet->getColumnDimension($column)->getVisible(), "列 {$column} 应保持可见");
|
||
}
|
||
// 数据照常导出:列头与合计行仍含隐藏列数据
|
||
$rows = $export->collection()->values();
|
||
$header = $rows[3];
|
||
$this->assertSame('序号', $header[0]);
|
||
$this->assertSame('金额', $header[11]);
|
||
$total = $rows->last();
|
||
$this->assertSame('合计', $total[2]);
|
||
$this->assertSame(45.0, (float) $total[11]);
|
||
}
|
||
|
||
/** 商品明细导出:范围=全部未删除商品(含下架)∪ 本采购单有订货商品;已删除且无订货的商品不导出 */
|
||
public function test_export_scope_includes_off_shelf_but_not_deleted(): void
|
||
{
|
||
[$purchase] = $this->buildPurchaseWithSuppliers();
|
||
$offShelf = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF, 'cost_price' => '8.00']);
|
||
$deleted = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
|
||
$deleted->delete();
|
||
|
||
$rows = (new PurchaseOrderExport($purchase))->collection()->values();
|
||
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
|
||
|
||
$this->assertContains($offShelf->name, $names);
|
||
$this->assertNotContains($deleted->name, $names);
|
||
}
|
||
|
||
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
|
||
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', '该门店在此采购单中无采购商品');
|
||
}
|
||
|
||
/** 供应商采购明细导出:按「供应商×市场」拆分工作表,模板列序=品名/汇总/市场/各门店明细 */
|
||
public function test_export_suppliers_multi_sheet(): void
|
||
{
|
||
[$purchase, $veg, $meat, $storeA, $storeB, $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, $storeA, $storeB): 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 件;门店列=有该供应商明细的两家门店
|
||
$rowsA = $sheets[0]->collection()->values();
|
||
if ($rowsA[2] !== ['品名', '汇总', '市场', $storeA->name, $storeB->name]) {
|
||
return false;
|
||
}
|
||
$vegRow = $rowsA->firstWhere(0, $veg->name);
|
||
if ($vegRow === null || (int) $vegRow[1] !== 5 || $vegRow[2] !== '新发地'
|
||
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
|
||
return false;
|
||
}
|
||
// 无合计行:末行即最后一条明细
|
||
$lastA = $rowsA->last();
|
||
if ($lastA[0] === '合计') {
|
||
return false;
|
||
}
|
||
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A
|
||
$rowsB = $sheets[1]->collection()->values();
|
||
if ($rowsB[2] !== ['品名', '汇总', '市场', $storeA->name]) {
|
||
return false;
|
||
}
|
||
$meatRow = $rowsB->firstWhere(0, $meat->name);
|
||
return $meatRow !== null && (int) $meatRow[1] === 1 && $meatRow[2] === '岳各庄' && (int) $meatRow[3] === 1;
|
||
}
|
||
);
|
||
}
|
||
|
||
/** 供应商采购明细导出:同一供应商多个市场拆分为多个工作表(空市场归入「未设置」) */
|
||
public function test_export_suppliers_split_by_market(): void
|
||
{
|
||
$supplier = SupplierModel::factory()->create();
|
||
$level = CustomerLevelModel::factory()->create();
|
||
$store = StoreModel::factory()->create(['level_id' => $level->id]);
|
||
$vegA = ProductModel::factory()->create([
|
||
'status' => ProductModel::STATUS_ON, 'cost_price' => '5.00',
|
||
'supplier_id' => $supplier->id, 'market' => '新发地',
|
||
]);
|
||
$vegB = ProductModel::factory()->create([
|
||
'status' => ProductModel::STATUS_ON, 'cost_price' => '6.00',
|
||
'supplier_id' => $supplier->id, 'market' => '岳各庄',
|
||
]);
|
||
$vegC = ProductModel::factory()->create([
|
||
'status' => ProductModel::STATUS_ON, 'cost_price' => '7.00',
|
||
'supplier_id' => $supplier->id, 'market' => '',
|
||
]);
|
||
|
||
$this->actingAsMiniStore($store);
|
||
$this->postJson('/mini/order', ['items' => [
|
||
['product_id' => $vegA->id, 'quantity' => 1],
|
||
['product_id' => $vegB->id, 'quantity' => 1],
|
||
['product_id' => $vegC->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);
|
||
$purchase = PurchaseOrderModel::first();
|
||
|
||
Excel::fake();
|
||
$this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplier->id}")->assertOk();
|
||
|
||
Excel::assertDownloaded(
|
||
$purchase->purchase_no . '_供应商采购明细_' . $supplier->name . '.xlsx',
|
||
static function (PurchaseSupplierExport $export) use ($supplier, $vegA, $vegB, $vegC): bool {
|
||
$sheets = $export->sheets();
|
||
$titles = array_map(static fn ($sheet) => $sheet->title(), $sheets);
|
||
sort($titles);
|
||
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
|
||
return false;
|
||
}
|
||
// 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
|
||
foreach ($sheets as $sheet) {
|
||
$rows = $sheet->collection()->values();
|
||
$names = $rows->slice(3)->map(static fn ($row) => $row[0])->values()->all();
|
||
$expected = match (true) {
|
||
str_ends_with($sheet->title(), '新发地') => [$vegA->name],
|
||
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
|
||
default => [$vegC->name],
|
||
};
|
||
if ($names !== $expected) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
/** 供应商采购明细导出:单供应商导出(工作表名=供应商·市场) + 无明细供应商拒绝 */
|
||
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', '该供应商在此采购单中无采购明细');
|
||
}
|
||
}
|