Add batch mark paid+activate (with sync) tool with governance safety valves

This commit is contained in:
萝卜
2026-03-13 12:16:52 +00:00
parent a614870eae
commit 6a40079466
3 changed files with 317 additions and 0 deletions

View File

@@ -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);
// 支持两种 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', ''),
'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);

View File

@@ -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']);

View File

@@ -0,0 +1,121 @@
<?php
namespace Tests\Feature;
use App\Models\Merchant;
use App\Models\Plan;
use App\Models\PlatformOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminPlatformOrderBatchMarkPaidAndActivateTest extends TestCase
{
use RefreshDatabase;
protected function loginAsPlatformAdmin(): void
{
$this->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');
}
}