From 6a40079466159098134c734c0413af84ca482cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=9D=E5=8D=9C?= Date: Fri, 13 Mar 2026 12:16:52 +0000 Subject: [PATCH] Add batch mark paid+activate (with sync) tool with governance safety valves --- .../Admin/PlatformOrderController.php | 195 ++++++++++++++++++ routes/web.php | 1 + ...tformOrderBatchMarkPaidAndActivateTest.php | 121 +++++++++++ 3 files changed, 317 insertions(+) create mode 100644 tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php diff --git a/app/Http/Controllers/Admin/PlatformOrderController.php b/app/Http/Controllers/Admin/PlatformOrderController.php index f3f8e39..c1d0491 100644 --- a/app/Http/Controllers/Admin/PlatformOrderController.php +++ b/app/Http/Controllers/Admin/PlatformOrderController.php @@ -1109,6 +1109,201 @@ class PlatformOrderController extends Controller return redirect()->back()->with('success', $msg); } + public function batchMarkPaidAndActivate(Request $request, SubscriptionActivationService $service): RedirectResponse + { + $admin = $this->ensurePlatformAdmin($request); + + // 支持两种 scope:filtered / all + $scope = (string) $request->input('scope', 'filtered'); + + $filters = [ + 'status' => trim((string) $request->input('status', '')), + 'payment_status' => trim((string) $request->input('payment_status', '')), + 'merchant_id' => trim((string) $request->input('merchant_id', '')), + 'plan_id' => trim((string) $request->input('plan_id', '')), + 'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')), + 'fail_only' => (string) $request->input('fail_only', ''), + 'synced_only' => (string) $request->input('synced_only', ''), + 'sync_status' => trim((string) $request->input('sync_status', '')), + 'keyword' => trim((string) $request->input('keyword', '')), + 'sync_error_keyword' => trim((string) $request->input('sync_error_keyword', '')), + 'syncable_only' => (string) $request->input('syncable_only', ''), + 'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''), + 'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_24h', ''), + 'reconcile_mismatch' => (string) $request->input('reconcile_mismatch', ''), + 'receipt_status' => trim((string) $request->input('receipt_status', '')), + 'refund_status' => trim((string) $request->input('refund_status', '')), + 'refund_inconsistent' => (string) $request->input('refund_inconsistent', ''), + ]; + + // 防误操作:批量“标记支付并生效”默认要求当前筛选口径为「待处理(pending) + 未支付(unpaid)」 + if ($scope === 'filtered') { + if (($filters['status'] ?? '') !== 'pending' || ($filters['payment_status'] ?? '') !== 'unpaid') { + 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 ((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); + + if (abs($receiptCents - $expectedCents) >= 1) { + 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); diff --git a/routes/web.php b/routes/web.php index 1d85fe5..d950b72 100644 --- a/routes/web.php +++ b/routes/web.php @@ -104,6 +104,7 @@ Route::prefix('admin')->group(function () { Route::get('/platform-orders/create', [PlatformOrderController::class, 'create']); Route::post('/platform-orders', [PlatformOrderController::class, 'store']); Route::post('/platform-orders/batch-activate-subscriptions', [PlatformOrderController::class, 'batchActivateSubscriptions']); + 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::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']); diff --git a/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php b/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php new file mode 100644 index 0000000..8996476 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php @@ -0,0 +1,121 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_platform_admin_can_batch_mark_paid_and_activate_in_filtered_scope(): void + { + $this->loginAsPlatformAdmin(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_mark_paid_and_activate_plan', + 'name' => '批量标记支付并生效测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 30, + 'list_price' => 30, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + // A:可处理(待处理+未支付) + $a = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BMPA_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' => 30, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(10), + ]); + + // B:不可处理(已有回执证据但金额不一致),应被安全阀阻断并记录失败原因 + $b = PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BMPA_0002', + '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' => 30, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(9), + 'meta' => [ + 'payment_summary' => [ + 'count' => 1, + 'total_amount' => 1.00, + ], + ], + ]); + + $res = $this->post('/admin/platform-orders/batch-mark-paid-and-activate', [ + 'scope' => 'filtered', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'limit' => 50, + ]); + + $res->assertRedirect(); + + $a->refresh(); + $b->refresh(); + + // A:已推进并同步订阅 + $this->assertSame('paid', $a->payment_status); + $this->assertSame('activated', $a->status); + $this->assertNotNull($a->site_subscription_id); + $this->assertNotNull(data_get($a->meta, 'subscription_activation.subscription_id')); + $this->assertSame('batch_mark_paid_and_activate', data_get($a->meta, 'audit.0.action')); + + // B:不应被推进 + $this->assertSame('unpaid', $b->payment_status); + $this->assertSame('pending', $b->status); + $this->assertNull($b->site_subscription_id); + $this->assertNotEmpty(data_get($b->meta, 'batch_mark_paid_and_activate_error.message')); + } + + public function test_batch_mark_paid_and_activate_requires_pending_and_unpaid_filters_in_filtered_scope(): void + { + $this->loginAsPlatformAdmin(); + + $res = $this->post('/admin/platform-orders/batch-mark-paid-and-activate', [ + 'scope' => 'filtered', + 'status' => 'activated', + 'payment_status' => 'paid', + 'limit' => 50, + ]); + + $res->assertRedirect(); + $res->assertSessionHas('warning'); + } +}