408 lines
21 KiB
PHP
408 lines
21 KiB
PHP
<?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(),
|
||
]
|
||
);
|
||
|
||
// 统一基准时间:避免本方法内多次 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(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();
|
||
|
||
// 平台定位(运营版):北极星指标 + 明确治理入口(不暴露组合维度)
|
||
$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?status=activated', \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'],
|
||
],
|
||
]);
|
||
}
|
||
}
|