feat(platform-orders): 支持总台手工创建平台订单并进入闭环

This commit is contained in:
萝卜
2026-03-10 14:35:31 +00:00
parent 826c58085b
commit 3f809c8150
5 changed files with 304 additions and 1 deletions

View File

@@ -4,12 +4,15 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\ResolvesPlatformAdminContext;
use App\Http\Controllers\Controller;
use App\Models\Merchant;
use App\Models\Plan;
use App\Models\PlatformOrder;
use App\Support\SubscriptionActivationService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -17,6 +20,89 @@ class PlatformOrderController extends Controller
{
use ResolvesPlatformAdminContext;
public function create(Request $request): View
{
$this->ensurePlatformAdmin($request);
$merchants = Merchant::query()->orderBy('id')->get(['id', 'name']);
$plans = Plan::query()->orderBy('sort')->orderByDesc('id')->get();
return view('admin.platform_orders.form', [
'merchants' => $merchants,
'plans' => $plans,
'billingCycleLabels' => $this->billingCycleLabels(),
'orderTypeLabels' => $this->orderTypeLabels(),
]);
}
public function store(Request $request): RedirectResponse
{
$admin = $this->ensurePlatformAdmin($request);
$data = $request->validate([
'merchant_id' => ['required', 'integer', 'exists:merchants,id'],
'plan_id' => ['required', 'integer', 'exists:plans,id'],
'order_type' => ['required', Rule::in(array_keys($this->orderTypeLabels()))],
'quantity' => ['required', 'integer', 'min:1', 'max:120'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
'payment_channel' => ['nullable', 'string', 'max:30'],
'remark' => ['nullable', 'string', 'max:2000'],
]);
$plan = Plan::query()->findOrFail((int) $data['plan_id']);
$periodMonths = $this->periodMonthsFromBillingCycle((string) $plan->billing_cycle);
$quantity = (int) $data['quantity'];
$listAmount = (float) $plan->price * $quantity;
$discount = (float) ($data['discount_amount'] ?? 0);
$discount = max(0, min($listAmount, $discount));
$payable = max(0, $listAmount - $discount);
$now = now();
// 订单号PO + 时间 + 4位随机数足够用于当前阶段演示与手工补单
$orderNo = 'PO' . $now->format('YmdHis') . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$order = PlatformOrder::query()->create([
'merchant_id' => (int) $data['merchant_id'],
'plan_id' => $plan->id,
'created_by_admin_id' => $admin->id,
'order_no' => $orderNo,
'order_type' => (string) $data['order_type'],
'status' => 'pending',
'payment_status' => 'unpaid',
'payment_channel' => $data['payment_channel'] ?? null,
'plan_name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'period_months' => $periodMonths,
'quantity' => $quantity,
'list_amount' => $listAmount,
'discount_amount' => $discount,
'payable_amount' => $payable,
'paid_amount' => 0,
'placed_at' => $now,
'plan_snapshot' => [
'plan_id' => $plan->id,
'code' => $plan->code,
'name' => $plan->name,
'billing_cycle' => $plan->billing_cycle,
'price' => (float) $plan->price,
'list_price' => (float) $plan->list_price,
'status' => $plan->status,
'published_at' => optional($plan->published_at)->toDateTimeString(),
],
'meta' => [
'created_from' => 'manual_form',
],
'remark' => $data['remark'] ?? null,
]);
return redirect('/admin/platform-orders/' . $order->id)
->with('success', '平台订单已创建:' . $order->order_no . '(待支付/待生效)');
}
public function index(Request $request): View
{
$this->ensurePlatformAdmin($request);
@@ -610,4 +696,35 @@ class PlatformOrderController extends Controller
'failed' => '支付失败',
];
}
protected function orderTypeLabels(): array
{
return [
'new_purchase' => '新购',
'renewal' => '续费',
'upgrade' => '升级',
'downgrade' => '降级',
];
}
protected function billingCycleLabels(): array
{
return [
'monthly' => '月付',
'quarterly' => '季付',
'yearly' => '年付',
'one_time' => '一次性',
];
}
protected function periodMonthsFromBillingCycle(string $billingCycle): int
{
return match ($billingCycle) {
'monthly' => 1,
'quarterly' => 3,
'yearly' => 12,
'one_time' => 1,
default => 1,
};
}
}

View File

