首页分析页
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BillModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ProductCategoryModel;
|
||||
use App\Models\ProductModel;
|
||||
use App\Models\PurchaseOrderModel;
|
||||
use App\Models\StoreModel;
|
||||
use App\Models\StoreOrderItemModel;
|
||||
use App\Models\StoreOrderModel;
|
||||
use App\Models\SupplierModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
/**
|
||||
* 仪表盘分析页聚合接口:权限校验、指标口径(排除已取消)、趋势补日、排行与对账概览
|
||||
*/
|
||||
class DashboardTest extends ProcurementTestCase
|
||||
{
|
||||
private const string ROUTE = '/dashboard/analysis';
|
||||
|
||||
/** 造一张门店订单(默认已完成) */
|
||||
private function makeOrder(StoreModel $store, string $amount, array $attributes = []): StoreOrderModel
|
||||
{
|
||||
return StoreOrderModel::factory()->create(array_merge([
|
||||
'store_id' => $store->id,
|
||||
'order_date' => now()->toDateString(),
|
||||
'total_amount' => $amount,
|
||||
'total_quantity' => 1,
|
||||
'status' => StoreOrderModel::STATUS_COMPLETED,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 造一条订单明细(显式指定数量与金额,避免工厂随机值干扰断言) */
|
||||
private function makeItem(StoreOrderModel $order, array $attributes = []): StoreOrderItemModel
|
||||
{
|
||||
return StoreOrderItemModel::factory()->create(array_merge([
|
||||
'order_id' => $order->id,
|
||||
'store_id' => $order->store_id,
|
||||
'product_id' => 1,
|
||||
'product_name' => '测试商品',
|
||||
'product_spec' => '500g/袋',
|
||||
'unit' => '斤',
|
||||
'price' => '10.00',
|
||||
'quantity' => 1,
|
||||
'amount' => '10.00',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 造一张账单 */
|
||||
private function makeBill(StoreModel $store, string $totalAmount, array $attributes = []): BillModel
|
||||
{
|
||||
return BillModel::create(array_merge([
|
||||
'bill_no' => 'ZD' . random_int(100000000000, 999999999999),
|
||||
// (purchase_id, store_id) 唯一约束:每张账单挂独立采购单
|
||||
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
|
||||
'store_id' => $store->id,
|
||||
'bill_date' => now()->toDateString(),
|
||||
'product_amount' => $totalAmount,
|
||||
'delivery_fee' => '0.00',
|
||||
'box_num' => 0,
|
||||
'tray_num' => 0,
|
||||
'box_price' => '0.00',
|
||||
'tray_price' => '0.00',
|
||||
'added_amount' => '0.00',
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => BillModel::STATUS_UNPAID,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** 未登录不可访问 */
|
||||
public function test_guest_cannot_access_dashboard(): void
|
||||
{
|
||||
$this->getJson(self::ROUTE)->assertStatus(401);
|
||||
}
|
||||
|
||||
/** 缺少 dashboard.analysis 权限点时拒绝访问 */
|
||||
public function test_requires_dashboard_analysis_ability(): void
|
||||
{
|
||||
// SysUserModel 的 id 不可批量赋值:首个创建的用户自增为 id=1,
|
||||
// SysAccessToken::can() 对 tokenable_id==1(超管)直接放行,故先建占位用户
|
||||
$this->actingAsSysUser();
|
||||
$this->actingAsSysUser(['system.user.query']);
|
||||
|
||||
// 应用约定:权限不足由 ExceptionsHandler 渲染为 HTTP 200 + success=false 警告通知
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('success', false)
|
||||
->assertJsonPath('msg', 'No Permission');
|
||||
}
|
||||
|
||||
/** 空库返回完整结构:指标为0、趋势补满30天、状态分布固定6项 */
|
||||
public function test_empty_database_returns_zero_structure(): void
|
||||
{
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
|
||||
$response = $this->getJson(self::ROUTE)->assertOk()->assertJsonPath('success', true);
|
||||
|
||||
$response->assertJsonPath('data.overview.sales.total', 0)
|
||||
->assertJsonPath('data.overview.orders.total', 0)
|
||||
->assertJsonPath('data.overview.purchase.total', 0)
|
||||
->assertJsonPath('data.overview.sales.growth', null)
|
||||
->assertJsonPath('data.overview.receivable.bill_total', 0)
|
||||
->assertJsonPath('data.archives.stores', 0);
|
||||
$this->assertCount(30, $response->json('data.trend'), '趋势固定输出近30天');
|
||||
$this->assertCount(6, $response->json('data.order_status'), '订单状态固定6项');
|
||||
$this->assertCount(3, $response->json('data.recon.bills'), '账单支付进度固定3档');
|
||||
$this->assertSame([], $response->json('data.category_sales'));
|
||||
$this->assertSame([], $response->json('data.top_products'));
|
||||
$this->assertSame([], $response->json('data.latest_orders'));
|
||||
}
|
||||
|
||||
/** 销售额与订单数排除已取消订单 */
|
||||
public function test_overview_excludes_cancelled_orders(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->makeOrder($store, '100.00');
|
||||
$this->makeOrder($store, '50.00');
|
||||
$this->makeOrder($store, '999.00', ['status' => StoreOrderModel::STATUS_CANCELLED]);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.overview.sales.total', 150)
|
||||
->assertJsonPath('data.overview.sales.today', 150)
|
||||
->assertJsonPath('data.overview.orders.total', 2)
|
||||
->assertJsonPath('data.overview.orders.today', 2);
|
||||
}
|
||||
|
||||
/** 环比:近7天相对前7天的增长率;上期为0时为 null */
|
||||
public function test_week_growth_rate(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
// 近7天 100,前7天 50 → (100-50)/50 = 100%
|
||||
$this->makeOrder($store, '100.00');
|
||||
$this->makeOrder($store, '50.00', ['order_date' => now()->subDays(8)->toDateString()]);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.overview.sales.growth', 100)
|
||||
->assertJsonPath('data.overview.orders.growth', 0);
|
||||
|
||||
// 仅剩本期数据、上期为0 → growth 为 null
|
||||
StoreOrderModel::query()->where('order_date', now()->subDays(8)->toDateString())->delete();
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.overview.sales.growth', null);
|
||||
}
|
||||
|
||||
/** 趋势补日:30天全量输出,当日与历史日数据正确落位 */
|
||||
public function test_trend_covers_30_days_with_zero_filling(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->makeOrder($store, '12.50', ['order_date' => now()->subDays(3)->toDateString()]);
|
||||
$this->makeOrder($store, '20.00');
|
||||
$this->makeOrder($store, '30.00');
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$response = $this->getJson(self::ROUTE)->assertOk();
|
||||
|
||||
$trend = $response->json('data.trend');
|
||||
$this->assertCount(30, $trend);
|
||||
$this->assertSame(now()->toDateString(), $trend[29]['date'], '最后一天为今日');
|
||||
$this->assertSame(50, $trend[29]['amount']);
|
||||
$this->assertSame(2, $trend[29]['orders']);
|
||||
$this->assertSame(12.5, $trend[26]['amount'], '3天前数据落位正确');
|
||||
$this->assertSame(0, $trend[0]['amount'], '无数据日期补0');
|
||||
$this->assertSame(0, $trend[0]['orders']);
|
||||
}
|
||||
|
||||
/** 品类占比:按明细金额汇总、按分类名归类、排除已取消订单、金额降序 */
|
||||
public function test_category_sales_grouped_by_category(): void
|
||||
{
|
||||
$vegetable = ProductCategoryModel::create(['parent_id' => 0, 'name' => '蔬菜', 'sort' => 0, 'status' => 1]);
|
||||
$fruit = ProductCategoryModel::create(['parent_id' => 0, 'name' => '水果', 'sort' => 1, 'status' => 1]);
|
||||
|
||||
$store = StoreModel::factory()->create();
|
||||
$order = $this->makeOrder($store, '60.00');
|
||||
$this->makeItem($order, ['category_id' => $vegetable->id, 'amount' => '30.00']);
|
||||
$this->makeItem($order, ['category_id' => $vegetable->id, 'amount' => '10.00']);
|
||||
$this->makeItem($order, ['category_id' => $fruit->id, 'amount' => '20.00']);
|
||||
|
||||
// 已取消订单的明细不计入
|
||||
$cancelled = $this->makeOrder($store, '999.00', ['status' => StoreOrderModel::STATUS_CANCELLED]);
|
||||
$this->makeItem($cancelled, ['category_id' => $fruit->id, 'amount' => '999.00']);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.category_sales.0.name', '蔬菜')
|
||||
->assertJsonPath('data.category_sales.0.amount', 40)
|
||||
->assertJsonPath('data.category_sales.1.name', '水果')
|
||||
->assertJsonPath('data.category_sales.1.amount', 20);
|
||||
}
|
||||
|
||||
/** 热销商品:跨订单汇总数量与金额,按金额降序 */
|
||||
public function test_top_products_aggregated_across_orders(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$orderA = $this->makeOrder($store, '50.00');
|
||||
$orderB = $this->makeOrder($store, '80.00');
|
||||
|
||||
// 商品A:两单合计 数量5 金额50
|
||||
$this->makeItem($orderA, ['product_id' => 11, 'product_name' => '大白菜', 'quantity' => 2, 'amount' => '20.00']);
|
||||
$this->makeItem($orderB, ['product_id' => 11, 'product_name' => '大白菜', 'quantity' => 3, 'amount' => '30.00']);
|
||||
// 商品B:数量1 金额60 → 金额更高排第一
|
||||
$this->makeItem($orderB, ['product_id' => 12, 'product_name' => '车厘子', 'quantity' => 1, 'amount' => '60.00']);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.top_products.0.product_name', '车厘子')
|
||||
->assertJsonPath('data.top_products.0.amount', 60)
|
||||
->assertJsonPath('data.top_products.1.product_name', '大白菜')
|
||||
->assertJsonPath('data.top_products.1.quantity', 5)
|
||||
->assertJsonPath('data.top_products.1.amount', 50);
|
||||
}
|
||||
|
||||
/** 门店排行:按订单金额降序,附带门店名与订单数 */
|
||||
public function test_top_stores_ranked_by_amount(): void
|
||||
{
|
||||
$storeA = StoreModel::factory()->create(['name' => '一号店']);
|
||||
$storeB = StoreModel::factory()->create(['name' => '二号店']);
|
||||
$this->makeOrder($storeA, '100.00');
|
||||
$this->makeOrder($storeA, '100.00');
|
||||
$this->makeOrder($storeB, '300.00');
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.top_stores.0.store_name', '二号店')
|
||||
->assertJsonPath('data.top_stores.0.amount', 300)
|
||||
->assertJsonPath('data.top_stores.0.order_count', 1)
|
||||
->assertJsonPath('data.top_stores.1.store_name', '一号店')
|
||||
->assertJsonPath('data.top_stores.1.amount', 200)
|
||||
->assertJsonPath('data.top_stores.1.order_count', 2);
|
||||
}
|
||||
|
||||
/** 采购金额:实际价优先,无实际价退回预估价 */
|
||||
public function test_purchase_amount_prefers_actual_over_estimate(): void
|
||||
{
|
||||
PurchaseOrderModel::factory()->create(['estimate_amount' => '100.00', 'actual_amount' => '80.00']);
|
||||
PurchaseOrderModel::factory()->create(['estimate_amount' => '50.00', 'actual_amount' => 0]);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.overview.purchase.total', 130, '80 实际价 + 50 预估价')
|
||||
->assertJsonPath('data.overview.purchase.today', 130);
|
||||
}
|
||||
|
||||
/** 对账概览:账单按支付进度分档统计,应收余额扣除已支付;待审核回款单独计 */
|
||||
public function test_recon_summary_and_receivable(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create();
|
||||
$this->makeBill($store, '100.00'); // 待支付
|
||||
$this->makeBill($store, '200.00', ['payment_id' => 5]); // 审核中
|
||||
$this->makeBill($store, '300.00', ['status' => BillModel::STATUS_PAID]); // 已支付
|
||||
|
||||
PaymentModel::create([
|
||||
'payment_no' => 'ZF' . random_int(100000000000, 999999999999),
|
||||
'store_id' => $store->id,
|
||||
'user_id' => 0,
|
||||
'amount' => '88.00',
|
||||
'pay_method' => PaymentModel::METHOD_WECHAT,
|
||||
'status' => PaymentModel::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.recon.bills.0.count', 1)
|
||||
->assertJsonPath('data.recon.bills.0.amount', 100)
|
||||
->assertJsonPath('data.recon.bills.1.count', 1)
|
||||
->assertJsonPath('data.recon.bills.1.amount', 200)
|
||||
->assertJsonPath('data.recon.bills.2.count', 1)
|
||||
->assertJsonPath('data.recon.bills.2.amount', 300)
|
||||
->assertJsonPath('data.recon.pending_payment.count', 1)
|
||||
->assertJsonPath('data.recon.pending_payment.amount', 88)
|
||||
->assertJsonPath('data.overview.receivable.bill_total', 600)
|
||||
->assertJsonPath('data.overview.receivable.received', 300)
|
||||
->assertJsonPath('data.overview.receivable.unreceived', 300);
|
||||
}
|
||||
|
||||
/** 最新订单:按日期倒序、最多8条、附带状态名与门店名 */
|
||||
public function test_latest_orders_limited_and_ordered(): void
|
||||
{
|
||||
$store = StoreModel::factory()->create(['name' => '旗舰店']);
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$this->makeOrder($store, '10.00', ['order_date' => now()->subDays($i)->toDateString()]);
|
||||
}
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$response = $this->getJson(self::ROUTE)->assertOk();
|
||||
|
||||
$latest = $response->json('data.latest_orders');
|
||||
$this->assertCount(8, $latest, '最多返回8条');
|
||||
$this->assertSame(now()->toDateString(), $latest[0]['order_date'], '最新订单在前');
|
||||
$this->assertSame('旗舰店', $latest[0]['store_name']);
|
||||
$this->assertSame('已完成', $latest[0]['status_name']);
|
||||
}
|
||||
|
||||
/** 基础档案仅统计启用中的门店/商品/供应商/用户 */
|
||||
public function test_archives_count_active_records_only(): void
|
||||
{
|
||||
StoreModel::factory()->count(2)->create(['status' => StoreModel::STATUS_NORMAL]);
|
||||
StoreModel::factory()->create(['status' => StoreModel::STATUS_DISABLED]);
|
||||
ProductModel::factory()->count(3)->create(['status' => ProductModel::STATUS_ON]);
|
||||
ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF]);
|
||||
SupplierModel::factory()->create(['status' => SupplierModel::STATUS_NORMAL]);
|
||||
SupplierModel::factory()->create(['status' => SupplierModel::STATUS_DISABLED]);
|
||||
UserModel::factory()->count(2)->create(['status' => UserModel::STATUS_NORMAL]);
|
||||
UserModel::factory()->create(['status' => UserModel::STATUS_DISABLED]);
|
||||
|
||||
$this->actingAsSysUser(['dashboard.analysis']);
|
||||
$this->getJson(self::ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.archives.stores', 2)
|
||||
->assertJsonPath('data.archives.products', 3)
|
||||
->assertJsonPath('data.archives.suppliers', 1)
|
||||
->assertJsonPath('data.archives.users', 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user