platform orders: add clear bmpa errors tool and audit
This commit is contained in:
@@ -991,10 +991,12 @@ class PlatformOrderController extends Controller
|
|||||||
'plan_id' => trim((string) $request->input('plan_id', '')),
|
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||||
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
||||||
'fail_only' => (string) $request->input('fail_only', ''),
|
'fail_only' => (string) $request->input('fail_only', ''),
|
||||||
|
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
|
||||||
'synced_only' => (string) $request->input('synced_only', ''),
|
'synced_only' => (string) $request->input('synced_only', ''),
|
||||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||||
'keyword' => trim((string) $request->input('keyword', '')),
|
'keyword' => trim((string) $request->input('keyword', '')),
|
||||||
'sync_error_keyword' => trim((string) $request->input('sync_error_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', ''),
|
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
'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', '')),
|
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||||
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
||||||
'fail_only' => (string) $request->input('fail_only', ''),
|
'fail_only' => (string) $request->input('fail_only', ''),
|
||||||
|
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
|
||||||
'synced_only' => (string) $request->input('synced_only', ''),
|
'synced_only' => (string) $request->input('synced_only', ''),
|
||||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||||
'keyword' => trim((string) $request->input('keyword', '')),
|
'keyword' => trim((string) $request->input('keyword', '')),
|
||||||
'sync_error_keyword' => trim((string) $request->input('sync_error_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', ''),
|
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
||||||
'batch_mark_activated_24h' => (string) $request->input('batch_mark_activated_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', '')),
|
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||||
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
||||||
'fail_only' => (string) $request->input('fail_only', ''),
|
'fail_only' => (string) $request->input('fail_only', ''),
|
||||||
|
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
|
||||||
'synced_only' => (string) $request->input('synced_only', ''),
|
'synced_only' => (string) $request->input('synced_only', ''),
|
||||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||||
'keyword' => trim((string) $request->input('keyword', '')),
|
'keyword' => trim((string) $request->input('keyword', '')),
|
||||||
'sync_error_keyword' => trim((string) $request->input('sync_error_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', ''),
|
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
||||||
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
|
// 与列表页筛选保持一致(可治理):用于在批量操作后仍能回到同一口径
|
||||||
@@ -1470,10 +1476,12 @@ class PlatformOrderController extends Controller
|
|||||||
'plan_id' => trim((string) $request->input('plan_id', '')),
|
'plan_id' => trim((string) $request->input('plan_id', '')),
|
||||||
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
'site_subscription_id' => trim((string) $request->input('site_subscription_id', '')),
|
||||||
'fail_only' => (string) $request->input('fail_only', ''),
|
'fail_only' => (string) $request->input('fail_only', ''),
|
||||||
|
'bmpa_failed_only' => (string) $request->input('bmpa_failed_only', ''),
|
||||||
'synced_only' => (string) $request->input('synced_only', ''),
|
'synced_only' => (string) $request->input('synced_only', ''),
|
||||||
'sync_status' => trim((string) $request->input('sync_status', '')),
|
'sync_status' => trim((string) $request->input('sync_status', '')),
|
||||||
'keyword' => trim((string) $request->input('keyword', '')),
|
'keyword' => trim((string) $request->input('keyword', '')),
|
||||||
'sync_error_keyword' => trim((string) $request->input('sync_error_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', ''),
|
'syncable_only' => (string) $request->input('syncable_only', ''),
|
||||||
'batch_synced_24h' => (string) $request->input('batch_synced_24h', ''),
|
'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 . ' 条)');
|
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
|
protected function applyFilters(Builder $query, array $filters): Builder
|
||||||
{
|
{
|
||||||
return $query
|
return $query
|
||||||
|
|||||||
@@ -603,10 +603,10 @@
|
|||||||
<input type="hidden" name="bmpa_error_keyword" value="{{ $filters['bmpa_error_keyword'] ?? '' }}">
|
<input type="hidden" name="bmpa_error_keyword" value="{{ $filters['bmpa_error_keyword'] ?? '' }}">
|
||||||
<input type="hidden" name="reconcile_mismatch" value="{{ $filters['reconcile_mismatch'] ?? '' }}">
|
<input type="hidden" name="reconcile_mismatch" value="{{ $filters['reconcile_mismatch'] ?? '' }}">
|
||||||
<input type="hidden" name="refund_inconsistent" value="{{ $filters['refund_inconsistent'] ?? '' }}">
|
<input type="hidden" name="refund_inconsistent" value="{{ $filters['refund_inconsistent'] ?? '' }}">
|
||||||
<button type="submit">清除当前筛选范围的失败标记</button>
|
<button type="submit">清除同步失败标记(当前筛选范围)</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="post" action="/admin/platform-orders/clear-sync-errors" onsubmit="return confirm('确认清除全部订单的“同步失败”标记?该操作不可逆(仅清理 meta 标记),请谨慎。');">
|
<form method="post" action="/admin/platform-orders/clear-sync-errors" onsubmit="return confirm('确认清除全部订单的“同步失败”标记?该操作不可逆(仅清理 meta 标记),请谨慎。');" class="mb-10">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="scope" value="all">
|
<input type="hidden" name="scope" value="all">
|
||||||
|
|
||||||
@@ -616,7 +616,45 @@
|
|||||||
<span>(必须输入 YES 才会执行)</span>
|
<span>(必须输入 YES 才会执行)</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">清除全部失败标记</button>
|
<button type="submit">清除同步失败标记(全部订单)</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/platform-orders/clear-bmpa-errors" onsubmit="return confirm('确认清除当前筛选范围内命中的订单的“批量标记支付失败”标记?');" class="mb-10">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="scope" value="filtered">
|
||||||
|
<input type="hidden" name="status" value="{{ $filters['status'] ?? '' }}">
|
||||||
|
<input type="hidden" name="payment_status" value="{{ $filters['payment_status'] ?? '' }}">
|
||||||
|
<input type="hidden" name="merchant_id" value="{{ $filters['merchant_id'] ?? '' }}">
|
||||||
|
<input type="hidden" name="plan_id" value="{{ $filters['plan_id'] ?? '' }}">
|
||||||
|
<input type="hidden" name="site_subscription_id" value="{{ $filters['site_subscription_id'] ?? '' }}">
|
||||||
|
<input type="hidden" name="fail_only" value="{{ $filters['fail_only'] ?? '' }}">
|
||||||
|
<input type="hidden" name="bmpa_failed_only" value="{{ $filters['bmpa_failed_only'] ?? '' }}">
|
||||||
|
<input type="hidden" name="synced_only" value="{{ $filters['synced_only'] ?? '' }}">
|
||||||
|
<input type="hidden" name="sync_status" value="{{ $filters['sync_status'] ?? '' }}">
|
||||||
|
<input type="hidden" name="receipt_status" value="{{ $filters['receipt_status'] ?? '' }}">
|
||||||
|
<input type="hidden" name="refund_status" value="{{ $filters['refund_status'] ?? '' }}">
|
||||||
|
<input type="hidden" name="syncable_only" value="{{ $filters['syncable_only'] ?? '' }}">
|
||||||
|
<input type="hidden" name="batch_synced_24h" value="{{ $filters['batch_synced_24h'] ?? '' }}">
|
||||||
|
<input type="hidden" name="batch_mark_activated_24h" value="{{ $filters['batch_mark_activated_24h'] ?? '' }}">
|
||||||
|
<input type="hidden" name="keyword" value="{{ $filters['keyword'] ?? '' }}">
|
||||||
|
<input type="hidden" name="sync_error_keyword" value="{{ $filters['sync_error_keyword'] ?? '' }}">
|
||||||
|
<input type="hidden" name="bmpa_error_keyword" value="{{ $filters['bmpa_error_keyword'] ?? '' }}">
|
||||||
|
<input type="hidden" name="reconcile_mismatch" value="{{ $filters['reconcile_mismatch'] ?? '' }}">
|
||||||
|
<input type="hidden" name="refund_inconsistent" value="{{ $filters['refund_inconsistent'] ?? '' }}">
|
||||||
|
<button type="submit">清除批量标记支付失败标记(当前筛选范围)</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/platform-orders/clear-bmpa-errors" onsubmit="return confirm('确认清除全部订单的“批量标记支付失败”标记?该操作不可逆(仅清理 meta 标记),请谨慎。');">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="scope" value="all">
|
||||||
|
|
||||||
|
<label class="muted form-inline-row mb-8">
|
||||||
|
<span>确认输入</span>
|
||||||
|
<input type="text" name="confirm" placeholder="YES" class="w-140">
|
||||||
|
<span>(必须输入 YES 才会执行)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">清除批量标记支付失败标记(全部订单)</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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-paid-and-activate', [PlatformOrderController::class, 'batchMarkPaidAndActivate']);
|
||||||
Route::post('/platform-orders/batch-mark-activated', [PlatformOrderController::class, 'batchMarkActivated']);
|
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-sync-errors', [PlatformOrderController::class, 'clearSyncErrors']);
|
||||||
|
Route::post('/platform-orders/clear-bmpa-errors', [PlatformOrderController::class, 'clearBmpaErrors']);
|
||||||
Route::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']);
|
Route::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']);
|
||||||
Route::post('/platform-orders/{order}/activate-subscription', [PlatformOrderController::class, 'activateSubscription']);
|
Route::post('/platform-orders/{order}/activate-subscription', [PlatformOrderController::class, 'activateSubscription']);
|
||||||
Route::post('/platform-orders/{order}/mark-paid-and-activate', [PlatformOrderController::class, 'markPaidAndActivate']);
|
Route::post('/platform-orders/{order}/mark-paid-and-activate', [PlatformOrderController::class, 'markPaidAndActivate']);
|
||||||
|
|||||||
85
tests/Feature/AdminPlatformOrderClearBmpaErrorsTest.php
Normal file
85
tests/Feature/AdminPlatformOrderClearBmpaErrorsTest.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?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 AdminPlatformOrderClearBmpaErrorsTest 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_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user