Files
saasshop/app/Http/Controllers/Admin/DashboardController.php

297 lines
15 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 App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
use App\Http\Controllers\Controller;
use App\Models\Admin;
use App\Models\Order;
use App\Models\Product;
use App\Models\Merchant;
use App\Models\Plan;
use App\Models\PlatformOrder;
use App\Models\SiteSubscription;
use App\Models\User;
use App\Support\CacheKeys;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ResolvesPlatformAdminContext;
public function index(Request $request): View
{
$admin = $this->ensurePlatformAdmin($request);
$stats = Cache::remember(
CacheKeys::platformDashboardStats(),
now()->addMinutes(10),
fn () => [
'merchants' => Merchant::count(),
'admins' => Admin::count(),
'users' => User::count(),
'products' => Product::count(),
'orders' => Order::count(),
// 收费中心(平台侧)
'plans' => Plan::count(),
'site_subscriptions' => SiteSubscription::count(),
'site_subscriptions_expired' => SiteSubscription::query()
->whereNotNull('ends_at')
->where('ends_at', '<', now())
->count(),
'site_subscriptions_expiring_7d' => SiteSubscription::query()
->whereNotNull('ends_at')
->where('ends_at', '>=', now())
->where('ends_at', '<', now()->addDays(7))
->count(),
'platform_orders' => PlatformOrder::count(),
'platform_orders_unpaid_pending' => PlatformOrder::query()
->where('payment_status', 'unpaid')
->where('status', 'pending')
->count(),
'platform_orders_paid_pending' => PlatformOrder::query()
->where('payment_status', 'paid')
->where('status', 'pending')
// 口径对齐“待生效”语义sync_status=unsynced未同步 + 非失败
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
->count(),
// 可同步沿用平台订单列表口径paid+activated+未同步+无失败),且排除续费缺订阅脏数据
'platform_orders_syncable' => PlatformOrder::query()
->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
->where(function ($q) {
$q->where('order_type', '!=', 'renewal')
->orWhereNotNull('site_subscription_id');
})
->count(),
// 同步失败沿用平台订单列表口径meta.subscription_activation_error.message 存在即失败)
'platform_orders_sync_failed' => PlatformOrder::query()
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL")
->count(),
'platform_orders_renewal_missing_subscription' => PlatformOrder::query()
->where('order_type', 'renewal')
->whereNull('site_subscription_id')
->count(),
// BMPA 失败:用于运营快速定位“批量标记支付并生效”失败的订单集合
'platform_orders_bmpa_failed' => PlatformOrder::query()
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') IS NOT NULL")
->count(),
// 无回执(已支付但缺少回执证据):用于治理“已付但无回执”的风险订单
'platform_orders_paid_no_receipt' => PlatformOrder::query()
->where('payment_status', 'paid')
->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NULL")
->count(),
// 对账不一致(基于 paid_amount vs 回执总额,容差见 config('saasshop.amounts.tolerance')
'platform_orders_reconcile_mismatch' => (function () {
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$q = PlatformOrder::query();
$driver = $q->getQuery()->getConnection()->getDriverName();
// 重要:与 PlatformOrder::isReconcileMismatch() 口径一致:若无回执证据,不判定 mismatch而是走“无回执”治理集合
$q->where(function ($b) {
$b->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NOT NULL")
->orWhereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NOT NULL");
});
if ($driver === 'sqlite') {
$q->whereRaw("ABS(ROUND((CASE WHEN JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NOT NULL THEN CAST(JSON_EXTRACT(meta, '$.payment_summary.total_amount') AS REAL) ELSE (SELECT IFNULL(SUM(CAST(JSON_EXTRACT(value, '$.amount') AS REAL)), 0) FROM json_each(COALESCE(JSON_EXTRACT(meta, '$.payment_receipts'), '[]'))) END) * 100) - ROUND(paid_amount * 100)) >= {$tolCents}");
} else {
$q->whereRaw("ABS(ROUND((CASE WHEN JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NOT NULL THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.payment_summary.total_amount')) AS DECIMAL(12,2)) ELSE (SELECT IFNULL(SUM(j.amount), 0) FROM JSON_TABLE(meta, '$.payment_receipts[*]' COLUMNS(amount DECIMAL(12,2) PATH '$.amount')) j) END) * 100) - ROUND(paid_amount * 100)) >= {$tolCents}");
}
return $q->count();
})(),
// 退款数据不一致(口径与平台订单列表 refund_inconsistent=1 一致)
'platform_orders_refund_inconsistent' => (function () {
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$q = PlatformOrder::query();
$driver = $q->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$refundTotalExpr = "(CASE WHEN JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NOT NULL THEN CAST(JSON_EXTRACT(meta, '$.refund_summary.total_amount') AS REAL) ELSE (SELECT IFNULL(SUM(CAST(JSON_EXTRACT(value, '$.amount') AS REAL)), 0) FROM json_each(COALESCE(JSON_EXTRACT(meta, '$.refund_receipts'), '[]'))) END)";
$q->where(function ($b) use ($refundTotalExpr, $tolCents) {
$b->where(function ($q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', 'refunded')
->whereRaw('paid_amount > 0')
->whereRaw("(ROUND($refundTotalExpr * 100) + {$tolCents}) < ROUND(paid_amount * 100)");
})->orWhere(function ($q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw('paid_amount > 0')
->whereRaw("ROUND($refundTotalExpr * 100) >= (ROUND(paid_amount * 100) + {$tolCents})");
});
});
} else {
$refundTotalExpr = "(CASE WHEN JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NOT NULL THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.refund_summary.total_amount')) AS DECIMAL(12,2)) ELSE (SELECT IFNULL(SUM(j.amount), 0) FROM JSON_TABLE(meta, '$.refund_receipts[*]' COLUMNS(amount DECIMAL(12,2) PATH '$.amount')) j) END)";
$q->where(function ($b) use ($refundTotalExpr, $tolCents) {
$b->where(function ($q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', 'refunded')
->whereRaw('paid_amount > 0')
->whereRaw("(ROUND($refundTotalExpr * 100) + {$tolCents}) < ROUND(paid_amount * 100)");
})->orWhere(function ($q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw('paid_amount > 0')
->whereRaw("ROUND($refundTotalExpr * 100) >= (ROUND(paid_amount * 100) + {$tolCents})");
});
});
}
return $q->count();
})(),
// 站点治理
'active_merchants' => Merchant::query()->where('status', 'active')->count(),
'pending_orders' => Order::query()->where('status', 'pending')->count(),
]
);
// 趋势卡(最小可用):近 7 天平台订单按天统计(订单数 + 已付金额)
$trendDays = 7;
$trendStart = now()->startOfDay()->subDays($trendDays - 1);
$trendEnd = now()->endOfDay();
// 统一提供给视图做“趋势/排行/占比”跳转的日期范围口径,避免 Blade 内重复 now() 计算导致漂移。
$dashboardRangeFrom7d = (string) $trendStart->format('Y-m-d');
$dashboardRangeTo7d = (string) $trendEnd->format('Y-m-d');
$trendRawRows = PlatformOrder::query()
->selectRaw("DATE(created_at) as d")
->selectRaw('COUNT(*) as cnt')
->selectRaw("SUM(CASE WHEN payment_status = 'paid' THEN paid_amount ELSE 0 END) as paid_sum")
->whereBetween('created_at', [$trendStart, $trendEnd])
->groupBy('d')
->orderBy('d')
->get();
$trendByDate = [];
foreach ($trendRawRows as $r) {
$trendByDate[(string) $r->d] = [
'date' => (string) $r->d,
'count' => (int) ($r->cnt ?? 0),
'paid_sum' => (float) ($r->paid_sum ?? 0),
];
}
$platformOrderTrend7d = [];
for ($i = 0; $i < $trendDays; $i++) {
$day = $trendStart->copy()->addDays($i)->format('Y-m-d');
$platformOrderTrend7d[] = $trendByDate[$day] ?? [
'date' => $day,
'count' => 0,
'paid_sum' => 0.0,
];
}
$recentPlatformOrders = PlatformOrder::query()
->with(['merchant', 'plan'])
->orderByDesc('id')
->limit(5)
->get();
// 占比卡(最小可用):近 7 天按套餐统计平台订单数量 TopN
// 说明:
// - Top5 数据用于“对比/聚焦”(减少噪音)
// - total 用于计算“占比/覆盖率”,避免将 Top5 误当全量
$planOrderShareTotal = PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->whereNotNull('plan_id')
->count();
$planOrderShare = PlatformOrder::query()
->selectRaw('plan_id, COUNT(*) as cnt')
->whereBetween('created_at', [$trendStart, $trendEnd])
->whereNotNull('plan_id')
->groupBy('plan_id')
->orderByDesc('cnt')
->limit(5)
->get()
->map(function ($row) {
return [
'plan_id' => (int) $row->plan_id,
'count' => (int) $row->cnt,
];
})
->values()
->all();
$planIdToName = Plan::query()->pluck('name', 'id')->all();
// 排行卡(最小可用):近 7 天站点收入排行 Top5按已付金额
// 说明:只统计 payment_status=paid避免“未支付订单=0金额”混入排行造成噪音。
$merchantRevenueRank7d = PlatformOrder::query()
->selectRaw('merchant_id, COUNT(*) as cnt')
->selectRaw('SUM(paid_amount) as paid_sum')
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'paid')
->groupBy('merchant_id')
->orderByDesc('paid_sum')
->limit(5)
->get()
->map(function ($row) {
return [
'merchant_id' => (int) ($row->merchant_id ?? 0),
'count' => (int) ($row->cnt ?? 0),
'paid_sum' => (float) ($row->paid_sum ?? 0),
];
})
->values()
->all();
// 用于计算“Top5覆盖率/其它”的全量分母(近 7 天全站点已付总额/总订单数)
$merchantRevenueTotalPaid7d = (float) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'paid')
->sum('paid_amount');
$merchantRevenueTotalOrders7d = (int) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'paid')
->count();
$merchantIdToName = Merchant::query()->pluck('name', 'id')->all();
return view('admin.dashboard', [
'adminName' => $admin->name,
'stats' => $stats,
'platformOrderTrend7d' => $platformOrderTrend7d,
'recentPlatformOrders' => $recentPlatformOrders,
'dashboardRangeFrom7d' => $dashboardRangeFrom7d,
'dashboardRangeTo7d' => $dashboardRangeTo7d,
'planOrderShare' => $planOrderShare,
'planOrderShareTotal' => (int) $planOrderShareTotal,
'planIdToName' => $planIdToName,
'merchantRevenueRank7d' => $merchantRevenueRank7d,
'merchantRevenueTotalPaid7d' => (float) $merchantRevenueTotalPaid7d,
'merchantRevenueTotalOrders7d' => (int) $merchantRevenueTotalOrders7d,
'merchantIdToName' => $merchantIdToName,
'platformAdmin' => $admin,
'cacheMeta' => [
'store' => config('cache.default'),
'ttl' => '10m',
],
'platformOverview' => [
'system_role' => '总台管理',
'current_scope' => '总台运营方视角',
'merchant_mode' => '统一管理多个站点',
'channel_count' => 5,
'active_merchants' => $stats['active_merchants'],
'pending_orders' => $stats['pending_orders'],
],
]);
}
}