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

2614 lines
136 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\BackUrl;
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();
// 支持从其它页面(例如订阅详情)带默认值跳转过来,提高运营效率
$orderTypeFromQuery = $request->query('order_type');
// require_subscription用于“续费必须先选订阅”的治理链路。
// - 优先读 query用于各种入口跳转
// - 同时兼容 old input用于提交校验失败后 redirect back仍保持治理 UI 口径)
$requireSubscription = ((string) $request->query('require_subscription', '') === '1')
|| ((string) $request->old('require_subscription', '') === '1');
$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 时默认 new_purchase若是从订阅进入site_subscription_id 存在),稍后会进一步偏向 renewal。
'order_type' => $orderTypeFromQuery === null ? 'new_purchase' : (string) $orderTypeFromQuery,
'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', ''),
// back用于创建成功后回到来源页例如订阅详情
'back' => (string) $request->query('back', ''),
// 线索联动:用于从开通线索进入下单页,需透传到 form hidden input
'lead_id' => (int) $request->query('lead_id', 0),
];
// back 安全阀:必须为站内相对路径,并拒绝引号/尖括号。
// 说明form 页会把 defaults.back 透传到 hidden input 与返回按钮;因此这里提前清洗,避免 unsafe back 在页面中出现。
$incomingBack = (string) ($defaults['back'] ?? '');
$defaults['back'] = BackUrl::sanitizeForLinks($incomingBack);
$siteSubscription = null;
$siteSubscriptionId = (int) ($defaults['site_subscription_id'] ?? 0);
if ($siteSubscriptionId > 0) {
$siteSubscription = SiteSubscription::query()->with(['merchant', 'plan'])->find($siteSubscriptionId);
}
// 治理口径当来源页要求“续费必须绑定订阅”时若未带订阅ID则强制回退为新购避免误导。
if ($requireSubscription && $siteSubscriptionId <= 0) {
$defaults['order_type'] = 'new_purchase';
}
// 续费下单场景:若带了 site_subscription_id则当前阶段强制视为续费单避免语义混乱
if ($siteSubscriptionId > 0) {
$defaults['order_type'] = 'renewal';
}
// 续费下单场景:若带了 site_subscription_id但未显式指定 merchant/plan则从订阅上补齐默认值。
// 目的:让“从订阅维度跳转到下单页”的链路更稳,不必每次手工二次选择。
if ($siteSubscription) {
if ((int) ($defaults['merchant_id'] ?? 0) <= 0) {
$defaults['merchant_id'] = (int) ($siteSubscription->merchant_id ?? 0);
}
if ((int) ($defaults['plan_id'] ?? 0) <= 0) {
$defaults['plan_id'] = (int) ($siteSubscription->plan_id ?? 0);
}
// 治理口径从订阅进入site_subscription_id 存在)时,若未显式指定 order_type则默认偏向续费。
if ($orderTypeFromQuery === null && trim((string) ($defaults['order_type'] ?? '')) === 'new_purchase') {
$defaults['order_type'] = 'renewal';
}
// 续费默认备注:若未显式传 remark自动补齐“来自订阅{subscription_no}”用于追溯/检索。
if ((string) ($defaults['order_type'] ?? '') === 'renewal' && trim((string) ($defaults['remark'] ?? '')) === '') {
$remarkPrefix = (string) config('saasshop.platform_orders.renewal_order_remark_prefix', '来自订阅:');
$defaults['remark'] = $remarkPrefix . (string) $siteSubscription->subscription_no;
}
}
return view('admin.platform_orders.form', [
'merchants' => $merchants,
'plans' => $plans,
'siteSubscription' => $siteSubscription,
'billingCycleLabels' => $this->billingCycleLabels(),
'orderTypeLabels' => $this->orderTypeLabels(),
'requireSubscription' => $requireSubscription,
'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' => [
Rule::requiredIf(fn () => (string) $request->input('order_type') === 'renewal'),
'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'],
'back' => ['nullable', 'string', 'max:2000'],
// 可选:从开通线索进入下单(用于轻量闭环联动)
'lead_id' => ['nullable', 'integer', 'exists:platform_leads,id'],
]);
// 治理口径:续费单必须与订阅上下文一致(站点/套餐)。
// 当前阶段暂不支持“跨站点/跨套餐续费”,避免误续费/串单风险;升级/降级后续再单独建设。
if ((string) ($data['order_type'] ?? '') === 'renewal') {
$subId = (int) ($data['site_subscription_id'] ?? 0);
$sub = $subId > 0 ? SiteSubscription::query()->find($subId) : null;
if (! $sub) {
return redirect()->back()->withErrors([
'site_subscription_id' => '续费单必须绑定有效订阅。',
])->withInput();
}
if ((int) ($data['merchant_id'] ?? 0) !== (int) ($sub->merchant_id ?? 0)) {
return redirect()->back()->withErrors([
'merchant_id' => '续费单站点必须与订阅所属站点一致(请从订阅维度进入下单)。',
])->withInput();
}
if ((int) ($data['plan_id'] ?? 0) !== (int) ($sub->plan_id ?? 0)) {
return redirect()->back()->withErrors([
'plan_id' => '续费单套餐必须与订阅当前套餐一致(当前阶段暂不支持跨套餐续费)。',
])->withInput();
}
}
$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',
// 轻量联动:从开通线索创建订单时记录 lead_id用于后续转化漏斗与追溯
'platform_lead_id' => (int) ($data['lead_id'] ?? 0) ?: null,
],
'remark' => $data['remark'] ?? null,
]);
// 轻量闭环:从线索创建订单后,自动把线索标记为 converted。
// 注意:只做“前进”流转,避免误把已关闭/已确认需求的线索回写。
$leadId = (int) ($data['lead_id'] ?? 0);
if ($leadId > 0) {
$lead = \App\Models\PlatformLead::query()->find($leadId);
if ($lead && !in_array((string) $lead->status, ['converted', 'closed'], true)) {
$lead->status = 'converted';
$lead->save();
}
}
$back = (string) ($data['back'] ?? '');
// back 安全护栏:统一收敛到 BackUrl::sanitizeForLinks避免口径漂移。
$safeBack = BackUrl::sanitizeForLinks($back);
$redirectUrl = '/admin/platform-orders/' . $order->id;
if ($safeBack !== '') {
$redirectUrl .= '?' . \Illuminate\Support\Arr::query(['back' => $safeBack]);
}
return redirect($redirectUrl)
->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', ''),
// 只看批量“标记支付并生效”失败meta.batch_mark_paid_and_activate_error.message 存在
'bmpa_failed_only' => (string) $request->query('bmpa_failed_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', '')),
// 批量“标记支付并生效”失败原因关键词:用于定位同原因失败订单(可治理)
'bmpa_error_keyword' => trim((string) $request->query('bmpa_error_keyword', '')),
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
'syncable_only' => (string) $request->query('syncable_only', ''),
// 只看“续费但未绑定订阅”的脏数据(可治理)
'renewal_missing_subscription' => (string) $request->query('renewal_missing_subscription', ''),
// 只看最近 24 小时批量同步过的订单(可治理追踪)
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
// 只看最近 24 小时批量“标记支付并生效(BMPA)”过的订单(可治理追踪)
'batch_mark_paid_and_activate_24h' => (string) $request->query('batch_mark_paid_and_activate_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', ''),
// 线索联动:用于从开通线索跳转查看关联订单
'lead_id' => trim((string) $request->query('lead_id', '')),
// 创建时间范围(用于“趋势→集合”跳转与运营筛选)
'created_from' => trim((string) $request->query('created_from', '')),
'created_to' => trim((string) $request->query('created_to', '')),
];
$orders = $this->applyFilters(PlatformOrder::query()->with(['merchant', 'plan', 'siteSubscription']), $filters)
->latest('id')
->paginate(10)
->withQueryString();
// 当前列表若锁定了订阅ID则加载该订阅用于“上下文提示/快捷续费下单”等运营入口
$currentSubscription = null;
$currentSubscriptionId = (int) ($filters['site_subscription_id'] ?? 0);
if ($currentSubscriptionId > 0) {
$currentSubscription = SiteSubscription::query()->with(['merchant', 'plan'])->find($currentSubscriptionId);
}
// 列表行级对账视图:回执总额 / 差额(便于运营快速定位问题订单)
$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 处理。
$topN = (int) config('saasshop.platform_orders.sync_failed_reason_top_n', 5);
$topN = max(1, min(20, $topN));
$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($topN)
->get();
// 批量“标记支付并生效”失败原因聚合Top 5
$bmpaFailedReasonRows = (clone $baseQuery)
->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($topN)
->get();
$failedReasonStats = $failedReasonRows->map(function ($row) {
$reason = (string) ($row->reason ?? '');
$reason = trim($reason, "\" ");
return [
'reason' => $reason !== '' ? $reason : '(空)',
'count' => (int) ($row->cnt ?? 0),
];
})->values()->all();
$bmpaFailedReasonStats = $bmpaFailedReasonRows->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', [
'currentSubscription' => $currentSubscription,
'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(),
// order_type label 映射已下沉到 PlatformOrder::orderTypeLabel();这里不再透传 orderTypeLabels
// 'orderTypeLabels' => $this->orderTypeLabels(),
'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(),
'bmpa_failed_orders' => (clone $baseQuery)
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_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")
// 口径一致syncable_only=1 隐含 sync_status=unsynced排除同步失败等异常单
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
// 口径一致:排除(续费但未绑定订阅)的脏数据
->where(function (\Illuminate\Database\Eloquent\Builder $q) {
$q->where('order_type', '!=', 'renewal')
->orWhereNotNull('site_subscription_id');
})
->count(),
'renewal_missing_subscription_orders' => (clone $baseQuery)
->where('order_type', 'renewal')
->whereNull('site_subscription_id')
->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_paid_and_activate_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_paid_and_activate.at') IS NOT NULL");
$driver = $q->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$q->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.at') >= ?", [$since]);
} else {
$q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.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,
'bmpaFailedReasonStats' => $bmpaFailedReasonStats,
]);
}
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(),
// order_type label 映射已下沉到 PlatformOrder::orderTypeLabel();这里不再透传 orderTypeLabels
// 'orderTypeLabels' => $this->orderTypeLabels(),
]);
}
/**
* 导出单笔订单的“对账明细”(支付回执 + 退款记录CSV。
* 用于运营线下对账沟通/留档,不依赖订单列表的筛选口径。
*/
public function exportLedger(Request $request, PlatformOrder $order): StreamedResponse
{
$this->ensurePlatformAdmin($request);
// 安全阀:必须显式声明 download=1避免浏览器预取/误触发导致频繁导出
if ((string) $request->query('download', '') !== '1') {
abort(400, 'download=1 required');
}
$order->loadMissing(['merchant', 'plan', 'siteSubscription']);
$paymentReceipts = (array) (data_get($order->meta, 'payment_receipts', []) ?? []);
$refundReceipts = (array) (data_get($order->meta, 'refund_receipts', []) ?? []);
$includeOrderSnapshot = (string) $request->query('include_order_snapshot', '') === '1';
$filename = 'platform_order_' . $order->id . '_ledger_' . now()->format('Ymd_His') . '.csv';
return response()->streamDownload(function () use ($order, $paymentReceipts, $refundReceipts, $includeOrderSnapshot) {
$out = fopen('php://output', 'w');
// UTF-8 BOM避免 Excel 打开中文乱码
fwrite($out, "\xEF\xBB\xBF");
// 订单摘要(基础三行)
fputcsv($out, ['order_id', (string) $order->id]);
fputcsv($out, ['order_no', (string) $order->order_no]);
fputcsv($out, ['exported_at', now()->format('Y-m-d H:i:s')]);
// 可选:导出更多订单快照字段(便于财务/对账留档,不必另截图)
if ($includeOrderSnapshot) {
fputcsv($out, ['merchant_id', (string) ($order->merchant_id ?? '')]);
fputcsv($out, ['merchant_name', (string) ($order->merchant?->name ?? '')]);
fputcsv($out, ['plan_id', (string) ($order->plan_id ?? '')]);
fputcsv($out, ['plan_name', (string) ($order->plan_name ?? ($order->plan?->name ?? ''))]);
fputcsv($out, ['order_type', (string) ($order->order_type ?? '')]);
fputcsv($out, ['status', (string) ($order->status ?? '')]);
fputcsv($out, ['payment_status', (string) ($order->payment_status ?? '')]);
fputcsv($out, ['payable_amount', number_format((float) ($order->payable_amount ?? 0), 2, '.', '')]);
fputcsv($out, ['paid_amount', number_format((float) ($order->paid_amount ?? 0), 2, '.', '')]);
// 汇总字段:对账/退款快速口径(复用模型统一口径,避免控制器内重复计算导致不一致)
$receiptTotal = (float) $order->receiptTotal();
$refundTotal = (float) $order->refundTotal();
$delta = $receiptTotal - (float) ($order->paid_amount ?? 0);
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$reconcileMismatch = (bool) $order->isReconcileMismatch();
fputcsv($out, ['receipt_total', number_format($receiptTotal, 2, '.', '')]);
fputcsv($out, ['refund_total', number_format($refundTotal, 2, '.', '')]);
fputcsv($out, ['reconcile_delta', number_format($delta, 2, '.', '')]);
fputcsv($out, ['amount_tolerance', number_format($tol, 2, '.', '')]);
$refundInconsistent = (bool) $order->isRefundInconsistent();
fputcsv($out, ['reconcile_mismatch', $reconcileMismatch ? '1' : '0']);
fputcsv($out, ['refund_inconsistent', $refundInconsistent ? '1' : '0']);
// 口径说明(给财务/运营离线核对时可读)
fputcsv($out, ['reconcile_mismatch_rule', 'receipt_total > 0 && abs(receipt_total - paid_amount) >= amount_tolerance']);
fputcsv($out, ['refund_inconsistent_rule', 'PlatformOrder::isRefundInconsistent()(按系统统一容差口径)']);
fputcsv($out, ['placed_at', (string) (optional($order->placed_at)->format('Y-m-d H:i:s') ?? '')]);
fputcsv($out, ['paid_at', (string) (optional($order->paid_at)->format('Y-m-d H:i:s') ?? '')]);
fputcsv($out, ['activated_at', (string) (optional($order->activated_at)->format('Y-m-d H:i:s') ?? '')]);
fputcsv($out, ['refunded_at', (string) (optional($order->refunded_at)->format('Y-m-d H:i:s') ?? '')]);
fputcsv($out, ['site_subscription_id', (string) ($order->site_subscription_id ?? '')]);
fputcsv($out, ['subscription_no', (string) ($order->siteSubscription?->subscription_no ?? '')]);
}
fputcsv($out, []);
// 明细表头
fputcsv($out, ['record_type', 'receipt_type', 'channel', 'amount', 'biz_time', 'created_at', 'admin_id', 'note']);
foreach ($paymentReceipts as $r) {
fputcsv($out, [
'payment',
(string) (data_get($r, 'type') ?? ''),
(string) (data_get($r, 'channel') ?? ''),
number_format((float) (data_get($r, 'amount') ?? 0), 2, '.', ''),
(string) (data_get($r, 'paid_at') ?? ''),
(string) (data_get($r, 'created_at') ?? ''),
(string) (data_get($r, 'admin_id') ?? ''),
(string) (data_get($r, 'note') ?? ''),
]);
}
foreach ($refundReceipts as $r) {
fputcsv($out, [
'refund',
(string) (data_get($r, 'type') ?? ''),
(string) (data_get($r, 'channel') ?? ''),
number_format((float) (data_get($r, 'amount') ?? 0), 2, '.', ''),
(string) (data_get($r, 'refunded_at') ?? ''),
(string) (data_get($r, 'created_at') ?? ''),
(string) (data_get($r, 'admin_id') ?? ''),
(string) (data_get($r, 'note') ?? ''),
]);
}
fclose($out);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 治理动作:为订单手工绑定订阅(用于“续费缺订阅”脏数据修复)。
*
* 口径:
* - 仅平台管理员可操作ensurePlatformAdmin
* - 仅允许续费单绑定订阅
* - 订阅必须与订单 merchant_id / plan_id 一致,避免串单
* - 写入 meta.audit 留痕
*/
public function attachSubscription(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'site_subscription_id' => ['required', 'integer', 'exists:site_subscriptions,id'],
'back' => ['nullable', 'string', 'max:2000'],
]);
$safeBack = \App\Support\BackUrl::sanitizeForLinks((string) ($data['back'] ?? ''));
// 进一步稳妥:强制回跳到订单详情自身(避免外部页面传错 back导致运营迷路
$fallbackBack = '/admin/platform-orders/' . $order->id;
if ($safeBack === '') {
$safeBack = $fallbackBack;
}
if ((string) ($order->order_type ?? '') !== 'renewal') {
return ($safeBack !== '' ? redirect($safeBack) : redirect()->back())
->with('warning', '仅「续费」类型订单允许绑定订阅。');
}
if ((int) ($order->site_subscription_id ?? 0) > 0) {
return ($safeBack !== '' ? redirect($safeBack) : redirect()->back())
->with('warning', '该订单已绑定订阅,无需重复操作。');
}
$subId = (int) $data['site_subscription_id'];
$sub = SiteSubscription::query()->with(['merchant', 'plan'])->findOrFail($subId);
// 强约束:订阅上下文必须与订单一致
if ((int) ($sub->merchant_id ?? 0) !== (int) ($order->merchant_id ?? 0)) {
return ($safeBack !== '' ? redirect($safeBack) : redirect()->back())
->withErrors([
'site_subscription_id' => '订阅所属站点与订单站点不一致,禁止绑定(避免串单)。',
]);
}
if ((int) ($sub->plan_id ?? 0) !== (int) ($order->plan_id ?? 0)) {
return ($safeBack !== '' ? redirect($safeBack) : redirect()->back())
->withErrors([
'site_subscription_id' => '订阅套餐与订单套餐不一致,禁止绑定(避免跨套餐续费)。',
]);
}
$order->site_subscription_id = $sub->id;
$meta = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'attach_subscription',
'scope' => 'single',
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
'subscription_id' => $sub->id,
'subscription_no' => (string) ($sub->subscription_no ?? ''),
'note' => '续费缺订阅治理:手工绑定订阅',
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
// 绑定成功后追加一个轻量 query用于前端 JS 做滚动/高亮提示(渐进增强)
$redirectUrl = $safeBack;
if (str_contains($redirectUrl, '?')) {
$redirectUrl .= '&attached_subscription=1';
} else {
$redirectUrl .= '?attached_subscription=1';
}
return redirect($redirectUrl)
->with('success', '已绑定订阅:' . (string) ($sub->subscription_no ?? $sub->id));
}
public function activateSubscription(Request $request, PlatformOrder $order, SubscriptionActivationService $service): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
return redirect()->back()->with('warning', '当前订单类型为「续费」但未绑定订阅site_subscription_id 为空)。为避免误同步/续期串单,请先补齐订阅关联后再处理。');
}
// 治理优先:当订单命中金额/状态不一致时,不建议直接同步订阅(避免把“带病订单”同步到订阅)
if ($order->isReconcileMismatch() || $order->isRefundInconsistent()) {
return redirect()->back()->with('warning', '当前订单命中「对账不一致/退款不一致」,为避免带病同步,请先完成金额/状态治理(补回执/核对退款/修正状态)后再同步订阅。');
}
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);
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
return redirect()->back()->with('warning', '当前订单类型为「续费」但未绑定订阅site_subscription_id 为空)。为避免误同步/续期串单,请先补齐订阅关联后再处理。');
}
// 治理优先若该订单已有退款轨迹refund_summary/refund_receipts不允许直接“标记支付并生效”避免出现带退款的订单被强行推进并同步订阅
if ((float) $order->refundTotal() > 0) {
return redirect()->back()->with('warning', '当前订单已存在退款记录/退款汇总,请先核对退款轨迹与订单状态后再处理(不建议直接标记支付并生效)。');
}
// 治理优先:若该订单已有回执但回执总额与应标记的已付金额不一致,不允许直接“标记支付并生效”
// 说明:该动作会尝试补回执并同步订阅,若原回执存在差额,直接推进可能掩盖问题
$receiptTotal = (float) $order->receiptTotal();
$expectedPaid = (float) (($order->paid_amount ?? 0) > 0 ? $order->paid_amount : $order->payable_amount);
$receiptCents = (int) round($receiptTotal * 100);
$expectedCents = (int) round($expectedPaid * 100);
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
if ($receiptCents > 0 && abs($receiptCents - $expectedCents) >= $tolCents) {
return redirect()->back()->with('warning', '当前订单已存在支付回执,但回执总额与订单金额不一致。为避免带病推进,请先补齐/修正支付回执后再执行「标记支付并生效」。');
}
// 最小状态推进:将订单标记为已支付 + 已生效,并补齐时间与金额字段
$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');
// 审计:标记支付并生效(并已同步订阅)
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'mark_paid_and_activate',
'scope' => 'single',
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
'subscription_id' => $subscription->id,
'paid_amount' => (float) ($order->paid_amount ?? 0),
'payable_amount' => (float) ($order->payable_amount ?? 0),
'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 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);
// 退款总额 + 容差 >= 已付金额 => 视为已退款;否则视为部分退款(与 refund_inconsistent 口径一致)
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$paidCents = (int) round($paidAmount * 100);
$refundCents = (int) round($totalRefunded * 100);
if ($paidCents > 0 && ($refundCents + $tolCents) >= $paidCents) {
$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);
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
if (round($refundTotal * 100) + $tolCents < 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);
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
if (round($refundTotal * 100) <= 0) {
return redirect()->back()->with('warning', '退款总额为 0无法标记为部分退款。');
}
if (round($refundTotal * 100) + $tolCents >= 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);
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
return redirect()->back()->with('warning', '当前订单类型为「续费」但未绑定订阅site_subscription_id 为空)。为避免续期串单,请先补齐订阅关联后再标记为已生效。');
}
// 仅标记“已生效”:用于处理已支付但未生效的订单(不改 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);
// 安全阀:必须显式声明 download=1避免浏览器预取/误触发导致频繁导出
if ((string) $request->query('download', '') !== '1') {
abort(400, 'download=1 required');
}
$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', ''),
// 只看批量“标记支付并生效”失败meta.batch_mark_paid_and_activate_error.message 存在
'bmpa_failed_only' => (string) $request->query('bmpa_failed_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', '')),
// 批量“标记支付并生效”失败原因关键词:用于定位同原因失败订单(可治理)
'bmpa_error_keyword' => trim((string) $request->query('bmpa_error_keyword', '')),
// 只看“可同步订阅”的订单:已支付 + 已生效 + 未同步(用于运营快速处理)
'syncable_only' => (string) $request->query('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->query('renewal_missing_subscription', ''),
// 只看最近 24 小时批量同步过的订单(可治理追踪)
'batch_synced_24h' => (string) $request->query('batch_synced_24h', ''),
// 只看最近 24 小时批量“标记支付并生效(BMPA)”过的订单(可治理追踪)
'batch_mark_paid_and_activate_24h' => (string) $request->query('batch_mark_paid_and_activate_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', ''),
// 线索联动:用于从开通线索跳转查看关联订单
'lead_id' => trim((string) $request->query('lead_id', '')),
// 创建时间范围(用于“趋势→集合”跳转与运营筛选)
'created_from' => trim((string) $request->query('created_from', '')),
'created_to' => trim((string) $request->query('created_to', '')),
];
$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',
'站点',
'套餐',
'订单类型',
'订单状态',
'支付状态',
'应付金额',
'已付金额',
'下单时间',
'支付时间',
'生效时间',
'同步状态',
'订阅ID',
'订阅号',
'订阅到期',
'同步时间',
'同步失败原因',
'同步失败时间',
'BMPA失败原因',
'BMPA失败时间',
'最近批量标记支付并生效时间',
'最近批量标记支付并生效管理员',
'最近批量生效时间',
'最近批量生效管理员',
'支付回执数',
'最近回执时间',
'最近回执金额',
'最近回执渠道',
'退款记录数',
'最近退款时间',
'最近退款金额',
'最近退款渠道',
'退款总额',
'回执总额',
'对账差额',
];
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,
(int) (data_get($order->meta, 'platform_lead_id') ?? 0),
$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_paid_and_activate_error.message') ?? ''),
(string) (data_get($order->meta, 'batch_mark_paid_and_activate_error.at') ?? ''),
(string) (data_get($order->meta, 'batch_mark_paid_and_activate.at') ?? ''),
(string) (data_get($order->meta, 'batch_mark_paid_and_activate.admin_id') ?? ''),
(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') ?? ''),
(float) $order->refundTotal(),
(float) $order->receiptTotal(),
(float) $order->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', ''),
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'lead_id' => trim((string) $request->input('lead_id', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'bmpa_error_keyword' => trim((string) $request->input('bmpa_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->input('renewal_missing_subscription', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_paid_and_activate_24h' => (string) $request->input('batch_mark_paid_and_activate_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', '为避免误操作,请先在筛选条件中勾选「只看可同步」,再执行批量同步订阅。');
}
// 防误操作(口径一致):当已勾选 syncable_only=1 时,不允许叠加互斥的“同步状态/失败原因/已同步”筛选
// 说明syncable_only=1 的语义已统一为 unsynced未同步且非失败。若仍保留 sync_status=failed/synced、synced_only=1 或失败原因关键词,
// 会导致集合为空或语义混乱;这里直接 warning 阻断,避免运营误解。
if ($scope === 'filtered' && ($filters['syncable_only'] ?? '') === '1') {
$syncStatus = (string) ($filters['sync_status'] ?? '');
if ($syncStatus !== '' && $syncStatus !== 'unsynced') {
return redirect()->back()->with('warning', '当前已勾选「只看可同步」,但同步状态筛选不是「未同步」。请先切回 sync_status=unsynced或清空同步状态筛选后再执行批量同步。');
}
if ((string) ($filters['synced_only'] ?? '') === '1') {
return redirect()->back()->with('warning', '当前已勾选「只看可同步」,但又勾选了「只看已同步」:两者语义互斥。请先取消只看已同步后再执行批量同步。');
}
if ((string) ($filters['fail_only'] ?? '') === '1' || trim((string) ($filters['sync_error_keyword'] ?? '')) !== '') {
return redirect()->back()->with('warning', '当前筛选包含「同步失败/失败原因」。请先治理失败原因或切回未同步集合,再执行批量同步。');
}
}
// 防误操作(治理优先):当筛选集合同时命中“对账不一致/退款不一致”时,不允许直接批量同步,避免把“带病订单”同步到订阅
if ($scope === 'filtered'
&& ($filters['syncable_only'] ?? '') === '1'
&& ((string) ($filters['reconcile_mismatch'] ?? '') === '1' || (string) ($filters['refund_inconsistent'] ?? '') === '1')) {
return redirect()->back()->with('warning', '当前筛选集合包含「对账不一致/退款不一致」订单,为避免带病同步,请先完成金额/状态治理(补回执/核对退款/修正状态)后再批量同步订阅。');
}
// 防误操作(回执治理优先):当用户显式筛选「无回执」时,禁止直接批量同步
// 原因:已支付/已生效但无回执证据的订单属于收费闭环缺口,应先补齐回执留痕(可治理、可对账)再同步订阅。
if ($scope === 'filtered'
&& ($filters['syncable_only'] ?? '') === '1'
&& ((string) ($filters['receipt_status'] ?? '') === 'none')) {
return redirect()->back()->with('warning', '当前筛选为「无回执」订单集合。为保证收费闭环可治理,请先补齐支付回执留痕后再批量同步订阅。');
}
// 防误操作(口径一致):当用户显式传入了 status/payment_status 时,要求口径至少锁定「已支付+已生效」
// 说明:订阅详情页的批量同步入口会带 site_subscription_id + syncable_only=1但未必显式带 status/payment_status。
// 这里采用“仅在显式传参时校验”的策略,避免误伤订阅详情页的一键批量同步。
$hasExplicitStatusFilters = ((string) ($filters['payment_status'] ?? '') !== '') || ((string) ($filters['status'] ?? '') !== '');
if ($scope === 'filtered' && $hasExplicitStatusFilters) {
if (($filters['payment_status'] ?? '') !== 'paid' || ($filters['status'] ?? '') !== 'activated') {
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);
}
// 只处理“可同步”的订单(双保险,避免误操作)
// 口径需与 applyFilters(syncable_only=1) 一致:
// - 已支付 + 已生效
// - 未同步subscription_activation.subscription_id 为空)
// - 非失败subscription_activation_error.message 为空)
// - 排除:续费但未绑定订阅(脏数据,避免串单风险)
$query = $query
->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
->where(function (Builder $q) {
$q->where('order_type', '!=', 'renewal')
->orWhereNotNull('site_subscription_id');
});
$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 {
$order = PlatformOrder::query()->find($orderRow->id);
if (! $order) {
continue;
}
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
throw new \InvalidArgumentException('续费单未绑定订阅site_subscription_id 为空),不允许批量同步订阅。');
}
$subscription = $service->activateOrder($orderRow->id, $admin->id);
// 注意activateOrder 过程中会写入 order 的 meta/site_subscription_id 等;此处必须 refresh避免后续写审计时覆盖掉同步结果
$order->refresh();
// 轻量审计:记录批量同步动作(方便追溯)
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 batchMarkPaidAndActivate(Request $request, SubscriptionActivationService $service): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
// 支持两种 scopefiltered / 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', ''),
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'lead_id' => trim((string) $request->input('lead_id', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'bmpa_error_keyword' => trim((string) $request->input('bmpa_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->input('renewal_missing_subscription', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
'batch_mark_paid_and_activate_24h' => (string) $request->input('batch_mark_paid_and_activate_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) + 未支付(unpaid)」
if ($scope === 'filtered') {
if (($filters['status'] ?? '') !== 'pending' || ($filters['payment_status'] ?? '') !== 'unpaid') {
return redirect()->back()->with('warning', '为避免误操作,请先筛选「订单状态=待处理」且「支付状态=未支付」,再执行批量标记支付并生效。');
}
// 互斥筛选阻断BMPA 面向“待处理+未支付”推进链路;若叠加同步治理筛选,语义会混乱,且可能误伤治理集合。
$hasSyncGovernanceFilters = ((string) ($filters['syncable_only'] ?? '') === '1')
|| ((string) ($filters['synced_only'] ?? '') === '1')
|| (trim((string) ($filters['sync_status'] ?? '')) !== '')
|| ((string) ($filters['fail_only'] ?? '') === '1')
|| (trim((string) ($filters['sync_error_keyword'] ?? '')) !== '');
if ($hasSyncGovernanceFilters) {
return redirect()->back()->with('warning', '当前筛选包含「订阅同步治理」相关条件(同步状态/同步失败/失败原因/只看可同步等)。本动作仅用于推进待处理未支付订单,请先清空同步治理筛选后再执行。');
}
// 治理优先:若当前筛选已包含“对账不一致/退款不一致”等治理集合,则先治理再执行批量推进
if ((string) ($filters['reconcile_mismatch'] ?? '') === '1' || (string) ($filters['refund_inconsistent'] ?? '') === '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);
}
// 双保险:只处理 pending + unpaid
$query = $query->where('status', 'pending')->where('payment_status', 'unpaid');
$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;
$failed = 0;
// 筛选摘要:用于审计记录(便于追溯本次批量处理口径)
$filterSummaryParts = [];
foreach ($filters as $k => $v) {
if ((string) $v !== '') {
$filterSummaryParts[] = $k . '=' . (string) $v;
}
}
$filterSummary = implode('&', $filterSummaryParts);
$now = now();
$nowStr = $now->toDateTimeString();
foreach ($orders as $row) {
$order = PlatformOrder::query()->find($row->id);
if (! $order) {
continue;
}
// 再次防御:仅推进 pending+unpaid
if ($order->status !== 'pending' || $order->payment_status !== 'unpaid') {
continue;
}
try {
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
throw new \InvalidArgumentException('续费单未绑定订阅site_subscription_id 为空),不允许批量标记支付并生效。');
}
// 治理优先:若该订单已有退款轨迹,则不允许推进
if ((float) $order->refundTotal() > 0) {
throw new \InvalidArgumentException('订单存在退款轨迹,不允许批量标记支付并生效,请先完成退款治理。');
}
// 治理优先:若该订单已有回执证据,但回执总额与应付金额不一致,则不允许推进
$receiptTotal = (float) $order->receiptTotal();
$hasReceiptEvidence = (data_get($order->meta, 'payment_summary.total_amount') !== null)
|| (data_get($order->meta, 'payment_receipts.0.amount') !== null);
if ($hasReceiptEvidence) {
$expectedPaid = (float) ($order->payable_amount ?? 0);
$receiptCents = (int) round($receiptTotal * 100);
$expectedCents = (int) round($expectedPaid * 100);
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
if (abs($receiptCents - $expectedCents) >= $tolCents) {
throw new \InvalidArgumentException('订单回执总额与应付金额不一致,不允许批量推进,请先修正回执/金额后再处理。');
}
}
// 最小状态推进:标记为已支付 + 已生效,并补齐时间与金额字段
$order->payment_status = 'paid';
$order->status = 'activated';
$order->paid_at = $order->paid_at ?: $now;
$order->activated_at = $order->activated_at ?: $now;
$order->paid_amount = (float) (($order->payable_amount ?? 0) > 0 ? $order->payable_amount : ($order->paid_amount ?? 0));
// 若尚无回执数组,则补一条(可治理留痕,便于后续对账)
$meta = (array) ($order->meta ?? []);
$receipts = (array) (data_get($meta, 'payment_receipts', []) ?? []);
if (count($receipts) === 0) {
$receipts[] = [
'type' => 'batch_mark_paid_and_activate',
'channel' => (string) ($order->payment_channel ?? ''),
'amount' => (float) ($order->paid_amount ?? 0),
'paid_at' => $order->paid_at ? $order->paid_at->format('Y-m-d H:i:s') : $nowStr,
'note' => '由【批量标记支付并生效】自动补记(可治理)',
'created_at' => $nowStr,
'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') ?? ''),
]);
}
// 清理历史错误
data_forget($meta, 'subscription_activation_error');
data_forget($meta, 'batch_mark_paid_and_activate_error');
$order->meta = $meta;
$order->save();
// 同步订阅
$subscription = $service->activateOrder($order->id, $admin->id);
// 审计:记录批量推进(包含订阅同步)
$order->refresh();
$meta2 = (array) ($order->meta ?? []);
$audit = (array) (data_get($meta2, 'audit', []) ?? []);
$audit[] = [
'action' => 'batch_mark_paid_and_activate',
'scope' => $scope,
'at' => $nowStr,
'admin_id' => $admin->id,
'subscription_id' => $subscription->id,
'filters' => $filterSummary,
'note' => '批量标记支付并生效含订阅同步limit=' . $limit . ', matched=' . $matchedTotal . ', processed=' . $processed . ')',
];
data_set($meta2, 'audit', $audit);
// 便于追踪:记录最近一次批量推进信息
data_set($meta2, 'batch_mark_paid_and_activate', [
'at' => $nowStr,
'admin_id' => $admin->id,
'scope' => $scope,
]);
$order->meta = $meta2;
$order->save();
$success++;
} catch (\Throwable $e) {
$failed++;
$reason = trim((string) $e->getMessage());
$reason = $reason !== '' ? $reason : '未知错误';
$meta = (array) ($order->meta ?? []);
data_set($meta, 'batch_mark_paid_and_activate_error', [
'message' => $reason,
'at' => $nowStr,
'admin_id' => $admin->id,
]);
$order->meta = $meta;
$order->save();
}
}
$msg = '批量标记支付并生效完成:成功 ' . $success . ' 条,失败 ' . $failed . ' 条(命中 ' . $matchedTotal . ' 条,本次处理 ' . $processed . ' 条limit=' . $limit . '';
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', ''),
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'lead_id' => trim((string) $request->input('lead_id', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'bmpa_error_keyword' => trim((string) $request->input('bmpa_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->input('renewal_missing_subscription', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_paid_and_activate_24h' => (string) $request->input('batch_mark_paid_and_activate_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) + 未同步(unsynced)」
if ($scope === 'filtered') {
if (($filters['payment_status'] ?? '') !== 'paid' || ($filters['status'] ?? '') !== 'pending') {
return redirect()->back()->with('warning', '为避免误操作,请先筛选「支付状态=已支付」且「订单状态=待处理」,再执行批量仅标记为已生效。');
}
if (($filters['sync_status'] ?? '') !== 'unsynced') {
return redirect()->back()->with('warning', '为避免把同步失败等异常单混入,请先锁定「同步状态=未同步(sync_status=unsynced)」(建议用快捷筛选「待生效」)再执行批量仅标记为已生效。');
}
// 互斥筛选阻断:避免“待生效”批量动作在其它治理集合上误触(或误以为会命中失败单/已同步单/可同步单)。
if ((string) ($filters['fail_only'] ?? '') === '1' || trim((string) ($filters['sync_error_keyword'] ?? '')) !== '') {
return redirect()->back()->with('warning', '当前筛选包含「同步失败/失败原因」治理集合:与“待生效(unsynced)”互斥。请先切回待生效集合后再执行批量仅标记为已生效。');
}
if ((string) ($filters['synced_only'] ?? '') === '1') {
return redirect()->back()->with('warning', '当前已勾选「只看已同步」:该集合与“待生效(unsynced)”互斥。请先取消该筛选后再执行批量仅标记为已生效。');
}
if ((string) ($filters['syncable_only'] ?? '') === '1') {
return redirect()->back()->with('warning', '当前已勾选「只看可同步」:该集合语义为“已生效(activated)+未同步”,与本动作处理的“待处理(pending)”互斥。请先取消只看可同步后再执行。');
}
}
// 防误操作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;
$failed = 0;
$failedReasonCounts = [];
$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;
}
// 治理优先:续费单必须绑定订阅(兼容历史脏数据/手工改库等场景)
if ((string) ($order->order_type ?? '') === 'renewal' && ! (int) ($order->site_subscription_id ?? 0)) {
$failed++;
$reason = '续费单未绑定订阅site_subscription_id 为空),不允许批量仅标记为已生效。';
$failedReasonCounts[$reason] = ($failedReasonCounts[$reason] ?? 0) + 1;
$meta = (array) ($order->meta ?? []);
data_set($meta, 'batch_mark_activated_error', [
'message' => $reason,
'at' => $nowStr,
'admin_id' => $admin->id,
'scope' => $scope,
'filters' => $filterSummary,
]);
$order->meta = $meta;
$order->save();
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 . ' 条,失败 ' . $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 clearSyncError(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$meta = (array) ($order->meta ?? []);
if (! data_get($meta, 'subscription_activation_error')) {
return redirect()->back()->with('warning', '当前订单暂无同步失败标记,无需清理。');
}
data_forget($meta, 'subscription_activation_error');
// 轻量审计:记录清理动作(不做独立表,先落 meta便于排查
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'clear_sync_error',
'scope' => 'single',
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
'note' => '手动点击订单详情【清除同步失败标记】',
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已清除该订单的同步失败标记。');
}
public function clearBmpaError(Request $request, PlatformOrder $order): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$meta = (array) ($order->meta ?? []);
if (! data_get($meta, 'batch_mark_paid_and_activate_error')) {
return redirect()->back()->with('warning', '当前订单暂无 BMPA 失败标记,无需清理。');
}
data_forget($meta, 'batch_mark_paid_and_activate_error');
// 轻量审计:记录清理动作(不做独立表,先落 meta便于排查
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'clear_bmpa_error',
'scope' => 'single',
'at' => now()->toDateTimeString(),
'admin_id' => $admin->id,
'note' => '手动点击订单详情【清除 BMPA 失败标记】',
];
data_set($meta, 'audit', $audit);
$order->meta = $meta;
$order->save();
return redirect()->back()->with('success', '已清除该订单的 BMPA 失败标记。');
}
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', ''),
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'lead_id' => trim((string) $request->input('lead_id', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'bmpa_error_keyword' => trim((string) $request->input('bmpa_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->input('renewal_missing_subscription', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
'batch_mark_paid_and_activate_24h' => (string) $request->input('batch_mark_paid_and_activate_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', ''),
];
// 防误操作(后端兜底):清理“同步失败”标记时,不允许叠加与失败集合互斥的筛选。
// 说明:列表页 UI 已做按钮禁用,但仍需后端兜底以防绕过 UI 直接 POST。
if ($scope === 'filtered') {
$syncStatus = (string) ($filters['sync_status'] ?? '');
if ($syncStatus !== '' && $syncStatus !== 'failed') {
return redirect()->back()->with('warning', '当前为「清理同步失败标记」动作:若需要筛选同步状态,请使用 sync_status=failed或清空该筛选后再执行。');
}
if ((string) ($filters['syncable_only'] ?? '') === '1') {
return redirect()->back()->with('warning', '当前已勾选「只看可同步」:该集合与「同步失败」互斥,请先取消只看可同步或切到失败集合后再清理。');
}
}
$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 . ' 条)');
}
public function clearBmpaErrors(Request $request): RedirectResponse
{
$this->ensurePlatformAdmin($request);
// 支持两种模式:
// - scope=all默认清理所有订单的 BMPA 失败标记(需要 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', '为避免误操作,清除全部 BMPA 失败标记前请在确认框输入 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', ''),
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
'synced_only' => (string) $request->input('synced_only', ''),
'sync_status' => trim((string) $request->input('sync_status', '')),
'keyword' => trim((string) $request->input('keyword', '')),
'lead_id' => trim((string) $request->input('lead_id', '')),
'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')),
'bmpa_error_keyword' => trim((string) $request->input('bmpa_error_keyword', '')),
'syncable_only' => (string) $request->input('syncable_only', ''),
'renewal_missing_subscription' => (string) $request->input('renewal_missing_subscription', ''),
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
'batch_mark_paid_and_activate_24h' => (string) $request->input('batch_mark_paid_and_activate_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', ''),
];
// 防误操作后端兜底清理“BMPA 失败”标记时,建议先锁定 BMPA 失败集合,避免误清理。
// 说明:列表页 UI 已做按钮禁用,但仍需后端兜底以防绕过 UI 直接 POST。
if ($scope === 'filtered') {
if ((string) ($filters['syncable_only'] ?? '') === '1') {
return redirect()->back()->with('warning', '当前已勾选「只看可同步」该集合与「BMPA失败」治理集合无关请先取消只看可同步或切到 BMPA 失败集合后再清理。');
}
if ((string) ($filters['fail_only'] ?? '') === '1' || trim((string) ($filters['sync_error_keyword'] ?? '')) !== '') {
return redirect()->back()->with('warning', '当前筛选包含「同步失败/失败原因」:该集合用于订阅同步治理,请切到 BMPA 失败集合后再清理 BMPA 失败标记。');
}
$hasBmpaScope = ((string) ($filters['bmpa_failed_only'] ?? '') === '1')
|| (trim((string) ($filters['bmpa_error_keyword'] ?? '')) !== '')
|| ((string) ($filters['batch_mark_paid_and_activate_24h'] ?? '') === '1');
if (! $hasBmpaScope) {
return redirect()->back()->with('warning', '为避免误操作请先筛选「BMPA失败」集合bmpa_failed_only=1 或失败原因关键词,或勾选 batch_mark_paid_and_activate_24h后再执行清理。');
}
}
$query = PlatformOrder::query()
->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_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, 'batch_mark_paid_and_activate_error')) {
continue;
}
data_forget($meta, 'batch_mark_paid_and_activate_error');
// 轻量审计:记录清理动作(不做独立表,先落 meta便于排查
$audit = (array) (data_get($meta, 'audit', []) ?? []);
$audit[] = [
'action' => 'clear_bmpa_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'
? '已清除当前筛选范围内的 BMPA 失败标记:'
: '已清除全部订单的 BMPA 失败标记:';
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['bmpa_failed_only'] ?? '') !== '', function (Builder $builder) {
// 只看批量“标记支付并生效”失败meta.batch_mark_paid_and_activate_error.message 存在即视为失败
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_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['bmpa_error_keyword'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 批量“标记支付并生效”失败原因关键词batch_mark_paid_and_activate_error.message like
$kw = trim((string) ($filters['bmpa_error_keyword'] ?? ''));
if ($kw === '') {
return;
}
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message') LIKE ?", ['%' . $kw . '%']);
} else {
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate_error.message')) LIKE ?", ['%' . $kw . '%']);
}
})
->when(($filters['syncable_only'] ?? '') === '1', function (Builder $builder) {
// 只看可同步:已支付 + 已生效 + 未同步 + 非失败
// - 未同步subscription_activation.subscription_id 为空
// - 非失败subscription_activation_error.message 为空
// 额外治理口径:排除(续费但未绑定订阅)的脏数据,避免误入可同步集合导致串单风险
$builder->where('payment_status', 'paid')
->where('status', 'activated')
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation.subscription_id') IS NULL")
->whereRaw("JSON_EXTRACT(meta, '$.subscription_activation_error.message') IS NULL")
->where(function (Builder $q) {
$q->where('order_type', '!=', 'renewal')
->orWhereNotNull('site_subscription_id');
});
})
->when(($filters['renewal_missing_subscription'] ?? '') !== '', function (Builder $builder) {
// 只看“续费但未绑定订阅”的脏数据(可治理)
$builder->where('order_type', 'renewal')
->whereNull('site_subscription_id');
})
->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_paid_and_activate_24h'] ?? '') !== '', function (Builder $builder) {
// 只看最近 24 小时批量“标记支付并生效(BMPA)”过的订单(基于 meta.batch_mark_paid_and_activate.at
$since = now()->subHours(24)->format('Y-m-d H:i:s');
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.at') IS NOT NULL");
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.at') >= ?", [$since]);
} else {
$builder->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.batch_mark_paid_and_activate.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)
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$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)) >= {$tolCents}");
} else {
// MySQL 下 JSON_EXTRACT 返回 JSON需要 JSON_UNQUOTE 再 cast同样按分取整避免浮点误差
// total_cents = (payment_summary.total_amount 存在 ? summary*100 : SUM(payment_receipts[].amount)*100)
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$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)) >= {$tolCents}");
}
})
->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 但 退款总额 + 容差 < 已付金额
// - 状态!=refunded 且 已付金额>0 且 退款总额 >= 已付金额 + 容差
// 退款总额口径:优先 refund_summary.total_amount缺省回退汇总 refund_receipts[].amount
// 容差口径与模型 PlatformOrder::isRefundInconsistent() 对齐config('saasshop.amounts.tolerance')
$tol = (float) config('saasshop.amounts.tolerance', 0.01);
$tolCents = (int) round($tol * 100);
$tolCents = max(1, $tolCents);
$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, $tolCents) {
// refunded 但退款不够refund_total + tol < paid
$q->where(function (Builder $q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("(ROUND($refundTotalExpr * 100) + {$tolCents}) < ROUND(paid_amount * 100)");
})
// 非 refunded 但退款已达到/超过已付 + tolrefund_total >= paid + tol
->orWhere(function (Builder $q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("ROUND($refundTotalExpr * 100) >= (ROUND(paid_amount * 100) + {$tolCents})");
});
});
} else {
$refundTotalExpr = "(CASE WHEN JSON_EXTRACT(meta, '$.refund_summary.total_amount') IS NOT NULL THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.refund_summary.total_amount')) AS DECIMAL(12,2)) ELSE (SELECT IFNULL(SUM(j.amount), 0) FROM JSON_TABLE(meta, '$.refund_receipts[*]' COLUMNS(amount DECIMAL(12,2) PATH '$.amount')) j) END)";
$builder->where(function (Builder $q) use ($refundTotalExpr, $tolCents) {
$q->where(function (Builder $q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("(ROUND($refundTotalExpr * 100) + {$tolCents}) < ROUND(paid_amount * 100)");
})->orWhere(function (Builder $q2) use ($refundTotalExpr, $tolCents) {
$q2->where('payment_status', '!=', 'refunded')
->whereRaw("paid_amount > 0")
->whereRaw("ROUND($refundTotalExpr * 100) >= (ROUND(paid_amount * 100) + {$tolCents})");
});
});
}
})
->when(($filters['lead_id'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 线索联动仅看指定线索关联的订单meta.platform_lead_id = lead_id
$leadId = (int) ($filters['lead_id'] ?? 0);
if ($leadId <= 0) {
return;
}
$driver = $builder->getQuery()->getConnection()->getDriverName();
if ($driver === 'sqlite') {
$builder->whereRaw("JSON_EXTRACT(meta, '$.platform_lead_id') = ?", [$leadId]);
} else {
$builder->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.platform_lead_id')) AS UNSIGNED) = ?", [$leadId]);
}
})
->when(($filters['created_from'] ?? '') !== '' || ($filters['created_to'] ?? '') !== '', function (Builder $builder) use ($filters) {
// 创建时间范围筛选:用于“趋势→集合”跳转与运营快速定位
// 口径:基于 created_at。
$from = trim((string) ($filters['created_from'] ?? ''));
$to = trim((string) ($filters['created_to'] ?? ''));
// 容错:仅接受 YYYY-MM-DD 格式;不合法则忽略,避免异常输入污染查询
if ($from !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$from = '';
}
if ($to !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$to = '';
}
if ($from !== '' && $to !== '') {
// [from 00:00:00, to 23:59:59]
$builder->whereBetween('created_at', [
$from . ' 00:00:00',
$to . ' 23:59:59',
]);
} elseif ($from !== '') {
$builder->where('created_at', '>=', $from . ' 00:00:00');
} elseif ($to !== '') {
$builder->where('created_at', '<=', $to . ' 23:59:59');
}
});
}
private function receiptTotalForOrder(PlatformOrder $order): float
{
// 口径统一:集中到模型方法,避免多处复制导致漂移
return (float) $order->receiptTotal();
}
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,
};
}
}