@@ -0,0 +1,74 @@
@extends('admin.layouts.app')
@section('title', '新建平台订单')
@section('page_title', '新建平台订单')
@section('content')
<div class="card mb-20">
<p class="muted muted-tight">用于总台运营手工创建一笔平台订单(演示/补单/线下收款录入)。</p>
<p class="muted">创建后可在「平台订单」列表中继续推进:标记支付并生效 同步订阅(形成最小收费闭环)。</p>
</div>
<form method="post" action="/admin/platform-orders" class="card form-grid">
@csrf
<label>
<span>站点</span>
<select name="merchant_id" required>
<option value="">请选择站点</option>
@foreach(($merchants ?? []) as $m)
<option value="{{ $m->id }}" @selected((string)old('merchant_id') === (string)$m->id)>{{ $m->name }}</option>
@endforeach
</select>
</label>
<label>
<span>套餐</span>
<select name="plan_id" required>
<option value="">请选择套餐</option>
@foreach(($plans ?? []) as $p)
<option value="{{ $p->id }}" @selected((string)old('plan_id') === (string)$p->id)>
{{ $p->name }}{{ $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle }} / ¥{{ number_format((float)$p->price, 2) }}
</option>
@endforeach
</select>
<small class="muted">订单会写入套餐快照plan_name / billing_cycle / plan_snapshot便于后续套餐变更时追溯。</small>
</label>
<label>
<span>订单类型</span>
<select name="order_type" required>
@foreach(($orderTypeLabels ?? []) as $value => $label)
<option value="{{ $value }}" @selected(old('order_type', 'new_purchase') === $value)>{{ $label }}</option>
@endforeach
</select>
</label>
<label>
<span>购买数量(周期数)</span>
<input type="number" min="1" max="120" name="quantity" value="{{ old('quantity', 1) }}" required>
<small class="muted">例如:月付套餐 quantity=3 表示购买 3 个月;年付套餐 quantity=2 表示购买 2 年。</small>
</label>
<label>
<span>优惠金额(可选)</span>
<input type="number" step="0.01" min="0" name="discount_amount" value="{{ old('discount_amount', 0) }}">
</label>
<label>
<span>支付渠道(可选)</span>
<input name="payment_channel" value="{{ old('payment_channel') }}" placeholder="例如offline / wechat / alipay">
<small class="muted">当前阶段仅用于记录口径,支付回执/对账后续再补。</small>
</label>
<label class="full">
<span>备注(可选)</span>
<textarea name="remark" rows="3" placeholder="可记录线下收款说明、对账备注等">{{ old('remark') }}</textarea>
</label>
<div class="form-actions">
<a href="/admin/platform-orders" class="btn-secondary">返回</a>
<button type="submit">创建订单</button>
</div>
</form>
@endsection

View File

@@ -211,7 +211,10 @@
</div>
<div class="card">
<h3>平台订单列表</h3>
<div class="flex-between">
<h3>平台订单列表</h3>
<a href="/admin/platform-orders/create" class="btn">新建平台订单</a>
</div>
<table>
<thead>
<tr>

View File

@@ -101,6 +101,8 @@ Route::prefix('admin')->group(function () {
Route::get('/platform-orders', [PlatformOrderController::class, 'index']);
Route::get('/platform-orders/export', [PlatformOrderController::class, 'export']);
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/clear-sync-errors', [PlatformOrderController::class, 'clearSyncErrors']);
Route::get('/platform-orders/{order}', [PlatformOrderController::class, 'show']);

View File

@@ -0,0 +1,107 @@
<?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 AdminPlatformOrderCreateTest 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_open_create_platform_order_form(): void
{
$this->loginAsPlatformAdmin();
// 需要至少一个套餐供选择
Plan::query()->create([
'code' => 'create_order_plan_01',
'name' => '创建订单测试套餐',
'billing_cycle' => 'monthly',
'price' => 100,
'list_price' => 100,
'status' => 'active',
'sort' => 10,
'published_at' => now(),
]);
$res = $this->get('/admin/platform-orders/create');
$res->assertOk();
$res->assertSee('新建平台订单');
$res->assertSee('站点');
$res->assertSee('套餐');
$res->assertSee('创建订单');
}
public function test_platform_admin_can_create_platform_order_and_see_it_in_show_page(): void
{
$this->loginAsPlatformAdmin();
$merchant = Merchant::query()->firstOrFail();
$plan = Plan::query()->create([
'code' => 'create_order_plan_02',
'name' => '创建订单测试套餐2',
'billing_cycle' => 'monthly',
'price' => 199,
'list_price' => 199,
'status' => 'active',
'sort' => 10,
'published_at' => now(),
]);
$res = $this->post('/admin/platform-orders', [
'merchant_id' => $merchant->id,
'plan_id' => $plan->id,
'order_type' => 'new_purchase',
'quantity' => 2,
'discount_amount' => 10,
'payment_channel' => 'offline',
'remark' => '线下补单',
]);
$res->assertRedirect();
$order = PlatformOrder::query()->latest('id')->first();
$this->assertNotNull($order);
$this->assertSame($merchant->id, $order->merchant_id);
$this->assertSame($plan->id, $order->plan_id);
$this->assertSame('pending', $order->status);
$this->assertSame('unpaid', $order->payment_status);
$this->assertSame('offline', $order->payment_channel);
// 金额口径list_amount=price*quantitypayable=list-discount
$this->assertSame(398.0, (float) $order->list_amount);
$this->assertSame(10.0, (float) $order->discount_amount);
$this->assertSame(388.0, (float) $order->payable_amount);
$show = $this->get('/admin/platform-orders/' . $order->id);
$show->assertOk();
$show->assertSee($order->order_no);
$show->assertSee('平台订单详情');
$show->assertSee('待处理');
$show->assertSee('未支付');
}
public function test_guest_cannot_open_create_platform_order_form(): void
{
$this->seed();
$this->get('/admin/platform-orders/create')
->assertRedirect('/admin/login');
}
}