ensurePlatformAdmin($request); $subscription->loadMissing(['merchant', 'plan']); $baseOrdersQuery = PlatformOrder::query() ->where('site_subscription_id', $subscription->id); // 可治理摘要:订阅下的订单同步情况(基于全量关联订单,不受页面筛选影响) $summaryStats = [ 'total_orders' => (clone $baseOrdersQuery)->count(), 'synced_orders' => (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL") ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL") ->count(), 'failed_orders' => (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL") ->count(), 'unsynced_orders' => (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL") ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL") ->count(), 'syncable_orders' => (clone $baseOrdersQuery) ->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") ->count(), ]; // 可治理摘要:订阅维度的回执/退款汇总(口径与平台订单列表一致:优先 summary,缺省回退 receipts) $metaOrders = (clone $baseOrdersQuery)->get(['id', 'paid_amount', 'payment_status', 'meta']); $totalReceiptAmount = 0.0; $receiptOrders = 0; $noReceiptOrders = 0; $totalRefundedAmount = 0.0; $refundOrders = 0; $noRefundOrders = 0; // 订阅维度:退款不一致订单数(与平台订单列表 refund_inconsistent 口径保持一致) $refundInconsistentOrders = 0; // 订阅维度:对账不一致订单数(与平台订单列表 reconcile_mismatch 口径保持一致) $reconcileMismatchOrders = 0; foreach ($metaOrders as $o) { $meta = $o->meta ?? []; $receiptTotal = (float) $o->receiptTotal(); if ($receiptTotal > 0) { $receiptOrders++; $totalReceiptAmount += $receiptTotal; } else { $noReceiptOrders++; } if ($o->isReconcileMismatch()) { $reconcileMismatchOrders++; } $refundTotal = (float) $o->refundTotal(); if ($refundTotal > 0) { $refundOrders++; $totalRefundedAmount += $refundTotal; } else { $noRefundOrders++; } if ($o->isRefundInconsistent()) { $refundInconsistentOrders++; } } // 订阅维度:BMPA(批量标记支付并生效)失败订单数(与平台订单列表 bmpa_failed_only 口径一致) $bmpaFailedOrders = (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') IS NOT NULL") ->count(); $summaryStats = $summaryStats + [ 'receipt_orders' => $receiptOrders, 'no_receipt_orders' => $noReceiptOrders, 'total_receipt_amount' => (float) $totalReceiptAmount, 'refund_orders' => $refundOrders, 'no_refund_orders' => $noRefundOrders, 'total_refunded_amount' => (float) $totalRefundedAmount, // 退款不一致订单(订阅维度) 'refund_inconsistent_orders' => (int) $refundInconsistentOrders, // 对账不一致订单(订阅维度) 'reconcile_mismatch_orders' => (int) $reconcileMismatchOrders, // BMPA 失败订单(订阅维度) 'bmpa_failed_orders' => (int) $bmpaFailedOrders, // 对账差额:回执总额 - 已付总额(订阅维度) 'reconciliation_delta' => (float) ($totalReceiptAmount - (float) $metaOrders->sum('paid_amount')), ]; // 同步失败原因聚合(Top3):订阅维度快速判断“常见失败原因” $failedReasonRows = (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL") ->selectRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') as reason, count(*) as cnt") ->groupBy('reason') ->orderByDesc('cnt') ->limit(3) ->get(); $failedReasonStats = $failedReasonRows->map(function ($row) { $reason = (string) ($row->reason ?? ''); $reason = trim($reason, "\" "); return [ 'reason' => $reason !== '' ? $reason : '(空)', 'count' => (int) ($row->cnt ?? 0), ]; })->values()->all(); // BMPA 失败原因聚合(Top3):订阅维度快速判断“常见批量标记支付失败原因” $bmpaFailedReasonRows = (clone $baseOrdersQuery) ->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') IS NOT NULL") ->selectRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') as reason, count(*) as cnt") ->groupBy('reason') ->orderByDesc('cnt') ->limit(3) ->get(); $bmpaFailedReasonStats = $bmpaFailedReasonRows->map(function ($row) { $reason = (string) ($row->reason ?? ''); $reason = trim($reason, "\" "); return [ 'reason' => $reason !== '' ? $reason : '(空)', 'count' => (int) ($row->cnt ?? 0), ]; })->values()->all(); // 页面列表筛选:仅影响“关联平台订单”列表展示,不影响摘要统计 $orderSyncStatus = trim((string) $request->query('order_sync_status', '')); $displayOrdersQuery = (clone $baseOrdersQuery); if ($orderSyncStatus === 'synced') { $displayOrdersQuery ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL") ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL"); } elseif ($orderSyncStatus === 'failed') { $displayOrdersQuery ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL"); } elseif ($orderSyncStatus === 'unsynced') { $displayOrdersQuery ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL") ->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL"); } elseif ($orderSyncStatus === 'syncable') { // 口径对齐平台订单页:可同步 = 已支付 + 已生效 + 未同步 + 非失败 $displayOrdersQuery ->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"); } $platformOrders = $displayOrdersQuery ->latest('id') ->paginate(10) ->withQueryString(); // 可治理摘要:订阅下的订单同步情况 $summaryStats = $summaryStats + [ 'current_order_sync_status' => $orderSyncStatus, ]; $endsAt = $subscription->ends_at; $expiryLabel = '无到期'; if ($endsAt) { if ($endsAt->lt(now())) { $expiryLabel = '已过期'; } elseif ($endsAt->lt(now()->addDays(7))) { $expiryLabel = '7天内到期'; } else { $expiryLabel = '未到期'; } } return view('admin.site_subscriptions.show', [ 'subscription' => $subscription, 'platformOrders' => $platformOrders, 'summaryStats' => $summaryStats, 'failedReasonStats' => $failedReasonStats, 'bmpaFailedReasonStats' => $bmpaFailedReasonStats, 'statusLabels' => $this->statusLabels(), 'expiryLabel' => $expiryLabel, ]); } public function export(Request $request): StreamedResponse { $this->ensurePlatformAdmin($request); // 安全阀:必须显式声明 download=1,避免浏览器预取/误触发导致频繁导出 if ((string) $request->query('download', '') !== '1') { abort(400, 'download=1 required'); } $filters = [ 'status' => trim((string) $request->query('status', '')), 'keyword' => trim((string) $request->query('keyword', '')), 'merchant_id' => trim((string) $request->query('merchant_id', '')), 'plan_id' => trim((string) $request->query('plan_id', '')), 'expiry' => trim((string) $request->query('expiry', '')), ]; $query = $this->applyFilters( SiteSubscription::query()->with(['merchant', 'plan'])->withCount('platformOrders'), $filters )->orderBy('id'); $filename = 'site_subscriptions_' . now()->format('Ymd_His') . '.csv'; return response()->streamDownload(function () use ($query) { $out = fopen('php://output', 'w'); // UTF-8 BOM,避免 Excel 打开中文乱码 fwrite($out, "\xEF\xBB\xBF"); fputcsv($out, [ 'ID', '订阅号', '站点', '套餐', '关联订单数', '状态', '计费周期', '周期(月)', '金额', '开始时间', '到期时间', '到期状态', '试用到期', '生效时间', '取消时间', ]); $statusLabels = $this->statusLabels(); $query->chunkById(500, function ($subs) use ($out, $statusLabels) { foreach ($subs as $sub) { $endsAt = $sub->ends_at; $expiryLabel = '无到期'; if ($endsAt) { if ($endsAt->lt(now())) { $expiryLabel = '已过期'; } elseif ($endsAt->lt(now()->addDays(7))) { $expiryLabel = '7天内到期'; } else { $expiryLabel = '未到期'; } } $status = (string) ($sub->status ?? ''); $statusText = ($statusLabels[$status] ?? $status); $statusText = $statusText . ' (' . $status . ')'; fputcsv($out, [ $sub->id, $sub->subscription_no, $sub->merchant?->name ?? '', $sub->plan_name ?: ($sub->plan?->name ?? ''), (int) ($sub->platform_orders_count ?? 0), $statusText, $sub->billing_cycle ?: '', (int) $sub->period_months, (float) $sub->amount, optional($sub->starts_at)->format('Y-m-d H:i:s') ?: '', optional($sub->ends_at)->format('Y-m-d H:i:s') ?: '', $expiryLabel, optional($sub->trial_ends_at)->format('Y-m-d H:i:s') ?: '', optional($sub->activated_at)->format('Y-m-d H:i:s') ?: '', optional($sub->cancelled_at)->format('Y-m-d H:i:s') ?: '', ]); } }); fclose($out); }, $filename, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function index(Request $request): View { $this->ensurePlatformAdmin($request); $filters = [ 'status' => trim((string) $request->query('status', '')), 'keyword' => trim((string) $request->query('keyword', '')), 'merchant_id' => trim((string) $request->query('merchant_id', '')), 'plan_id' => trim((string) $request->query('plan_id', '')), // 到期辅助筛选(不改变 status 字段,仅按 ends_at 计算) // - expired:已过期(ends_at < now) // - expiring_7d:7 天内到期(now <= ends_at < now+7d) 'expiry' => trim((string) $request->query('expiry', '')), ]; $query = $this->applyFilters( SiteSubscription::query()->with(['merchant', 'plan'])->withCount('platformOrders'), $filters ); $subscriptions = (clone $query) ->latest('id') ->paginate(10) ->withQueryString(); $baseQuery = $this->applyFilters(SiteSubscription::query(), $filters); $expiryMerchantRows = []; if ((string) ($filters['expiry'] ?? '') === 'expiring_7d') { // 到期提醒清单:站点维度 Top10(用于运营快速触达/续费跟进) $expiryMerchantRows = $this->applyFilters(SiteSubscription::query(), $filters) ->leftJoin('merchants', 'site_subscriptions.merchant_id', '=', 'merchants.id') ->whereNotNull('site_subscriptions.ends_at') ->selectRaw('site_subscriptions.merchant_id as merchant_id, merchants.name as merchant_name, count(*) as cnt, min(site_subscriptions.ends_at) as min_ends_at') ->groupBy('site_subscriptions.merchant_id', 'merchants.name') ->orderByDesc('cnt') ->orderBy('min_ends_at') ->limit(10) ->get() ->map(function ($row) { return [ 'merchant_id' => (int) ($row->merchant_id ?? 0), 'merchant_name' => (string) ($row->merchant_name ?? ''), 'count' => (int) ($row->cnt ?? 0), 'min_ends_at' => (string) ($row->min_ends_at ?? ''), ]; }) ->values() ->all(); } return view('admin.site_subscriptions.index', [ 'subscriptions' => $subscriptions, 'filters' => $filters, 'statusLabels' => $this->statusLabels(), 'filterOptions' => [ 'statuses' => $this->statusLabels(), ], 'merchants' => SiteSubscription::query()->with('merchant')->select('merchant_id')->distinct()->get()->pluck('merchant')->filter()->unique('id')->values(), 'plans' => SiteSubscription::query()->with('plan')->select('plan_id')->whereNotNull('plan_id')->distinct()->get()->pluck('plan')->filter()->unique('id')->values(), 'summaryStats' => [ 'total_subscriptions' => (clone $baseQuery)->count(), 'activated_subscriptions' => (clone $baseQuery)->where('status', 'activated')->count(), 'pending_subscriptions' => (clone $baseQuery)->where('status', 'pending')->count(), 'cancelled_subscriptions' => (clone $baseQuery)->where('status', 'cancelled')->count(), // 可治理辅助指标:按 ends_at 计算 'expired_subscriptions' => (clone $baseQuery) ->whereNotNull('ends_at') ->where('ends_at', '<', now()) ->count(), 'expiring_7d_subscriptions' => (clone $baseQuery) ->whereNotNull('ends_at') ->where('ends_at', '>=', now()) ->where('ends_at', '<', now()->addDays(7)) ->count(), ], 'expiryMerchantRows' => $expiryMerchantRows, ]); } protected function statusLabels(): array { return [ 'pending' => '待生效', 'activated' => '已生效', 'cancelled' => '已取消', 'expired' => '已过期', ]; } public function setStatus(Request $request, SiteSubscription $subscription): \Illuminate\Http\RedirectResponse { $this->ensurePlatformAdmin($request); $data = $request->validate([ 'status' => ['required', \Illuminate\Validation\Rule::in(array_keys($this->statusLabels()))], ]); $subscription->status = (string) $data['status']; $subscription->save(); return redirect()->back()->with('success', '订阅状态已更新:' . ($this->statusLabels()[$subscription->status] ?? $subscription->status)); } public function batchMarkExpired(Request $request): \Illuminate\Http\RedirectResponse { $this->ensurePlatformAdmin($request); // 仅支持在“已过期(expiry=expired)集合”上执行,避免误把正常订阅批量标记为已过期。 $filters = [ 'status' => trim((string) $request->input('status', '')), 'keyword' => trim((string) $request->input('keyword', '')), 'merchant_id' => trim((string) $request->input('merchant_id', '')), 'plan_id' => trim((string) $request->input('plan_id', '')), 'expiry' => trim((string) $request->input('expiry', '')), ]; if ((string) ($filters['expiry'] ?? '') !== 'expired') { return redirect()->back()->with('warning', '为避免误操作:批量标记已过期仅允许在「已过期(expiry=expired)」集合视图下执行。'); } // 防误操作:需要二次确认 if ((string) $request->input('confirm', '') !== 'YES') { return redirect()->back()->with('warning', '为避免误操作,请在确认框输入 YES 后再批量标记已过期。'); } $query = $this->applyFilters(SiteSubscription::query(), $filters); // 再加一道硬条件:ends_at 必须 < now(与 expiry=expired 一致) $query->whereNotNull('ends_at')->where('ends_at', '<', now()); // 仅把“非已过期”的订阅更新为 expired $affected = (clone $query) ->where('status', '!=', 'expired') ->update([ 'status' => 'expired', ]); return redirect()->back()->with('success', '已批量标记已过期:' . (int) $affected . ' 条。'); } protected function applyFilters(Builder $query, array $filters): Builder { return $query ->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) ->when(($filters['merchant_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('merchant_id', (int) $filters['merchant_id'])) ->when(($filters['plan_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('plan_id', (int) $filters['plan_id'])) ->when(($filters['expiry'] ?? '') !== '', function (Builder $builder) use ($filters) { $expiry = (string) ($filters['expiry'] ?? ''); if ($expiry === 'expired') { $builder->whereNotNull('ends_at')->where('ends_at', '<', now()); } elseif ($expiry === 'expiring_7d') { $builder->whereNotNull('ends_at') ->where('ends_at', '>=', now()) ->where('ends_at', '<', now()->addDays(7)); } }) ->when($filters['keyword'] !== '', function (Builder $builder) use ($filters) { // 关键词搜索:订阅号 / 站点 / 套餐 / 计费周期 $keyword = trim((string) ($filters['keyword'] ?? '')); if ($keyword === '') { return; } $builder->where(function (Builder $q) use ($keyword) { $q->where('subscription_no', 'like', '%' . $keyword . '%') ->orWhere('plan_name', 'like', '%' . $keyword . '%') ->orWhere('billing_cycle', 'like', '%' . $keyword . '%') ->orWhereHas('merchant', function (Builder $mq) use ($keyword) { $mq->where('name', 'like', '%' . $keyword . '%') ->orWhere('slug', 'like', '%' . $keyword . '%'); }) ->orWhereHas('plan', function (Builder $pq) use ($keyword) { $pq->where('name', 'like', '%' . $keyword . '%') ->orWhere('code', 'like', '%' . $keyword . '%'); }); if (ctype_digit($keyword)) { $q->orWhere('id', (int) $keyword); } }); }); } }