Files
saasshop/app/Http/Controllers/Admin/DashboardController.php
2026-03-18 08:26:46 +08:00

447 lines
24 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(),
// 可BMPA处理尽量贴近后端治理安全阀
// - pending + unpaid
// - 排除存在退款轨迹refund_total > 0
// - 排除续费缺订阅order_type=renewal 且 site_subscription_id 为空)
'platform_orders_unpaid_pending' => (function () {
$q = PlatformOrder::query()
->where('payment_status', 'unpaid')
->where('status', 'pending')
->where(function ($b) {
$b->where('order_type', '!=', 'renewal')
->orWhereNotNull('site_subscription_id');
});
$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->whereRaw("ROUND(($refundTotalExpr) * 100) <= 0");
} 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->whereRaw("ROUND(($refundTotalExpr) * 100) <= 0");
}
return $q->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(),
// BMPA 成功用于运营抽样复核spot-check批量标记支付并生效的成功集合
// 口径与平台订单列表 bmpa_success_only=1 一致run_id 存在且 error.message 为空
'platform_orders_bmpa_success' => PlatformOrder::query()
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.run_id') IS NOT NULL")
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') IS 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 一致)
// 注意:仅当退款总额 > 0 时才参与不一致判定(避免 refund_summary.total_amount=0 的“空字段”噪音被计入)。
'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)";
// 只统计退款总额 > 0 的订单
$q->whereRaw("ROUND(($refundTotalExpr) * 100) > 0");
$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)";
// 只统计退款总额 > 0 的订单
$q->whereRaw("ROUND(($refundTotalExpr) * 100) > 0");
$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(),
]
);
// 统一基准时间:避免本方法内多次 now() 调用在跨天瞬间造成口径漂移
$baseNow = now();
// 趋势卡(最小可用):近 7 天平台订单按天统计(订单数 + 已付金额)
$trendDays = 7;
$trendStart = $baseNow->copy()->startOfDay()->subDays($trendDays - 1);
$trendEnd = $baseNow->copy()->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(2)
->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();
// 平台定位(运营版):北极星指标 + 明确治理入口(不暴露组合维度)
$range30Start = $baseNow->copy()->subDays(29)->startOfDay();
$range30End = $baseNow->copy()->endOfDay();
$range30From = (string) $range30Start->format('Y-m-d');
$range30To = (string) $range30End->format('Y-m-d');
$paidRevenue30d = (float) PlatformOrder::query()
->where('payment_status', 'paid')
->whereBetween('created_at', [$range30Start, $range30End])
->sum('paid_amount');
$activePaidMerchants = (int) SiteSubscription::query()
->whereNotNull('merchant_id')
->where('status', 'activated')
->whereNotNull('ends_at')
->where('ends_at', '>=', $baseNow)
->distinct()
->count('merchant_id');
$renewalCreated30d = (int) PlatformOrder::query()
->where('order_type', 'renewal')
->whereBetween('created_at', [$range30Start, $range30End])
->count();
$renewalSuccess30d = (int) PlatformOrder::query()
->where('order_type', 'renewal')
->where('payment_status', 'paid')
->where('status', 'activated')
->whereBetween('created_at', [$range30Start, $range30End])
->count();
$renewalSuccessRate30d = $renewalCreated30d > 0
? min(100, max(0, round(($renewalSuccess30d / $renewalCreated30d) * 100, 1)))
: 0;
$ordersTotal7d = (int) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->count();
$funnelUnpaidPending7d = (int) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'unpaid')
->where('status', 'pending')
->count();
$funnelPaid7d = (int) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'paid')
->count();
$funnelPaidActivated7d = (int) PlatformOrder::query()
->whereBetween('created_at', [$trendStart, $trendEnd])
->where('payment_status', 'paid')
->where('status', 'activated')
->count();
// 将平台定位的关键指标与“可执行动作入口”绑定(回到仪表盘自身)
$opsLinks = [
'revenue_30d_paid_orders' => \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'payment_status' => 'paid',
'created_from' => $range30From,
'created_to' => $range30To,
]), \App\Support\BackUrl::selfWithoutBack()),
'active_paid_merchants_subscriptions' => \App\Support\BackUrl::withBack('/admin/site-subscriptions?' . \Illuminate\Support\Arr::query([
'status' => 'activated',
// 口径对齐 active_paid_merchants已生效且未到期ends_at >= today
'ends_from' => (string) $baseNow->format('Y-m-d'),
'page' => null,
]), \App\Support\BackUrl::selfWithoutBack()),
'renewal_orders_30d' => \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'order_type' => 'renewal',
'created_from' => $range30From,
'created_to' => $range30To,
]), \App\Support\BackUrl::selfWithoutBack()),
'funnel_unpaid_pending_7d' => \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'payment_status' => 'unpaid',
'status' => 'pending',
'created_from' => $dashboardRangeFrom7d,
'created_to' => $dashboardRangeTo7d,
]), \App\Support\BackUrl::selfWithoutBack()),
'funnel_paid_7d' => \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'payment_status' => 'paid',
'created_from' => $dashboardRangeFrom7d,
'created_to' => $dashboardRangeTo7d,
]), \App\Support\BackUrl::selfWithoutBack()),
'funnel_paid_activated_7d' => \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query([
'payment_status' => 'paid',
'status' => 'activated',
'created_from' => $dashboardRangeFrom7d,
'created_to' => $dashboardRangeTo7d,
]), \App\Support\BackUrl::selfWithoutBack()),
];
return view('admin.dashboard', [
'adminName' => $admin->name,
'stats' => $stats,
'platformOrderTrend7d' => $platformOrderTrend7d,
'recentPlatformOrders' => $recentPlatformOrders,
'dashboardRangeFrom7d' => $dashboardRangeFrom7d,
'dashboardRangeTo7d' => $dashboardRangeTo7d,
// 注意:旧版的 platformPositioning 已弃用;当前仪表盘使用 platformOpsOverview 作为“平台定位(运营版)”的数据源。
// 这里先移除 platformPositioning避免遗留变量命名不一致导致 /admin 500破坏 Dashboard 回归基线。
'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',
],
'platformOpsOverview' => [
// 北极星指标
'paid_revenue_30d' => $paidRevenue30d,
'active_paid_merchants' => $activePaidMerchants,
'renewal_success_rate_30d' => $renewalSuccessRate30d,
'renewal_success_30d' => $renewalSuccess30d,
'renewal_created_30d' => $renewalCreated30d,
// 漏斗近7天
'orders_total_7d' => $ordersTotal7d,
'funnel_unpaid_pending_7d' => $funnelUnpaidPending7d,
'funnel_paid_7d' => $funnelPaid7d,
'funnel_paid_activated_7d' => $funnelPaidActivated7d,
// 待处理治理(积压口径,全量)
'govern_bmpa_processable' => (int) ($stats['platform_orders_unpaid_pending'] ?? 0),
'govern_syncable' => (int) ($stats['platform_orders_syncable'] ?? 0),
'govern_sync_failed' => (int) ($stats['platform_orders_sync_failed'] ?? 0),
'links' => $opsLinks,
],
'platformOverview' => [
'system_role' => '总台管理',
'current_scope' => '总台运营方视角',
'merchant_mode' => '统一管理多个站点',
'channel_count' => 5,
'active_merchants' => $stats['active_merchants'],
'pending_orders' => $stats['pending_orders'],
],
]);
}
}