feat(platform-orders): 支持总台手工创建平台订单并进入闭环
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
74
resources/views/admin/platform_orders/form.blade.php
Normal file
74
resources/views/admin/platform_orders/form.blade.php
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
107
tests/Feature/AdminPlatformOrderCreateTest.php
Normal file
107
tests/Feature/AdminPlatformOrderCreateTest.php
Normal 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*quantity,payable=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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user