diff --git a/app/Http/Controllers/Admin/PlanController.php b/app/Http/Controllers/Admin/PlanController.php index d65e4af..a8d0880 100644 --- a/app/Http/Controllers/Admin/PlanController.php +++ b/app/Http/Controllers/Admin/PlanController.php @@ -162,6 +162,64 @@ class PlanController extends Controller ]); } + public function show(Request $request, Plan $plan): View + { + $this->ensurePlatformAdmin($request); + + $plan->loadCount(['subscriptions', 'platformOrders']); + + $summaryStats = [ + 'subscriptions_count' => (int) SiteSubscription::query()->where('plan_id', $plan->id)->count(), + 'activated_subscriptions_count' => (int) SiteSubscription::query()->where('plan_id', $plan->id)->where('status', 'activated')->count(), + 'expiring_7d_subscriptions_count' => (int) SiteSubscription::query() + ->where('plan_id', $plan->id) + ->whereNotNull('ends_at') + ->whereBetween('ends_at', [now(), now()->copy()->addDays(7)]) + ->count(), + 'platform_orders_count' => (int) PlatformOrder::query()->where('plan_id', $plan->id)->count(), + 'paid_orders_count' => (int) PlatformOrder::query()->where('plan_id', $plan->id)->where('payment_status', 'paid')->count(), + 'paid_amount_total' => (float) (PlatformOrder::query()->where('plan_id', $plan->id)->where('payment_status', 'paid')->sum('paid_amount') ?? 0), + 'paid_no_receipt_orders_count' => (int) PlatformOrder::query() + ->where('plan_id', $plan->id) + ->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(), + 'sync_failed_orders_count' => (int) PlatformOrder::query() + ->where('plan_id', $plan->id) + ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL") + ->count(), + 'renewal_missing_subscription_orders_count' => (int) PlatformOrder::query() + ->where('plan_id', $plan->id) + ->where('order_type', 'renewal') + ->whereNull('site_subscription_id') + ->count(), + ]; + + $recentOrders = PlatformOrder::query() + ->with(['merchant', 'siteSubscription']) + ->where('plan_id', $plan->id) + ->orderByDesc('id') + ->limit(10) + ->get(); + + $recentSubscriptions = SiteSubscription::query() + ->with('merchant') + ->where('plan_id', $plan->id) + ->orderByDesc('id') + ->limit(10) + ->get(); + + return view('admin.plans.show', [ + 'plan' => $plan, + 'summaryStats' => $summaryStats, + 'recentOrders' => $recentOrders, + 'recentSubscriptions' => $recentSubscriptions, + 'statusLabels' => $this->statusLabels(), + 'billingCycleLabels' => $this->billingCycleLabels(), + ]); + } + public function store(Request $request): RedirectResponse { $this->ensurePlatformAdmin($request); diff --git a/resources/views/admin/plans/index.blade.php b/resources/views/admin/plans/index.blade.php index 7f64e09..6df2e45 100644 --- a/resources/views/admin/plans/index.blade.php +++ b/resources/views/admin/plans/index.blade.php @@ -257,6 +257,7 @@ @php + $showPlanUrl = \App\Support\BackUrl::withBack('/admin/plans/' . $plan->id, $selfWithoutBack); $editPlanUrl = \App\Support\BackUrl::withBack('/admin/plans/' . $plan->id . '/edit', $selfWithoutBack); $createOrderUrl = \App\Support\BackUrl::withBack('/admin/platform-orders/create?' . \Illuminate\Support\Arr::query([ 'plan_id' => $plan->id, @@ -275,6 +276,7 @@ ]); @endphp
+ 查看详情 编辑 @if((string) ($plan->status ?? '') === 'active') 创建订单 diff --git a/resources/views/admin/plans/show.blade.php b/resources/views/admin/plans/show.blade.php new file mode 100644 index 0000000..e276fc4 --- /dev/null +++ b/resources/views/admin/plans/show.blade.php @@ -0,0 +1,215 @@ +@extends('admin.layouts.app') + +@section('title', '套餐详情') +@section('page_title', '套餐详情') + +@section('content') +@php + $planShowSelf = \App\Support\BackUrl::selfWithoutBack(); + + $incomingBack = (string) request()->query('back', ''); + $safeBackForLinks = \App\Support\BackUrl::sanitizeForLinks($incomingBack); + + $makeSubscriptionUrl = function (array $query) use ($planShowSelf) { + return \App\Support\BackUrl::withBack('/admin/site-subscriptions?' . \Illuminate\Support\Arr::query($query), $planShowSelf); + }; + + $makePlatformOrderUrl = function (array $query) use ($planShowSelf) { + return \App\Support\BackUrl::withBack('/admin/platform-orders?' . \Illuminate\Support\Arr::query($query), $planShowSelf); + }; + + $editPlanUrl = \App\Support\BackUrl::withBack('/admin/plans/' . $plan->id . '/edit', $planShowSelf); + $createOrderUrl = \App\Support\BackUrl::withBack('/admin/platform-orders/create?' . \Illuminate\Support\Arr::query([ + 'plan_id' => $plan->id, + 'order_type' => 'new_purchase', + ]), $planShowSelf); +@endphp + + + +
+ + + + +
+

已支付订单

+ +
已付总额:¥{{ number_format((float) ($summaryStats['paid_amount_total'] ?? 0), 2) }}
+
+ + + +
+ +
+

套餐信息

+ + + + + + + + + + + +
ID{{ $plan->id }}
套餐名称{{ $plan->name }}
编码{{ $plan->code }}
状态{{ $statusLabels[$plan->status] ?? $plan->status }} ({{ $plan->status }})
计费周期{{ $billingCycleLabels[$plan->billing_cycle] ?? $plan->billing_cycle }}
售价 / 划线价¥{{ number_format((float) $plan->price, 2) }} / ¥{{ number_format((float) $plan->list_price, 2) }}
发布时间{{ optional($plan->published_at)->format('Y-m-d H:i:s') ?: '-' }}
描述{{ $plan->description ?: '暂无说明' }}
+
+ +
+

治理入口

+ +
+ +
+
+
+

最近平台订单

+

展示最近 10 条,便于从套餐维度快速下钻订单治理。

+
+ 查看全部订单 +
+
+ + + + + + + + + + + + + + @forelse($recentOrders as $order) + + + + + + + + + + @empty + + + + @endforelse + +
订单号站点订单类型订单状态支付状态应付 / 已付关联订阅
{{ $order->order_no }}{{ $order->merchant?->name ?? '未关联站点' }}{{ $order->orderTypeLabel() }}{{ $order->status }}{{ $order->payment_status }}¥{{ number_format((float) $order->payable_amount, 2) }} / ¥{{ number_format((float) $order->paid_amount, 2) }}{{ $order->siteSubscription?->subscription_no ?? '-' }}
暂无平台订单
+
+
+ +
+
+
+

最近订阅

+

展示最近 10 条,便于从套餐维度快速下钻订阅治理。

+
+ 查看全部订阅 +
+
+ + + + + + + + + + + + + @forelse($recentSubscriptions as $subscription) + + + + + + + + + @empty + + + + @endforelse + +
订阅号站点状态金额开始时间到期时间
{{ $subscription->subscription_no }}{{ $subscription->merchant?->name ?? '未关联站点' }}{{ $subscription->status }}¥{{ number_format((float) $subscription->amount, 2) }}{{ optional($subscription->starts_at)->format('Y-m-d H:i:s') ?: '-' }}{{ optional($subscription->ends_at)->format('Y-m-d H:i:s') ?: '-' }}
暂无订阅记录
+
+
+@endsection diff --git a/resources/views/admin/site_subscriptions/show.blade.php b/resources/views/admin/site_subscriptions/show.blade.php index 1ce9366..e09c4d3 100644 --- a/resources/views/admin/site_subscriptions/show.blade.php +++ b/resources/views/admin/site_subscriptions/show.blade.php @@ -260,6 +260,10 @@ / ¥{{ number_format((float) ($summaryStats['total_receipt_amount'] ?? 0), 2) }}
点击订单数可跳转:该订阅下「有回执」订单
+
+ 无回执订单(广义): + {{ $summaryStats['no_receipt_orders'] ?? 0 }} +
已付无回执订单: {{ $summaryStats['no_receipt_orders'] ?? 0 }} diff --git a/routes/web.php b/routes/web.php index f5548e1..c5e2ab8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -149,6 +149,7 @@ Route::prefix('admin')->group(function () { Route::post('/plans', [PlanController::class, 'store']); // 注意:必须放在 /plans/{plan} 之前,避免被参数路由吞掉导致 404 Route::post('/plans/seed-defaults', [PlanController::class, 'seedDefaults']); + Route::get('/plans/{plan}', [PlanController::class, 'show']); Route::get('/plans/{plan}/edit', [PlanController::class, 'edit']); Route::post('/plans/{plan}', [PlanController::class, 'update']); Route::post('/plans/{plan}/set-status', [PlanController::class, 'setStatus']); diff --git a/tests/Feature/AdminPlanShowTest.php b/tests/Feature/AdminPlanShowTest.php new file mode 100644 index 0000000..7f950a6 --- /dev/null +++ b/tests/Feature/AdminPlanShowTest.php @@ -0,0 +1,182 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_open_plan_show_page(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + + $plan = Plan::query()->create([ + 'code' => 'plan_show_test', + 'name' => '套餐详情测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 99, + 'list_price' => 199, + 'status' => 'active', + 'sort' => 10, + 'description' => '用于验证套餐详情页', + 'published_at' => now(), + ]); + + $subscription = SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'activated', + 'source' => 'manual', + 'subscription_no' => 'SUB_PLAN_SHOW_0001', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'amount' => 99, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDays(5), + 'activated_at' => now()->subDay(), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'site_subscription_id' => $subscription->id, + 'order_no' => 'PO_PLAN_SHOW_0001', + 'order_type' => 'renewal', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'list_amount' => 99, + 'discount_amount' => 0, + 'payable_amount' => 99, + 'paid_amount' => 99, + 'placed_at' => now()->subHour(), + 'paid_at' => now()->subMinutes(50), + 'activated_at' => now()->subMinutes(40), + 'meta' => [], + ]); + + $res = $this->get('/admin/plans/' . $plan->id); + $res->assertOk() + ->assertSee('套餐详情') + ->assertSee('套餐详情测试套餐') + ->assertSee('查看已付无回执订单') + ->assertSee('查看续费缺订阅订单'); + } + + public function test_guest_cannot_open_plan_show_page(): void + { + $plan = Plan::query()->create([ + 'code' => 'plan_show_guest_test', + 'name' => '游客不可见套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + ]); + + $this->get('/admin/plans/' . $plan->id)->assertRedirect('/admin/login'); + } + + public function test_plan_show_links_to_subscriptions_and_orders_should_contain_back_to_plan_show(): void + { + $this->loginAsPlatformAdmin(); + + $plan = Plan::query()->create([ + 'code' => 'plan_show_back_test', + 'name' => '套餐详情 back 测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 88, + 'list_price' => 108, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $res = $this->get('/admin/plans/' . $plan->id); + $res->assertOk(); + + $back = '/admin/plans/' . $plan->id; + + $expectedSubscriptionUrl = '/admin/site-subscriptions?' . Arr::query([ + 'plan_id' => $plan->id, + 'back' => $back, + ]); + + $expectedOrderUrl = '/admin/platform-orders?' . Arr::query([ + 'plan_id' => $plan->id, + 'back' => $back, + ]); + + $expectedPaidNoReceiptUrl = '/admin/platform-orders?' . Arr::query([ + 'plan_id' => $plan->id, + 'payment_status' => 'paid', + 'receipt_status' => 'none', + 'back' => $back, + ]); + + $expectedRenewalMissingUrl = '/admin/platform-orders?' . Arr::query([ + 'plan_id' => $plan->id, + 'renewal_missing_subscription' => '1', + 'back' => $back, + ]); + + $res->assertSee($expectedSubscriptionUrl, false); + $res->assertSee($expectedOrderUrl, false); + $res->assertSee($expectedPaidNoReceiptUrl, false); + $res->assertSee($expectedRenewalMissingUrl, false); + } + + public function test_plan_index_show_link_should_carry_back_to_index_self_without_back(): void + { + $this->loginAsPlatformAdmin(); + + $plan = Plan::query()->create([ + 'code' => 'plan_index_show_link_test', + 'name' => '套餐列表详情入口测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 18, + 'list_price' => 18, + 'status' => 'active', + 'sort' => 10, + ]); + + $res = $this->get('/admin/plans?status=active&back=' . urlencode('/admin/platform-orders')); + $res->assertOk(); + + $expectedBack = '/admin/plans?' . Arr::query([ + 'status' => 'active', + ]); + + $expectedShowUrl = '/admin/plans/' . $plan->id . '?' . Arr::query([ + 'back' => $expectedBack, + ]); + + $res->assertSee($expectedShowUrl, false); + $res->assertSee('查看详情'); + } +} diff --git a/tests/Feature/AdminSiteSubscriptionShowBroadNoReceiptOrdersLinkShouldUseSubscriptionShowSelfBackTest.php b/tests/Feature/AdminSiteSubscriptionShowBroadNoReceiptOrdersLinkShouldUseSubscriptionShowSelfBackTest.php new file mode 100644 index 0000000..3c7464f --- /dev/null +++ b/tests/Feature/AdminSiteSubscriptionShowBroadNoReceiptOrdersLinkShouldUseSubscriptionShowSelfBackTest.php @@ -0,0 +1,74 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_broad_no_receipt_orders_link_should_use_subscription_show_self_as_back(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'sub_show_broad_no_receipt_self_back_plan', + 'name' => '订阅详情广义无回执回跳自环测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 66, + 'list_price' => 66, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $subscription = SiteSubscription::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'status' => 'active', + 'source' => 'manual', + 'subscription_no' => 'SS_SHOW_BNR_SELF_BACK_0001', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'amount' => 66, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + ]); + + $res = $this->get('/admin/site-subscriptions/' . $subscription->id . '?back=' . urlencode('/admin/platform-orders?payment_status=paid')); + $res->assertOk(); + + $html = (string) $res->getContent(); + $matched = preg_match('/无回执订单(广义):\s*]+href="([^"]+)"/u', $html, $m); + $this->assertSame(1, $matched, '未找到订阅详情页“无回执订单(广义)”链接'); + + $href = html_entity_decode($m[1] ?? ''); + $expectedUrl = '/admin/platform-orders?' . Arr::query([ + 'site_subscription_id' => $subscription->id, + 'receipt_status' => 'none', + 'back' => '/admin/site-subscriptions/' . $subscription->id, + ]); + + $this->assertSame($expectedUrl, $href); + $this->assertStringNotContainsString('payment_status=paid', $href); + $this->assertStringNotContainsString(urlencode('/admin/platform-orders?payment_status=paid'), $href); + } +}