Add batch mark paid+activate (with sync) tool with governance safety valves
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
121
tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php
Normal file
121
tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user