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

519 lines
23 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\SiteSubscription;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Models\PlatformOrder;
class SiteSubscriptionController extends Controller
{
use ResolvesPlatformAdminContext;
public function show(Request $request, SiteSubscription $subscription): View
{
$this->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_7d7 天内到期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 = [];
$expiryMerchantPlanRows = [];
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();
// 到期提醒清单:站点+套餐维度 Top10更精确的续费下单入口
$expiryMerchantPlanRows = $this->applyFilters(SiteSubscription::query(), $filters)
->leftJoin('merchants', 'site_subscriptions.merchant_id', '=', 'merchants.id')
->leftJoin('plans', 'site_subscriptions.plan_id', '=', 'plans.id')
->whereNotNull('site_subscriptions.ends_at')
->selectRaw('site_subscriptions.merchant_id as merchant_id, merchants.name as merchant_name, site_subscriptions.plan_id as plan_id, plans.name as plan_name, count(*) as cnt, min(site_subscriptions.ends_at) as min_ends_at')
->groupBy('site_subscriptions.merchant_id', 'merchants.name', 'site_subscriptions.plan_id', 'plans.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 ?? ''),
'plan_id' => (int) ($row->plan_id ?? 0),
'plan_name' => (string) ($row->plan_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,
'expiryMerchantPlanRows' => $expiryMerchantPlanRows,
]);
}
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);
}
});
});
}
}