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

1566 lines
75 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\Merchant;
use App\Models\Plan;
use App\Models\PlatformOrder;
use App\Models\SiteSubscription;
use App\Support\SubscriptionActivationService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PlatformOrderController extends Controller
{
use ResolvesPlatformAdminContext;
public function create(Request $request): View
{
$this->ensurePlatformAdmin($request);
$merchants = Merchant::query()->orderBy('id')->get(['id', 'name']);
$plans = Plan::query()->orderBy('sort')->orderByDesc('id')->get();
// 支持从其它页面(例如订阅详情)带默认值跳转过来,提高运营效率
$defaults = [
'merchant_id' => (int) $request->query('merchant_id', 0),
'plan_id' => (int) $request->query('plan_id', 0),
'site_subscription_id' => (int) $request->query('site_subscription_id', 0),
'order_type' => (string) $request->query('order_type', 'new_purchase'),
'quantity' => (int) $request->query('quantity', 1),
'discount_amount' => (float) $request->query('discount_amount', 0),
'payment_channel' => (string) $request->query('payment_channel', ''),
'remark' => (string) $request->query('remark', ''),
];
$siteSubscription = null;
$siteSubscriptionId = (int) ($defaults['site_subscription_id'] ?? 0);
if ($siteSubscriptionId > 0) {
$siteSubscription = SiteSubscription::query()->with(['merchant', 'plan'])->find($siteSubscriptionId);
}
return view('admin.platform_orders.form', [
'merchants' => $merchants,
'plans' => $plans,
'siteSubscription' => $siteSubscription,
'billingCycleLabels' => $this->billingCycleLabels(),
'orderTypeLabels' => $this->orderTypeLabels(),
'defaults' => $defaults,
]);
}
public function store(Request $request): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'merchant_id' => ['required', 'integer', 'exists:merchants,id'],
'plan_id' => ['required', 'integer', 'exists:plans,id'],
'site_subscription_id' => ['nullable', 'integer', 'exists:site_subscriptions,id'],
'order_type' => ['required', Rule::in(array_keys($this->orderTypeLabels()))],
'quantity' => ['required', 'integer', 'min:1', 'max:120'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
'payment_channel' => ['nullable', 'string', 'max:30'],
'remark' => ['nullable', 'string', 'max:2000'],
]);
$plan = Plan::query()->findOrFail((int) $data['plan_id']);
$periodMonths = $this->periodMonthsFromBillingCycle((string) $plan->billing_cycle);
$quantity = (int) $data['quantity'];
$listAmount = (float) $plan->price * $quantity;
$discount = (float) ($data['discount_amount'] ?? 0);
$discount = max(0, min($listAmount, $discount));
$payable = max(0, $listAmount - $discount);
$now = now();
// 订单号PO + 时间 + 4位随机数足够用于当前阶段演示与手工补单
$orderNo = 'PO' . $now->format('YmdHis') . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$order = PlatformOrder::query()->create([
'merchant_id' => (int) $data['merchant_id'],
'plan_id' => $plan->id,
'site_subscription_id' => (int) ($data['site_subscription_id'] ?? 0) ?: null,
'created_by_admin_id' => $admin->id,
'order_no' => $orderNo,
'order_type' => (string) $data['order_type'],
'status' => 'pending',
'payment_status' => 'unpaid',
'payment_channel' => $data['payment_channel'] ?? null,
'plan_name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'period_months' => $periodMonths,
'quantity' => $quantity,
'list_amount' => $listAmount,
'discount_amount' => $discount,
'payable_amount' => $payable,
'paid_amount' => 0,
'placed_at' => $now,
'plan_snapshot' => [
'plan_id' => $plan->id,
'code' => $plan->code,
'name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'price' => (float) $plan->price,
'list_price' => (float) $plan->list_price,
'status' => $plan->status,
'published_at' => optional($plan->published_at)->toDateTimeString(),
],
'meta' => [
'created_from' => 'manual_form',
],
'remark' => $data['remark'] ?? null,
]);
return redirect('/admin/platform-orders/' . $order->id)
->with('success', '平台订单已创建:' . $order->order_no . '(待支付/待生效)');
}
public function index(Request $request): View
{
$this->ensurePlatformAdmin($request);
$filters = [
'status' => trim((string) $request->query('status', '')),
'payment_status' => trim((string) $request->query('payment_status', '')),
'merchant_id' => trim((string) $request->query('merchant_id', '')),
'plan_id' => trim((string) $request->query('plan_id', '')),
// 精确过滤订阅ID用于从订阅详情页跳转到平台订单列表时锁定范围
'site_subscription_id' => trim((string) $request->query('site_subscription_id', '')),
'fail_only' => (string) $request->query('fail_only', ''),
'synced_only' => (string) $request->query('synced_only', ''),
'sync_status' => trim((string) $request->query('sync_status', '')),
'keyword' => trim((string) $request->query('keyword', '')),
// 同步失败原因关键词:用于快速定位同原因失败订单(可治理)
'sync_error_keyword' => trim((string) $request->query('sync_error_keyword', '')),
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
'syncable_only' => (string) $request->query('syncable_only', ''),
// 只看最近 24 小时批量同步过的订单(可治理追踪)
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
// 只看最近 24 小时批量“仅标记为已生效”过的订单(可治理追踪)
'batch_mark_activated_24h' => (string) $request->query('batch_mark_activated_24h', ''),
// 只看“对账不一致”的订单粗版meta.payment_summary.total_amount 与 paid_amount 不一致
'reconcile_mismatch' => (string) $request->query('reconcile_mismatch', ''),
// 支付回执筛选has有回执/none无回执
'receipt_status' => trim((string) $request->query('receipt_status', '')),
// 退款轨迹筛选has有退款/none无退款
'refund_status' => trim((string) $request->query('refund_status', '')),
// 退款数据不一致(可治理):基于 refund_summary.total_amount 与 paid_amount 对比
'refund_inconsistent' => (string) $request->query('refund_inconsistent', ''),
];
$orders = $this->applyFilters(PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']), $filters)
->latest('id')
->paginate(10)
->withQueryString();
// 列表行级对账视图:回执总额 / 差额(便于运营快速定位问题订单)
$orders->getCollection()->transform(function (PlatformOrder $o) {
$receiptTotal = (float) $this->receiptTotalForOrder($o);
$o->setAttribute('receipt_total', $receiptTotal);
$o->setAttribute('reconciliation_delta_row', $receiptTotal - (float) $o->paid_amount);
return $o;
});
$baseQuery = $this->applyFilters(PlatformOrder::query(), $filters);
// 同步失败原因聚合Top 5用于运营快速判断“常见失败原因”
// 注意:这里用 JSON_EXTRACT 做 group byMySQL 会返回带引号的 JSON 字符串,展示时做一次 trim 处理。
$failedReasonRows = (clone $baseQuery)
->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(5)
->get();
$failedReasonStats = $failedReasonRows->map(function ($row) {
$reason = (string) ($row->reason ?? '');
$reason = trim($reason, "\" ");
return [
'reason' => $reason !== '' ? $reason : '(空)',
'count' => (int) ($row->cnt ?? 0),
];
})->values()->all();
// 运营摘要中的 meta 汇总refund/payment需要遍历订单 meta。
// 为避免重复 get(),在当前筛选范围内一次性拉取所需字段。
$metaOrders = (clone $baseQuery)->get(['id', 'paid_amount', 'meta']);
$totalRefundedAmount = (float) $metaOrders->sum(function ($o) {
// 优先读 meta.refund_summary.total_amount回退汇总 meta.refund_receipts[].amount
$total = (float) (data_get($o->meta, 'refund_summary.total_amount') ?? 0);
if ($total > 0) {
return $total;
}
$refunds = (array) (data_get($o->meta, 'refund_receipts', []) ?? []);
$sum = 0.0;
foreach ($refunds as $r) {
$sum += (float) (data_get($r, 'amount') ?? 0);
}
return $sum;
});
$totalReceiptAmount = (float) $this->sumReceiptAmount($metaOrders);
$totalPayableAmount = (float) ((clone $baseQuery)->sum('payable_amount') ?: 0);
$totalPaidAmount = (float) ((clone $baseQuery)->sum('paid_amount') ?: 0);
return view('admin.platform_orders.index', [
'orders' => $orders,
'filters' => $filters,
'filterOptions' => [
'statuses' => $this->statusLabels(),
'paymentStatuses' => $this->paymentStatusLabels(),
],
'merchants' => PlatformOrder::query()->with('merchant')
->select('merchant_id')
->whereNotNull('merchant_id')
->distinct()
->get()
->pluck('merchant')
->filter()
->unique('id')
->values(),
'plans' => PlatformOrder::query()->with('plan')
->select('plan_id')
->whereNotNull('plan_id')
->distinct()
->get()
->pluck('plan')
->filter()
->unique('id')
->values(),
'statusLabels' => $this->statusLabels(),
'paymentStatusLabels' => $this->paymentStatusLabels(),
'summaryStats' => [
'total_orders' => (clone $baseQuery)->count(),
'paid_orders' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
'activated_orders' => (clone $baseQuery)->where('status', 'activated')->count(),
'synced_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL")
->count(),
'failed_sync_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL")
->count(),
'unsynced_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
->count(),
'total_payable_amount' => $totalPayableAmount,
'total_paid_amount' => $totalPaidAmount,
'syncable_orders' => (clone $baseQuery)
->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->count(),
'batch_synced_24h_orders' => (function () use ($baseQuery) {
$since = now()->subHours(24)->format('Y-m-d H:i:s');
$q = (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') IS NOT NULL");
$driver = $q->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$q->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') >= ?", [$since]);
} else {
$q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_activation.at')) >= ?", [$since]);
}
return $q->count();
})(),
'batch_mark_activated_24h_orders' => (function () use ($baseQuery) {
$since = now()->subHours(24)->format('Y-m-d H:i:s');
$q = (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_activated.at') IS NOT NULL");
$driver = $q->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$q->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_activated.at') >= ?", [$since]);
} else {
$q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_mark_activated.at')) >= ?", [$since]);
}
return $q->count();
})(),
'partially_refunded_orders' => (clone $baseQuery)->where('payment_status', 'partially_refunded')->count(),
'refunded_orders' => (clone $baseQuery)->where('payment_status', 'refunded')->count(),
'total_refunded_amount' => $totalRefundedAmount,
'receipt_orders' => (clone $baseQuery)
->where(function (Builder $q) {
$q->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NOT NULL")
->orWhereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NOT NULL");
})
->count(),
'no_receipt_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NULL")
->count(),
'refund_orders' => (clone $baseQuery)
->where(function (Builder $q) {
$q->whereRaw("JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NOT NULL")
->orWhereRaw("JSON_EXTRACT(meta, '$.refund_receipts[0].amount') IS NOT NULL");
})
->count(),
'no_refund_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.refund_receipts[0].amount') IS NULL")
->count(),
'total_receipt_amount' => $totalReceiptAmount,
// 对账差额:回执总额 - 订单已付总额(当前筛选范围)
'reconciliation_delta' => (float) ($totalReceiptAmount - $totalPaidAmount),
'reconciliation_delta_note' => '回执总额 - 订单已付总额',
// 对账不一致订单数(在当前筛选范围基础上叠加 mismatch 口径)
'reconcile_mismatch_orders' => (function () use ($filters) {
$mismatchFilters = $filters;
$mismatchFilters['reconcile_mismatch'] = '1';
return $this->applyFilters(PlatformOrder::query(), $mismatchFilters)->count();
})(),
// 退款数据不一致订单数(在当前筛选范围基础上叠加 inconsistent 口径)
'refund_inconsistent_orders' => (function () use ($filters) {
$f = $filters;
$f['refund_inconsistent'] = '1';
return $this->applyFilters(PlatformOrder::query(), $f)->count();
})(),
],
'failedReasonStats' => $failedReasonStats,
]);
}
public function show(Request $request, PlatformOrder $order): View
{
$this->ensurePlatformAdmin($request);
$order->loadMissing(['merchant', 'plan', 'siteSubscription']);
return view('admin.platform_orders.show', [
'order' => $order,
'statusLabels' => $this->statusLabels(),
'paymentStatusLabels' => $this->paymentStatusLabels(),
]);
}
public function activateSubscription(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
try {
$subscription = $service->activateOrder($order->id, $admin->id);
// 同步成功:清理失败记录(若存在)+ 写入审计记录
$meta = (array) ($order->meta ?? []);
data_forget($meta, 'subscription_activation_error');
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'activate_subscription',
'scope' => 'single',
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
'subscription_id' => $subscription->id,
'note' => '手动点击订单详情【同步订阅】',
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
} catch (\Throwable $e) {
// 同步失败:写入错误信息,便于运营排查(可治理)
$meta = (array) ($order->meta ?? []);
data_set($meta, 'subscription_activation_error', [
'message' => $e->getMessage(),
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
]);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('error', '订阅同步失败:' . $e->getMessage());
}
return redirect()->back()->with('success', '订阅已同步:' . $subscription->subscription_no);
}
public function markPaidAndActivate(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 最小状态推进:将订单标记为已支付 + 已生效,并补齐时间与金额字段
$now = now();
$order->payment_status = 'paid';
$order->status = 'activated';
$order->paid_at = $order->paid_at ?: $now;
$order->activated_at = $order->activated_at ?: $now;
$order->paid_amount = $order->paid_amount > 0 ? $order->paid_amount : $order->payable_amount;
// 兼容:若尚未写入“支付回执”,则自动补一条(可治理)
$meta = (array) ($order->meta ?? []);
$receipts = (array) (data_get($meta, 'payment_receipts', []) ?? []);
if (count($receipts) === 0) {
$receipts[] = [
'type' => 'manual_mark_paid',
'channel' => (string) ($order->payment_channel ?? ''),
'amount' => (float) $order->paid_amount,
'paid_at' => $order->paid_at ? $order->paid_at->format('Y-m-d H:i:s') : $now->toDateTimeString(),
'note' => '由【标记支付并生效】自动补记(可治理)',
'created_at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
];
data_set($meta, 'payment_receipts', $receipts);
// 扁平统计:避免在列表/汇总处频繁遍历支付回执数组(可治理)
$totalPaid = 0.0;
foreach ($receipts as $r) {
$totalPaid += (float) (data_get($r, 'amount') ?? 0);
}
$latest = count($receipts) > 0 ? end($receipts) : null;
data_set($meta, 'payment_summary', [
'count' => count($receipts),
'total_amount' => $totalPaid,
'last_at' => (string) (data_get($latest, 'paid_at') ?? ''),
'last_amount' => (float) (data_get($latest, 'amount') ?? 0),
'last_channel' => (string) (data_get($latest, 'channel') ?? ''),
]);
$order->meta = $meta;
}
$order->save();
// 立刻同步订阅
try {
$subscription = $service->activateOrder($order->id, $admin->id);
$meta = (array) ($order->meta ?? []);
data_forget($meta, 'subscription_activation_error');
$order->meta = $meta;
$order->save();
} catch (\Throwable $e) {
$meta = (array) ($order->meta ?? []);
data_set($meta, 'subscription_activation_error', [
'message' => $e->getMessage(),
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
]);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('error', '订单已标记为已支付/已生效,但订阅同步失败:' . $e->getMessage());
}
return redirect()->back()->with('success', '订单已标记支付并生效,订阅已同步:' . $subscription->subscription_no);
}
public function addPaymentReceipt(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'type' => ['required', 'string', 'max:30'],
'channel' => ['nullable', 'string', 'max:30'],
'amount' => ['required', 'numeric', 'min:0'],
'paid_at' => ['nullable', 'date'],
'note' => ['nullable', 'string', 'max:2000'],
]);
$now = now();
$meta = (array) ($order->meta ?? []);
$receipts = (array) (data_get($meta, 'payment_receipts', []) ?? []);
$receipts[] = [
'type' => (string) $data['type'],
'channel' => (string) ($data['channel'] ?? ''),
'amount' => (float) $data['amount'],
'paid_at' => $data['paid_at'] ? (string) $data['paid_at'] : null,
'note' => (string) ($data['note'] ?? ''),
'created_at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
];
data_set($meta, 'payment_receipts', $receipts);
// 扁平统计:避免在列表/汇总处频繁遍历支付回执数组(可治理)
$totalPaid = 0.0;
foreach ($receipts as $r) {
$totalPaid += (float) (data_get($r, 'amount') ?? 0);
}
$latest = count($receipts) > 0 ? end($receipts) : null;
data_set($meta, 'payment_summary', [
'count' => count($receipts),
'total_amount' => $totalPaid,
'last_at' => (string) (data_get($latest, 'paid_at') ?? ''),
'last_amount' => (float) (data_get($latest, 'amount') ?? 0),
'last_channel' => (string) (data_get($latest, 'channel') ?? ''),
]);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已追加支付回执记录(仅用于对账留痕,不自动改状态)。');
}
public function addRefundReceipt(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'type' => ['required', 'string', 'max:30'],
'channel' => ['nullable', 'string', 'max:30'],
'amount' => ['required', 'numeric', 'min:0'],
'refunded_at' => ['nullable', 'date'],
'note' => ['nullable', 'string', 'max:2000'],
]);
$now = now();
$meta = (array) ($order->meta ?? []);
$refunds = (array) (data_get($meta, 'refund_receipts', []) ?? []);
$refunds[] = [
'type' => (string) $data['type'],
'channel' => (string) ($data['channel'] ?? ''),
'amount' => (float) $data['amount'],
'refunded_at' => $data['refunded_at'] ? (string) $data['refunded_at'] : null,
'note' => (string) ($data['note'] ?? ''),
'created_at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
];
data_set($meta, 'refund_receipts', $refunds);
// 扁平统计:避免在列表/汇总处频繁遍历退款数组(可治理)
$totalRefunded = 0.0;
foreach ($refunds as $r) {
$totalRefunded += (float) (data_get($r, 'amount') ?? 0);
}
$latestRefund = count($refunds) > 0 ? end($refunds) : null;
data_set($meta, 'refund_summary', [
'count' => count($refunds),
'total_amount' => $totalRefunded,
'last_at' => (string) (data_get($latestRefund, 'refunded_at') ?? ''),
'last_amount' => (float) (data_get($latestRefund, 'amount') ?? 0),
'last_channel' => (string) (data_get($latestRefund, 'channel') ?? ''),
]);
// 可治理辅助:自动推进退款标记(仅当退款金额>0 时)
// 注意:允许从 paid / partially_refunded 推进到 partially_refunded / refunded
// 且不会把已 refunded 的订单降级。
if ((float) $data['amount'] > 0 && in_array($order->payment_status, ['paid', 'partially_refunded'], true)) {
$paidAmount = (float) ($order->paid_amount ?? 0);
// 退款总额 >= 已付金额 => 视为已退款;否则视为部分退款
if ($paidAmount > 0 && $totalRefunded >= $paidAmount) {
$order->payment_status = 'refunded';
$order->refunded_at = $order->refunded_at ?: now();
} else {
$order->payment_status = 'partially_refunded';
$order->refunded_at = $order->refunded_at ?: now();
}
}
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已追加退款记录(用于退款轨迹留痕)。');
}
public function markRefunded(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$paidAmount = (float) ($order->paid_amount ?? 0);
if ($paidAmount <= 0) {
return redirect()->back()->with('warning', '当前订单已付金额为 0无法标记为已退款。');
}
if ((string) $order->payment_status === 'refunded') {
return redirect()->back()->with('warning', '当前订单已是已退款状态,无需重复操作。');
}
// 安全阀:仅允许在“退款总额已达到/超过已付金额”时标记为已退款
$refundTotal = (float) $this->refundTotalForOrder($order);
if (round($refundTotal * 100) + 1 < round($paidAmount * 100)) {
return redirect()->back()->with('warning', '退款总额尚未达到已付金额,无法标记为已退款。请先核对/补齐退款记录。');
}
$now = now();
$order->payment_status = 'refunded';
$order->refunded_at = $order->refunded_at ?: $now;
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'mark_refunded',
'scope' => 'single',
'at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
'note' => '手动标记为已退款(仅修正支付状态,不自动写退款回执)',
'snapshot' => [
'paid_amount' => $paidAmount,
'refund_total' => $refundTotal,
],
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已将订单支付状态标记为已退款(未自动写入退款回执)。');
}
public function markPartiallyRefunded(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$paidAmount = (float) ($order->paid_amount ?? 0);
if ($paidAmount <= 0) {
return redirect()->back()->with('warning', '当前订单已付金额为 0无法标记为部分退款。');
}
if ((string) $order->payment_status === 'partially_refunded') {
return redirect()->back()->with('warning', '当前订单已是部分退款状态,无需重复操作。');
}
// 安全阀:部分退款需要“退款总额>0 且未达到已付金额”
$refundTotal = (float) $this->refundTotalForOrder($order);
if (round($refundTotal * 100) <= 0) {
return redirect()->back()->with('warning', '退款总额为 0无法标记为部分退款。');
}
if (round($refundTotal * 100) + 1 >= round($paidAmount * 100)) {
return redirect()->back()->with('warning', '退款总额已达到/超过已付金额,建议标记为已退款。');
}
$now = now();
$order->payment_status = 'partially_refunded';
$order->refunded_at = $order->refunded_at ?: $now;
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'mark_partially_refunded',
'scope' => 'single',
'at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
'note' => '手动标记为部分退款(仅修正支付状态,不自动写退款回执)',
'snapshot' => [
'paid_amount' => $paidAmount,
'refund_total' => $refundTotal,
],
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已将订单支付状态标记为部分退款(未自动写入退款回执)。');
}
public function markPaidStatus(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$paidAmount = (float) ($order->paid_amount ?? 0);
if ($paidAmount <= 0) {
return redirect()->back()->with('warning', '当前订单已付金额为 0无法标记为已支付。');
}
if ((string) $order->payment_status === 'unpaid') {
return redirect()->back()->with('warning', '当前订单为未支付状态,不允许直接标记为已支付,请使用「标记支付并生效」或补回执/金额后再处理。');
}
if ((string) $order->payment_status === 'paid') {
return redirect()->back()->with('warning', '当前订单已是已支付状态,无需重复操作。');
}
$now = now();
$order->payment_status = 'paid';
// paid 状态不强依赖 refunded_at这里不做清空避免丢历史痕迹
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'mark_paid_status',
'scope' => 'single',
'at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
'note' => '手动标记为已支付(仅修正支付状态,不自动写回执/退款回执)',
'snapshot' => [
'paid_amount' => $paidAmount,
'refund_total' => (float) $this->refundTotalForOrder($order),
],
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已将订单支付状态标记为已支付(未自动写入回执/退款回执)。');
}
public function markActivated(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 仅标记“已生效”:用于处理已支付但未生效的订单(不改 payment_status
if ($order->payment_status !== 'paid') {
return redirect()->back()->with('warning', '当前订单尚未支付,无法仅标记为已生效。');
}
if ($order->status === 'activated') {
return redirect()->back()->with('warning', '当前订单已是已生效状态,无需重复操作。');
}
$now = now();
$order->status = 'activated';
$order->activated_at = $order->activated_at ?: $now;
$order->save();
// 轻量审计:记录这次“仅标记生效”的动作,便于追溯
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'mark_activated',
'scope' => 'single',
'at' => $now->toDateTimeString(),
'admin_id' => $admin->id,
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '订单已标记为已生效(未修改支付状态)。');
}
public function export(Request $request): StreamedResponse
{
$this->ensurePlatformAdmin($request);
$filters = [
'status' => trim((string) $request->query('status', '')),
'payment_status' => trim((string) $request->query('payment_status', '')),
'merchant_id' => trim((string) $request->query('merchant_id', '')),
'plan_id' => trim((string) $request->query('plan_id', '')),
// 精确过滤订阅ID用于从订阅详情页跳转到平台订单列表时锁定范围
'site_subscription_id' => trim((string) $request->query('site_subscription_id', '')),
'fail_only' => (string) $request->query('fail_only', ''),
'synced_only' => (string) $request->query('synced_only', ''),
'sync_status' => trim((string) $request->query('sync_status', '')),
'keyword' => trim((string) $request->query('keyword', '')),
// 同步失败原因关键词:用于快速定位同原因失败订单(可治理)
'sync_error_keyword' => trim((string) $request->query('sync_error_keyword', '')),
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
'syncable_only' => (string) $request->query('syncable_only', ''),
// 只看最近 24 小时批量同步过的订单(可治理追踪)
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
// 只看最近 24 小时批量“仅标记为已生效”过的订单(可治理追踪)
'batch_mark_activated_24h' => (string) $request->query('batch_mark_activated_24h', ''),
// 只看“对账不一致”的订单粗版meta.payment_summary.total_amount 与 paid_amount 不一致
'reconcile_mismatch' => (string) $request->query('reconcile_mismatch', ''),
// 支付回执筛选has有回执/none无回执
'receipt_status' => trim((string) $request->query('receipt_status', '')),
// 退款轨迹筛选has有退款/none无退款
'refund_status' => trim((string) $request->query('refund_status', '')),
// 退款数据不一致(可治理):基于 refund_summary.total_amount 与 paid_amount 对比
'refund_inconsistent' => (string) $request->query('refund_inconsistent', ''),
];
$includeMeta = (string) $request->query('include_meta', '') === '1';
$query = $this->applyFilters(
PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']),
$filters
)->orderBy('id');
$filename = 'platform_orders_' . now()->format('Ymd_His') . '.csv';
return response()->streamDownload(function () use ($query, $includeMeta) {
$out = fopen('php://output', 'w');
// UTF-8 BOM避免 Excel 打开中文乱码
fwrite($out, "\xEF\xBB\xBF");
$headers = [
'ID',
'订单号',
'站点',
'套餐',
'订单类型',
'订单状态',
'支付状态',
'应付金额',
'已付金额',
'下单时间',
'支付时间',
'生效时间',
'同步状态',
'订阅ID',
'订阅号',
'订阅到期',
'同步时间',
'同步失败原因',
'同步失败时间',
'最近批量生效时间',
'最近批量生效管理员',
'支付回执数',
'最近回执时间',
'最近回执金额',
'最近回执渠道',
'退款记录数',
'最近退款时间',
'最近退款金额',
'最近退款渠道',
'退款总额',
'回执总额',
'对账差额',
];
if ($includeMeta) {
$headers[] = '原始meta(JSON)';
}
fputcsv($out, $headers);
$query->chunkById(500, function ($orders) use ($out, $includeMeta) {
foreach ($orders as $order) {
$syncedId = (int) data_get($order->meta, 'subscription_activation.subscription_id', 0);
$syncErr = (string) (data_get($order->meta, 'subscription_activation_error.message') ?? '');
if ($syncedId > 0) {
$syncStatus = '已同步';
} elseif ($syncErr !== '') {
$syncStatus = '同步失败';
} else {
$syncStatus = '未同步';
}
$receipts = (array) (data_get($order->meta, 'payment_receipts', []) ?? []);
$receiptSummaryCount = data_get($order->meta, 'payment_summary.count');
$receiptCount = $receiptSummaryCount !== null ? (int) $receiptSummaryCount : count($receipts);
$latestReceipt = count($receipts) > 0 ? end($receipts) : null;
$refunds = (array) (data_get($order->meta, 'refund_receipts', []) ?? []);
$refundSummaryCount = data_get($order->meta, 'refund_summary.count');
$refundCount = $refundSummaryCount !== null ? (int) $refundSummaryCount : count($refunds);
$latestRefund = count($refunds) > 0 ? end($refunds) : null;
$row = [
$order->id,
$order->order_no,
$order->merchant?->name ?? '',
$order->plan_name ?: ($order->plan?->name ?? ''),
$order->order_type,
$order->status,
$order->payment_status,
(float) $order->payable_amount,
(float) $order->paid_amount,
optional($order->placed_at)->format('Y-m-d H:i:s') ?: '',
optional($order->paid_at)->format('Y-m-d H:i:s') ?: '',
optional($order->activated_at)->format('Y-m-d H:i:s') ?: '',
$syncStatus,
(int) ($order->site_subscription_id ?? 0),
$order->siteSubscription?->subscription_no ?: '',
optional($order->siteSubscription?->ends_at)->format('Y-m-d H:i:s') ?: '',
(string) (data_get($order->meta, 'subscription_activation.synced_at') ?? ''),
$syncErr,
(string) (data_get($order->meta, 'subscription_activation_error.at') ?? ''),
(string) (data_get($order->meta, 'batch_mark_activated.at') ?? ''),
(string) (data_get($order->meta, 'batch_mark_activated.admin_id') ?? ''),
$receiptCount,
(string) (data_get($latestReceipt, 'paid_at') ?? ''),
(float) (data_get($latestReceipt, 'amount') ?? 0),
(string) (data_get($latestReceipt, 'channel') ?? ''),
$refundCount,
(string) (data_get($latestRefund, 'refunded_at') ?? ''),
(float) (data_get($latestRefund, 'amount') ?? 0),
(string) (data_get($latestRefund, 'channel') ?? ''),
(function () use ($order, $refunds) {
$summaryTotal = data_get($order->meta, 'refund_summary.total_amount');
if ($summaryTotal !== null) {
return (float) $summaryTotal;
}
$total = 0.0;
foreach ($refunds as $r) {
$total += (float) (data_get($r, 'amount') ?? 0);
}
return $total;
})(),
(function () use ($order, $receipts) {
// 回执总额:优先读 payment_summary.total_amount缺省回退遍历 payment_receipts[].amount
$t = (float) (data_get($order->meta, 'payment_summary.total_amount') ?? 0);
if ($t > 0) {
return $t;
}
$sum = 0.0;
foreach ($receipts as $r) {
$sum += (float) (data_get($r, 'amount') ?? 0);
}
return $sum;
})(),
(function () use ($order, $receipts) {
$receiptTotal = (float) (data_get($order->meta, 'payment_summary.total_amount') ?? 0);
if ($receiptTotal <= 0) {
foreach ($receipts as $r) {
$receiptTotal += (float) (data_get($r, 'amount') ?? 0);
}
}
return (float) $receiptTotal - (float) ($order->paid_amount ?? 0);
})(),
];
if ($includeMeta) {
$row[] = json_encode($order->meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
fputcsv($out, $row);
}
});
fclose($out);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function batchActivateSubscriptions(Request $request, SubscriptionActivationService $service): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 支持两种 scope
// - scope=filtered只处理当前筛选范围内的订单更安全默认
// - scope=all处理全部订单谨慎
$scope = (string) $request->input('scope', 'filtered');
$filters = [
'status' => trim((string) $request->input('status', '')),
'payment_status' => trim((string) $request->input('payment_status', '')),
'merchant_id' => trim((string) $request->input('merchant_id', '')),
'plan_id' => trim((string) $request->input('plan_id', '')),
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
'fail_only' => (string) $request->input('fail_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''),
'reconcile_mismatch' => (string) $request->input('reconcile_mismatch', ''),
'receipt_status' => trim((string) $request->input('receipt_status', '')),
'refund_status' => trim((string) $request->input('refund_status', '')),
'refund_inconsistent' => (string) $request->input('refund_inconsistent', ''),
];
// 防误操作:批量同步默认要求先勾选“只看可同步”,避免无意识扩大处理范围
if ($scope === 'filtered' && ($filters['syncable_only'] ?? '') !== '1') {
return redirect()->back()->with('warning', '为避免误操作,请先在筛选条件中勾选「只看可同步」,再执行批量同步订阅。');
}
// 防误操作scope=all 需要二次确认
if ($scope === 'all' && (string) $request->input('confirm', '') !== 'YES') {
return redirect()->back()->with('warning', '为避免误操作,执行全量批量同步前请在确认框输入 YES。');
}
$query = PlatformOrder::query();
if ($scope === 'filtered') {
$query = $this->applyFilters($query, $filters);
}
// 只处理“可同步”的订单(双保险,避免误操作)
$query = $query
->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL");
$limit = (int) $request->input('limit', 50);
$limit = max(1, min(500, $limit));
$matchedTotal = (clone $query)->count();
// 默认按最新订单优先处理:避免 seed/demo 数据干扰测试,同时也更符合“先处理新问题”的运营直觉
$orders = $query->orderByDesc('id')->limit($limit)->get(['id']);
$processed = $orders->count();
$success = 0;
$failed = 0;
$failedReasonCounts = [];
// 筛选摘要:用于审计记录(避免每条订单都手写拼接,且便于追溯本次批量处理口径)
$filterSummaryParts = [];
foreach ($filters as $k => $v) {
if ((string) $v !== '') {
$filterSummaryParts[] = $k . '=' . (string) $v;
}
}
$filterSummary = implode('&', $filterSummaryParts);
foreach ($orders as $orderRow) {
try {
$subscription = $service->activateOrder($orderRow->id, $admin->id);
// 轻量审计:记录批量同步动作(方便追溯)
$order = PlatformOrder::query()->find($orderRow->id);
if ($order) {
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$nowStr = now()->toDateTimeString();
$audit[] = [
'action' => 'batch_activate_subscription',
'scope' => $scope,
'at' => $nowStr,
'admin_id' => $admin->id,
'subscription_id' => $subscription->id,
'filters' => $filterSummary,
'note' => '批量同步订阅limit=' . $limit . ', matched=' . $matchedTotal . ', processed=' . $processed . ')',
];
data_set($meta, 'audit', $audit);
// 便于筛选/统计:记录最近一次批量同步信息(扁平字段)
data_set($meta, 'batch_activation', [
'at' => $nowStr,
'admin_id' => $admin->id,
'scope' => $scope,
]);
$order->meta = $meta;
$order->save();
}
$success++;
} catch (\Throwable $e) {
$failed++;
$reason = trim((string) $e->getMessage());
$reason = $reason !== '' ? $reason : '未知错误';
$failedReasonCounts[$reason] = ($failedReasonCounts[$reason] ?? 0) + 1;
// 批量同步失败也需要可治理:写入失败原因到订单 meta便于后续筛选/导出/清理
$order = PlatformOrder::query()->find($orderRow->id);
if ($order) {
$meta = (array) ($order->meta ?? []);
data_set($meta, 'subscription_activation_error', [
'message' => $reason,
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
]);
$order->meta = $meta;
$order->save();
}
}
}
$msg = '批量同步订阅完成:成功 ' . $success . ' 条,失败 ' . $failed . ' 条(命中 ' . $matchedTotal . ' 条,本次处理 ' . $processed . ' 条limit=' . $limit . '';
if ($failed > 0 && count($failedReasonCounts) > 0) {
arsort($failedReasonCounts);
$top = array_slice($failedReasonCounts, 0, 3, true);
$topText = collect($top)->map(function ($cnt, $reason) {
$reason = mb_substr((string) $reason, 0, 60);
return $reason . '' . $cnt . '';
})->implode('');
$msg .= '失败原因Top' . $topText;
}
return redirect()->back()->with('success', $msg);
}
public function batchMarkActivated(Request $request): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 支持两种 scope
// - scope=filtered只处理当前筛选范围内的订单更安全默认
// - scope=all处理全部订单谨慎
$scope = (string) $request->input('scope', 'filtered');
$filters = [
'status' => trim((string) $request->input('status', '')),
'payment_status' => trim((string) $request->input('payment_status', '')),
'merchant_id' => trim((string) $request->input('merchant_id', '')),
'plan_id' => trim((string) $request->input('plan_id', '')),
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
'fail_only' => (string) $request->input('fail_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''),
'reconcile_mismatch' => (string) $request->input('reconcile_mismatch', ''),
'receipt_status' => trim((string) $request->input('receipt_status', '')),
'refund_status' => trim((string) $request->input('refund_status', '')),
'refund_inconsistent' => (string) $request->input('refund_inconsistent', ''),
];
// 防误操作:批量“仅标记为已生效”默认要求当前筛选口径为「已支付 + 待处理(pending)」
if ($scope === 'filtered') {
if (($filters['payment_status'] ?? '') !== 'paid' || ($filters['status'] ?? '') !== 'pending') {
return redirect()->back()->with('warning', '为避免误操作,请先筛选「支付状态=已支付」且「订单状态=待处理」,再执行批量仅标记为已生效。');
}
}
// 防误操作scope=all 需要二次确认
if ($scope === 'all' && (string) $request->input('confirm', '') !== 'YES') {
return redirect()->back()->with('warning', '为避免误操作,执行全量批量生效前请在确认框输入 YES。');
}
$query = PlatformOrder::query();
if ($scope === 'filtered') {
$query = $this->applyFilters($query, $filters);
}
// 只处理“已支付 + 待处理”的订单(双保险)
$query = $query
->where('payment_status', 'paid')
->where('status', 'pending');
$limit = (int) $request->input('limit', 50);
$limit = max(1, min(500, $limit));
$matchedTotal = (clone $query)->count();
$orders = $query->orderByDesc('id')->limit($limit)->get(['id']);
$processed = $orders->count();
$success = 0;
$nowStr = now()->toDateTimeString();
// 筛选摘要:用于审计记录(便于追溯本次批量处理口径)
$filterSummaryParts = [];
foreach ($filters as $k => $v) {
if ((string) $v !== '') {
$filterSummaryParts[] = $k . '=' . (string) $v;
}
}
$filterSummary = implode('&', $filterSummaryParts);
foreach ($orders as $row) {
$order = PlatformOrder::query()->find($row->id);
if (! $order) {
continue;
}
// 再次防御:仅推进 pending
if ($order->payment_status !== 'paid' || $order->status !== 'pending') {
continue;
}
$order->status = 'activated';
$order->activated_at = $order->activated_at ?: now();
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'batch_mark_activated',
'scope' => $scope,
'at' => $nowStr,
'admin_id' => $admin->id,
'filters' => $filterSummary,
'note' => '批量仅标记为已生效limit=' . $limit . ', matched=' . $matchedTotal . ', processed=' . $processed . ')',
];
data_set($meta, 'audit', $audit);
// 便于筛选/统计:记录最近一次批量生效信息(扁平字段)
data_set($meta, 'batch_mark_activated', [
'at' => $nowStr,
'admin_id' => $admin->id,
'scope' => $scope,
]);
$order->meta = $meta;
$order->save();
$success++;
}
$msg = '批量仅标记为已生效完成:成功 ' . $success . ' 条(命中 ' . $matchedTotal . ' 条,本次处理 ' . $processed . ' 条limit=' . $limit . '';
return redirect()->back()->with('success', $msg);
}
public function clearSyncErrors(Request $request): RedirectResponse
{
$this->ensurePlatformAdmin($request);
// 支持两种模式:
// - scope=all默认清理所有订单的失败标记需要 confirm=YES
// - scope=filtered仅清理当前筛选结果命中的订单更安全
$scope = (string) $request->input('scope', 'all');
// 防误操作scope=all 需要二次确认
if ($scope === 'all' && (string) $request->input('confirm', '') !== 'YES') {
return redirect()->back()->with('warning', '为避免误操作,清除全部失败标记前请在确认框输入 YES。');
}
$filters = [
'status' => trim((string) $request->input('status', '')),
'payment_status' => trim((string) $request->input('payment_status', '')),
'merchant_id' => trim((string) $request->input('merchant_id', '')),
'plan_id' => trim((string) $request->input('plan_id', '')),
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
'fail_only' => (string) $request->input('fail_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''),
'reconcile_mismatch' => (string) $request->input('reconcile_mismatch', ''),
'receipt_status' => trim((string) $request->input('receipt_status', '')),
'refund_status' => trim((string) $request->input('refund_status', '')),
'refund_inconsistent' => (string) $request->input('refund_inconsistent', ''),
];
$query = PlatformOrder::query()
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
if ($scope === 'filtered') {
$query = $this->applyFilters($query, $filters);
}
$orders = $query->get(['id', 'meta']);
$matched = $orders->count();
$cleared = 0;
foreach ($orders as $order) {
$meta = (array) ($order->meta ?? []);
if (! data_get($meta, 'subscription_activation_error')) {
continue;
}
data_forget($meta, 'subscription_activation_error');
// 轻量审计:记录清理动作(不做独立表,先落 meta便于排查
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'clear_sync_error',
'scope' => $scope,
'at' => now()->toDateTimeString(),
'admin_id' => $this->platformAdminId($request),
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
$cleared++;
}
$msg = $scope === 'filtered'
? '已清除当前筛选范围内的同步失败标记:'
: '已清除全部订单的同步失败标记:';
return redirect()->back()->with('success', $msg . $cleared . ' 条(命中 ' . $matched . ' 条)');
}
protected function applyFilters(Builder $query, array $filters): Builder
{
return $query
->when($filters['status'] !== '', fn (Builder $builder) => $builder->where('status', $filters['status']))
->when($filters['payment_status'] !== '', fn (Builder $builder) => $builder->where('payment_status', $filters['payment_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['site_subscription_id'] ?? '') !== '', fn (Builder $builder) => $builder->where('site_subscription_id', (int) $filters['site_subscription_id']))
->when(($filters['fail_only'] ?? '') !== '', function (Builder $builder) {
// 只看同步失败meta.subscription_activation_error.message 存在即视为失败
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
})
->when(($filters['synced_only'] ?? '') !== '', function (Builder $builder) {
// 只看已同步meta.subscription_activation.subscription_id 存在即视为已同步
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL");
})
->when(($filters['sync_status'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 同步状态筛选unsynced / synced / failed
if (($filters['sync_status'] ?? '') === 'synced') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NOT NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL");
} elseif (($filters['sync_status'] ?? '') === 'failed') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NOT NULL");
} elseif (($filters['sync_status'] ?? '') === 'unsynced') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL");
}
})
->when(($filters['keyword'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 关键词搜索:订单号 / 站点名称 / 订阅号
$kw = trim((string) ($filters['keyword'] ?? ''));
if ($kw === '') {
return;
}
$builder->where(function (Builder $q) use ($kw) {
$q->where('order_no', 'like', '%' . $kw . '%')
->orWhere('order_type', 'like', '%' . $kw . '%')
->orWhere('plan_name', 'like', '%' . $kw . '%')
->orWhereHas('merchant', function (Builder $mq) use ($kw) {
$mq->where('name', 'like', '%' . $kw . '%')
->orWhere('slug', 'like', '%' . $kw . '%');
})
->orWhereHas('siteSubscription', function (Builder $sq) use ($kw) {
$sq->where('subscription_no', 'like', '%' . $kw . '%');
});
if (ctype_digit($kw)) {
$q->orWhere('id', (int) $kw);
}
});
})
->when(($filters['sync_error_keyword'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 同步失败原因关键词subscription_activation_error.message like
$kw = trim((string) ($filters['sync_error_keyword'] ?? ''));
if ($kw === '') {
return;
}
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') LIKE ?", ['%' . $kw . '%']);
} else {
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.subscription_activation_error.message')) LIKE ?", ['%' . $kw . '%']);
}
})
->when(($filters['syncable_only'] ?? '') !== '', function (Builder $builder) {
// 只看可同步:已支付 + 已生效 + 尚未写入 subscription_activation.subscription_id
$builder->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL");
})
->when(($filters['batch_synced_24h'] ?? '') !== '', function (Builder $builder) {
// 只看最近 24 小时批量同步过的订单(基于 meta.batch_activation.at
$since = now()->subHours(24)->format('Y-m-d H:i:s');
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') IS NOT NULL");
// sqlite 测试库没有 JSON_UNQUOTE(),需要做兼容
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_activation.at') >= ?", [$since]);
} else {
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_activation.at')) >= ?", [$since]);
}
})
->when(($filters['batch_mark_activated_24h'] ?? '') !== '', function (Builder $builder) {
// 只看最近 24 小时批量“仅标记为已生效”过的订单(基于 meta.batch_mark_activated.at
$since = now()->subHours(24)->format('Y-m-d H:i:s');
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_activated.at') IS NOT NULL");
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_activated.at') >= ?", [$since]);
} else {
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_mark_activated.at')) >= ?", [$since]);
}
})
->when(($filters['reconcile_mismatch'] ?? '') !== '', function (Builder $builder) {
// 只看“对账不一致”的订单:支付回执总额 与订单 paid_amount 不一致
// 口径:优先使用 meta.payment_summary.total_amount若为空则回退汇总 meta.payment_receipts[].amount
// 注意:该筛选需要读取 JSON 字段MySQL/SQLite 写法略有差异。
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
// sqlite 下 JSON_EXTRACT 直接返回标量(数值或字符串),这里用“按分”取整避免浮点误差导致 0.01 边界不稳定
// total_cents = (payment_summary.total_amount 存在 ? summary*100 : sum(payment_receipts[].amount)*100)
$builder->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)) >= 1");
} else {
// MySQL 下 JSON_EXTRACT 返回 JSON需要 JSON_UNQUOTE 再 cast同样按分取整避免浮点误差
// total_cents = (payment_summary.total_amount 存在 ? summary*100 : SUM(payment_receipts[].amount)*100)
$builder->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)) >= 1");
}
})
->when(($filters['receipt_status'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 支付回执筛选:
// - has有回执payment_summary.total_amount 存在 或 payment_receipts[0].amount 存在)
// - none无回执两者都不存在
$status = (string) ($filters['receipt_status'] ?? '');
if ($status === 'has') {
$builder->where(function (Builder $q) {
$q->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NOT NULL")
->orWhereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NOT NULL");
});
} elseif ($status === 'none') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.payment_summary.total_amount') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.payment_receipts[0].amount') IS NULL");
}
})
->when(($filters['refund_status'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 退款轨迹筛选:
// - has有退款refund_summary.total_amount 存在 或 refund_receipts[0].amount 存在)
// - none无退款两者都不存在
$status = (string) ($filters['refund_status'] ?? '');
if ($status === 'has') {
$builder->where(function (Builder $q) {
$q->whereRaw("JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NOT NULL")
->orWhereRaw("JSON_EXTRACT(meta, '$.refund_receipts[0].amount') IS NOT NULL");
});
} elseif ($status === 'none') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.refund_receipts[0].amount') IS NULL");
}
})
->when(($filters['refund_inconsistent'] ?? '') !== '', function (Builder $builder) {
// 退款数据不一致(可治理):
// - 状态=refunded 但 退款总额 < 已付金额(允许 0.01 容差)
// - 状态!=refunded 且 已付金额>0 且 退款总额 >= 已付金额
// 退款总额口径:优先 refund_summary.total_amount缺省回退汇总 refund_receipts[].amount
$driver = $builder->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)";
$builder->where(function (Builder $q) use ($refundTotalExpr) {
// refunded 但退款不够
$q->where(function (Builder $q2) use ($refundTotalExpr) {
$q2->where('payment_status', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("(ROUND($refundTotalExpr * 100) + 1) < ROUND(paid_amount * 100)");
})
// 非 refunded 但退款已达到/超过已付
->orWhere(function (Builder $q2) use ($refundTotalExpr) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("ROUND($refundTotalExpr * 100) >= ROUND(paid_amount * 100)");
});
});
} 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)";
$builder->where(function (Builder $q) use ($refundTotalExpr) {
$q->where(function (Builder $q2) use ($refundTotalExpr) {
$q2->where('payment_status', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("(ROUND($refundTotalExpr * 100) + 1) < ROUND(paid_amount * 100)");
})->orWhere(function (Builder $q2) use ($refundTotalExpr) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("ROUND($refundTotalExpr * 100) >= ROUND(paid_amount * 100)");
});
});
}
});
}
private function receiptTotalForOrder(PlatformOrder $order): float
{
// 优先读扁平字段 payment_summary.total_amount更稳定、避免遍历 receipts
$total = (float) (data_get($order->meta, 'payment_summary.total_amount') ?? 0);
if ($total > 0) {
return $total;
}
// 回退:遍历 payment_receipts[].amount
$receipts = (array) (data_get($order->meta, 'payment_receipts', []) ?? []);
$sum = 0.0;
foreach ($receipts as $r) {
$sum += (float) (data_get($r, 'amount') ?? 0);
}
return $sum;
}
private function refundTotalForOrder(PlatformOrder $order): float
{
// 口径统一:集中到模型方法,避免多处复制导致漂移
return (float) $order->refundTotal();
}
protected function sumReceiptAmount($orders): float
{
$total = 0.0;
foreach ($orders as $o) {
$t = (float) (data_get($o->meta, 'payment_summary.total_amount') ?? 0);
if ($t > 0) {
$total += $t;
continue;
}
$receipts = (array) (data_get($o->meta, 'payment_receipts', []) ?? []);
foreach ($receipts as $r) {
$total += (float) (data_get($r, 'amount') ?? 0);
}
}
return $total;
}
protected function statusLabels(): array
{
return [
'pending' => '待处理',
'paid' => '已支付',
'activated' => '已生效',
'cancelled' => '已取消',
'refunded' => '已退款',
];
}
protected function paymentStatusLabels(): array
{
return [
'unpaid' => '未支付',
'paid' => '已支付',
'partially_refunded' => '部分退款',
'refunded' => '已退款',
'failed' => '支付失败',
];
}
protected function orderTypeLabels(): array
{
return [
'new_purchase' => '新购',
'renewal' => '续费',
'upgrade' => '升级',
'downgrade' => '降级',
];
}
protected function billingCycleLabels(): array
{
return [
'monthly' => '月付',
'quarterly' => '季付',
'yearly' => '年付',
'one_time' => '一次性',
];
}
protected function periodMonthsFromBillingCycle(string $billingCycle): int
{
return match ($billingCycle) {
'monthly' => 1,
'quarterly' => 3,
'yearly' => 12,
'one_time' => 1,
default => 1,
};
}
}