diff --git a/app/Http/Controllers/Admin/PlatformOrderController.php b/app/Http/Controllers/Admin/PlatformOrderController.php index e0a5c71..86c8512 100644 --- a/app/Http/Controllers/Admin/PlatformOrderController.php +++ b/app/Http/Controllers/Admin/PlatformOrderController.php @@ -991,10 +991,12 @@ class PlatformOrderController extends Controller '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', '')), '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', ''), 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), // 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径 @@ -1151,10 +1153,12 @@ class PlatformOrderController extends Controller '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', '')), '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', ''), 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), 'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''), @@ -1348,10 +1352,12 @@ class PlatformOrderController extends Controller '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', '')), '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', ''), 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), // 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径 @@ -1450,7 +1456,7 @@ class PlatformOrderController extends Controller } public function clearSyncErrors(Request $request): RedirectResponse - { + { $this->ensurePlatformAdmin($request); // 支持两种模式: @@ -1470,10 +1476,12 @@ class PlatformOrderController extends Controller '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', '')), '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', ''), 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), // 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径 @@ -1525,6 +1533,83 @@ class PlatformOrderController extends Controller 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', '')), + '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', ''), + 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), + 'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''), + 'reconcile_mismatch' => (string) $request->input('reconcile_mismatch', ''), + 'receipt_status' => trim((string) $request->input('receipt_status', '')), + 'refund_status' => trim((string) $request->input('refund_status', '')), + 'refund_inconsistent' => (string) $request->input('refund_inconsistent', ''), + ]; + + $query = PlatformOrder::query() + ->whereRaw("JSON_EXTRACT(meta, '$.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 diff --git a/resources/views/admin/platform_orders/index.blade.php b/resources/views/admin/platform_orders/index.blade.php index e36d9e5..3eeb0ad 100644 --- a/resources/views/admin/platform_orders/index.blade.php +++ b/resources/views/admin/platform_orders/index.blade.php @@ -603,10 +603,10 @@ - + -
+ @csrf @@ -616,7 +616,45 @@ (必须输入 YES 才会执行) - + +
+ +
+ @csrf + + + + + + + + + + + + + + + + + + + + + +
+ +
+ @csrf + + + + +
diff --git a/routes/web.php b/routes/web.php index d950b72..e26ae4f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -107,6 +107,7 @@ Route::prefix('admin')->group(function () { Route::post('/platform-orders/batch-mark-paid-and-activate', [PlatformOrderController::class, 'batchMarkPaidAndActivate']); Route::post('/platform-orders/batch-mark-activated', [PlatformOrderController::class, 'batchMarkActivated']); Route::post('/platform-orders/clear-sync-errors', [PlatformOrderController::class, 'clearSyncErrors']); + Route::post('/platform-orders/clear-bmpa-errors', [PlatformOrderController::class, 'clearBmpaErrors']); Route::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']); Route::post('/platform-orders/{order}/activate-subscription', [PlatformOrderController::class, 'activateSubscription']); Route::post('/platform-orders/{order}/mark-paid-and-activate', [PlatformOrderController::class, 'markPaidAndActivate']); diff --git a/tests/Feature/AdminPlatformOrderClearBmpaErrorsTest.php b/tests/Feature/AdminPlatformOrderClearBmpaErrorsTest.php new file mode 100644 index 0000000..9f49344 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderClearBmpaErrorsTest.php @@ -0,0 +1,85 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_clear_bmpa_errors(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'clear_bmpa_err_test', + 'name' => '清理 BMPA 失败标记测试', + 'billing_cycle' => 'monthly', + 'price' => 1, + 'list_price' => 1, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + $order = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_CLEAR_BMPA_ERR_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 1, + 'paid_amount' => 0, + 'placed_at' => now(), + 'meta' => [ + 'batch_mark_paid_and_activate_error' => [ + 'message' => '模拟 BMPA 失败', + 'at' => now()->toDateTimeString(), + 'admin_id' => 1, + ], + ], + ]); + + // scope=all 需要二次确认 + $this->post('/admin/platform-orders/clear-bmpa-errors', ['scope' => 'all']) + ->assertRedirect(); + + $order->refresh(); + $this->assertNotEmpty(data_get($order->meta, 'batch_mark_paid_and_activate_error.message')); + + $this->post('/admin/platform-orders/clear-bmpa-errors', ['scope' => 'all', 'confirm' => 'YES']) + ->assertRedirect(); + + $order->refresh(); + $this->assertEmpty(data_get($order->meta, 'batch_mark_paid_and_activate_error')); + } + + public function test_guest_cannot_clear_bmpa_errors(): void + { + $this->seed(); + + $this->post('/admin/platform-orders/clear-bmpa-errors', ['scope' => 'all']) + ->assertRedirect('/admin/login'); + } +}