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

274 lines
14 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')
// 口径对齐“待生效”语义:排除明确的同步失败(失败单应该去同步失败治理)
->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();
$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()
->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按已付金额
$merchantRevenueRank7d = PlatformOrder::query()
->selectRaw('merchant_id, 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('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();
$merchantIdToName = Merchant::query()->pluck('name', 'id')->all();
return view('admin.dashboard', [
'adminName' => $admin->name,
'stats' => $stats,
'platformOrderTrend7d' => $platformOrderTrend7d,
'recentPlatformOrders' => $recentPlatformOrders,
'planOrderShare' => $planOrderShare,
'planOrderShareTotal' => (int) $planOrderShareTotal,
'planIdToName' => $planIdToName,
'merchantRevenueRank7d' => $merchantRevenueRank7d,
'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'],
],
]);
}
}