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, '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]); $this->actingAsSysUser(['dashboard.analysis']); $response = $this->getJson(self::ROUTE) ->assertOk() ->assertJsonPath('data.archives.stores', 2) ->assertJsonPath('data.archives.products', 3) ->assertJsonPath('data.archives.suppliers', 1); $this->assertArrayNotHasKey('users', $response->json('data.archives'), '档案计数不再返回 users 键(与门店数重复)'); } }