AgentSkillsCN

order-processing

Golden Touch 中订单生命周期管理的完整指南

SKILL.md
--- frontmatter
name: order-processing
description: Complete guide for implementing order lifecycle management in Golden Touch

Order Processing Skill

This skill covers implementing order management features for the Golden Touch massage booking system.

When to Use This Skill

  • Creating new order-related features
  • Implementing order status transitions
  • Building order management admin pages
  • Creating order APIs for client/cabinet

Order Model Setup

Migration

php
// database/migrations/xxxx_create_orders_table.php

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->onDelete('cascade');
    $table->foreignId('therapist_id')->constrained()->onDelete('cascade');
    $table->foreignId('slot_id')->constrained()->onDelete('cascade');
    $table->foreignId('massage_type_id')->constrained()->onDelete('cascade');
    $table->foreignId('oil_type_id')->nullable()->constrained()->onDelete('set null');

    $table->string('public_token', 64)->unique();

    // Status fields
    $table->string('status')->default('NEW');
    $table->string('payment_status')->default('NOT_INVOICED');

    // Payment fields
    $table->integer('total_amount'); // in UZS
    $table->string('pay_provider')->nullable(); // payme, click
    $table->string('pay_reference')->nullable();
    $table->string('pay_url')->nullable();
    $table->timestamp('pay_expires_at')->nullable();
    $table->timestamp('paid_at')->nullable();

    // Work order fields
    $table->string('work_order_status')->default('NONE'); // NONE, READY, SENT
    $table->text('work_order_text')->nullable();
    $table->timestamp('sent_to_therapist_at')->nullable();
    $table->timestamp('ready_sent_at')->nullable();

    $table->timestamps();

    $table->index(['status', 'payment_status']);
    $table->index('created_at');
});

Model

php
// app/Models/Order.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use HasFactory;

    // Order statuses
    const STATUS_NEW = 'NEW';
    const STATUS_CONFIRMING = 'CONFIRMING';
    const STATUS_WAITING_PAYMENT = 'WAITING_PAYMENT';
    const STATUS_PAID = 'PAID';
    const STATUS_RESERVED = 'RESERVED';
    const STATUS_COMPLETED = 'COMPLETED';
    const STATUS_CANCELLED = 'CANCELLED';
    const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';

    // Payment statuses
    const PAY_NOT_INVOICED = 'NOT_INVOICED';
    const PAY_INVOICED = 'INVOICED';
    const PAY_PAID = 'PAID';
    const PAY_FAILED = 'FAILED';
    const PAY_REFUNDED = 'REFUNDED';

    // Work order statuses
    const WO_NONE = 'NONE';
    const WO_READY = 'READY';
    const WO_SENT = 'SENT';

    protected $fillable = [
        'client_id',
        'therapist_id',
        'slot_id',
        'massage_type_id',
        'oil_type_id',
        'public_token',
        'status',
        'payment_status',
        'total_amount',
        'pay_provider',
        'pay_reference',
        'pay_url',
        'pay_expires_at',
        'paid_at',
        'work_order_status',
        'work_order_text',
        'sent_to_therapist_at',
        'ready_sent_at',
    ];

    protected $casts = [
        'total_amount' => 'integer',
        'pay_expires_at' => 'datetime',
        'paid_at' => 'datetime',
        'sent_to_therapist_at' => 'datetime',
        'ready_sent_at' => 'datetime',
    ];

    // Relationships
    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class);
    }

    public function therapist(): BelongsTo
    {
        return $this->belongsTo(Therapist::class);
    }

    public function slot(): BelongsTo
    {
        return $this->belongsTo(Slot::class);
    }

    public function massageType(): BelongsTo
    {
        return $this->belongsTo(MassageType::class);
    }

    public function oilType(): BelongsTo
    {
        return $this->belongsTo(OilType::class);
    }

    public function confirmation(): HasOne
    {
        return $this->hasOne(OrderConfirmation::class);
    }

    public function quality(): HasOne
    {
        return $this->hasOne(OrderQuality::class);
    }

    public function auditLogs(): HasMany
    {
        return $this->hasMany(OrderAuditLog::class)->latest();
    }

    // Helpers
    public function isPaid(): bool
    {
        return $this->payment_status === self::PAY_PAID;
    }

    public function isReserved(): bool
    {
        return $this->status === self::STATUS_RESERVED;
    }

    public function canGenerateWorkOrder(): bool
    {
        return $this->isPaid() && $this->isReserved();
    }

    public function canComplete(): bool
    {
        return $this->isReserved() && $this->quality !== null;
    }

    public function getFormattedAmountAttribute(): string
    {
        return number_format($this->total_amount, 0, '', ' ') . ' UZS';
    }

    // Audit logging
    public function logEvent(string $event, array $data = []): OrderAuditLog
    {
        return $this->auditLogs()->create([
            'event' => $event,
            'user_id' => auth()->id(),
            'data' => $data,
        ]);
    }

    // Boot
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($order) {
            if (empty($order->public_token)) {
                $order->public_token = \Str::random(32);
            }
        });
    }
}

Related Models

OrderConfirmation

php
// app/Models/OrderConfirmation.php

class OrderConfirmation extends Model
{
    const OUTCOME_CONFIRMED = 'CONFIRMED';
    const OUTCOME_RESCHEDULE = 'RESCHEDULE';
    const OUTCOME_NO_ANSWER = 'NO_ANSWER';
    const OUTCOME_CANCELLED = 'CANCELLED';

    protected $primaryKey = 'order_id';
    public $incrementing = false;

    protected $fillable = [
        'order_id',
        'address',
        'entrance',
        'floor',
        'elevator',
        'parking',
        'landmark',
        'onsite_phone',
        'constraints',
        'space_ok',
        'pets',
        'note_to_therapist',
        'call_outcome',
        'filled_by',
        'filled_at',
    ];

    protected $casts = [
        'elevator' => 'boolean',
        'space_ok' => 'boolean',
        'pets' => 'boolean',
        'filled_at' => 'datetime',
    ];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function filledBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'filled_by');
    }
}

OrderQuality

php
// app/Models/OrderQuality.php

class OrderQuality extends Model
{
    protected $primaryKey = 'order_id';
    public $incrementing = false;

    protected $fillable = [
        'order_id',
        'rating_punctuality',
        'rating_quality',
        'rating_communication',
        'rating_overall',
        'will_order_again',
        'recommend',
        'hygiene_issue',
        'hygiene_comment',
        'improvement_comment',
        'filled_by',
        'filled_at',
    ];

    protected $casts = [
        'rating_punctuality' => 'integer',
        'rating_quality' => 'integer',
        'rating_communication' => 'integer',
        'rating_overall' => 'integer',
        'will_order_again' => 'boolean',
        'recommend' => 'boolean',
        'hygiene_issue' => 'boolean',
        'filled_at' => 'datetime',
    ];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function filledBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'filled_by');
    }
}

OrderAuditLog

php
// app/Models/OrderAuditLog.php

class OrderAuditLog extends Model
{
    public $timestamps = false;

    protected $fillable = [
        'order_id',
        'event',
        'user_id',
        'data',
    ];

    protected $casts = [
        'data' => 'array',
        'created_at' => 'datetime',
    ];

    protected static function boot()
    {
        parent::boot();
        static::creating(fn($log) => $log->created_at = now());
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Service Implementation

php
// app/Services/OrderService.php

namespace App\Services;

use App\Models\{Order, Client, Slot, OrderConfirmation, OrderQuality};
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class OrderService
{
    public function __construct(
        private SlotService $slotService,
        private PaymentService $paymentService,
        private TelegramService $telegram
    ) {}

    /**
     * Create order from public booking form
     */
    public function createFromBooking(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            // Lock slot
            $slot = Slot::lockForUpdate()->findOrFail($data['slot_id']);

            if (!$slot->canBook()) {
                throw new \App\Exceptions\SlotNotAvailableException();
            }

            // Get or create client
            $client = Client::firstOrCreate(
                ['phone' => $this->normalizePhone($data['phone'])],
                [
                    'name' => $data['name'] ?? null,
                    'comment' => $data['comment'] ?? null,
                ]
            );

            // Create order
            $order = Order::create([
                'client_id' => $client->id,
                'therapist_id' => $slot->therapist_id,
                'slot_id' => $slot->id,
                'massage_type_id' => $data['massage_type_id'],
                'oil_type_id' => $data['oil_type_id'] ?? null,
                'total_amount' => config('booking.default_price', 500000),
                'status' => Order::STATUS_NEW,
                'payment_status' => Order::PAY_NOT_INVOICED,
            ]);

            // Mark slot as pending
            $this->slotService->markPending($slot, $order);

            // Log and notify
            $order->logEvent('created');
            $this->telegram->sendNew($order);

            return $order->load(['client', 'therapist', 'slot', 'massageType']);
        });
    }

    /**
     * Normalize phone number to +998 format
     */
    private function normalizePhone(string $phone): string
    {
        $digits = preg_replace('/\D/', '', $phone);

        if (strlen($digits) === 9) {
            return '+998' . $digits;
        }

        if (strlen($digits) === 12 && str_starts_with($digits, '998')) {
            return '+' . $digits;
        }

        return '+' . $digits;
    }

    /**
     * Update order status with validation
     */
    public function updateStatus(Order $order, string $newStatus): void
    {
        $validTransitions = [
            Order::STATUS_NEW => [Order::STATUS_CONFIRMING, Order::STATUS_CANCELLED],
            Order::STATUS_CONFIRMING => [Order::STATUS_WAITING_PAYMENT, Order::STATUS_CANCELLED],
            Order::STATUS_WAITING_PAYMENT => [Order::STATUS_PAID, Order::STATUS_CANCELLED],
            Order::STATUS_PAID => [Order::STATUS_RESERVED, Order::STATUS_CANCELLED],
            Order::STATUS_RESERVED => [Order::STATUS_COMPLETED, Order::STATUS_CANCEL_REQUESTED],
            Order::STATUS_CANCEL_REQUESTED => [Order::STATUS_RESERVED, Order::STATUS_CANCELLED],
        ];

        if (!isset($validTransitions[$order->status]) ||
            !in_array($newStatus, $validTransitions[$order->status])) {
            throw new \App\Exceptions\InvalidStatusTransitionException(
                "Cannot transition from {$order->status} to {$newStatus}"
            );
        }

        $oldStatus = $order->status;
        $order->update(['status' => $newStatus]);
        $order->logEvent('status_changed', [
            'from' => $oldStatus,
            'to' => $newStatus,
        ]);
    }
}

API Endpoints

Public Booking API

php
// routes/api.php

Route::prefix('booking')->group(function () {
    Route::get('/therapists', [BookingController::class, 'therapists']);
    Route::get('/therapists/{therapist}/slots', [BookingController::class, 'slots']);
    Route::get('/massage-types', [BookingController::class, 'massageTypes']);
    Route::get('/oil-types', [BookingController::class, 'oilTypes']);
    Route::post('/orders', [BookingController::class, 'store']);
});

// app/Http/Controllers/Api/BookingController.php

class BookingController extends Controller
{
    public function __construct(private OrderService $orderService) {}

    public function therapists()
    {
        $therapists = Therapist::where('is_active', true)
            ->select(['id', 'name', 'photo', 'rating'])
            ->get();

        return response()->json(['therapists' => $therapists]);
    }

    public function slots(Therapist $therapist, Request $request)
    {
        $date = $request->get('date', now()->toDateString());

        $slots = Slot::where('therapist_id', $therapist->id)
            ->whereDate('date', $date)
            ->orderBy('start_time')
            ->get()
            ->map(fn($slot) => [
                'id' => $slot->id,
                'time' => substr($slot->start_time, 0, 5),
                'status' => $slot->status,
                'bookable' => $slot->canBook(),
            ]);

        return response()->json(['slots' => $slots]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'slot_id' => 'required|exists:slots,id',
            'massage_type_id' => 'required|exists:massage_types,id',
            'oil_type_id' => 'nullable|exists:oil_types,id',
            'phone' => 'required|string|regex:/^\+?998\d{9}$/',
            'name' => 'nullable|string|max:100',
            'comment' => 'nullable|string|max:300',
            'privacy_consent' => 'required|accepted',
        ]);

        try {
            $order = $this->orderService->createFromBooking($validated);

            return response()->json([
                'success' => true,
                'message' => 'Request received. Dispatcher will confirm shortly.',
                'order_id' => $order->id,
            ], 201);

        } catch (\App\Exceptions\SlotNotAvailableException $e) {
            return response()->json([
                'success' => false,
                'message' => 'This time is unavailable. Please choose another slot.',
            ], 422);
        }
    }
}

Vue Components

Order Card (Admin)

vue
<!-- resources/js/Pages/Admin/Orders/Show.vue -->

<script setup>
import { ref } from 'vue'
import { useForm, router } from '@inertiajs/vue3'
import AdminLayout from '@/Layouts/AdminLayout.vue'

const props = defineProps({
    order: Object,
})

// Confirmation form
const confirmationForm = useForm({
    address: props.order.confirmation?.address ?? '',
    entrance: props.order.confirmation?.entrance ?? '',
    floor: props.order.confirmation?.floor ?? '',
    elevator: props.order.confirmation?.elevator ?? false,
    parking: props.order.confirmation?.parking ?? '',
    landmark: props.order.confirmation?.landmark ?? '',
    onsite_phone: props.order.confirmation?.onsite_phone ?? '',
    constraints: props.order.confirmation?.constraints ?? '',
    space_ok: props.order.confirmation?.space_ok ?? false,
    pets: props.order.confirmation?.pets ?? false,
    note_to_therapist: props.order.confirmation?.note_to_therapist ?? '',
    call_outcome: props.order.confirmation?.call_outcome ?? '',
})

const saveConfirmation = () => {
    confirmationForm.post(route('admin.orders.confirmation', props.order.id))
}

// Payment actions
const createInvoice = (provider) => {
    router.post(route('admin.orders.invoice', props.order.id), { provider })
}

const confirmBooking = () => {
    router.post(route('admin.orders.confirm-booking', props.order.id))
}
</script>

<template>
    <AdminLayout>
        <div class="order-card">
            <!-- Block A: Client -->
            <section class="card">
                <h3>Client</h3>
                <p><strong>Phone:</strong>
                    <a :href="'tel:' + order.client.phone">{{ order.client.phone }}</a>
                </p>
                <p><strong>Name:</strong> {{ order.client.name || '—' }}</p>
            </section>

            <!-- Block B: Order Details -->
            <section class="card">
                <h3>Order Details</h3>
                <p><strong>Massage:</strong> {{ order.massage_type.name }}</p>
                <p v-if="order.oil_type"><strong>Oil:</strong> {{ order.oil_type.name }}</p>
                <p><strong>Therapist:</strong> {{ order.therapist.name }}</p>
                <p><strong>Date/Time:</strong> {{ order.slot.date }} {{ order.slot.start_time }}</p>
                <p><strong>Status:</strong> {{ order.status }}</p>
                <p><strong>Payment:</strong> {{ order.payment_status }}</p>
            </section>

            <!-- Block C: Confirmation Form -->
            <section class="card">
                <h3>Confirmation Form</h3>
                <form @submit.prevent="saveConfirmation">
                    <div class="form-group">
                        <label>Address *</label>
                        <input v-model="confirmationForm.address" required />
                    </div>
                    <div class="form-row">
                        <div class="form-group">
                            <label>Entrance</label>
                            <input v-model="confirmationForm.entrance" />
                        </div>
                        <div class="form-group">
                            <label>Floor</label>
                            <input v-model="confirmationForm.floor" />
                        </div>
                    </div>
                    <div class="form-group">
                        <label>
                            <input type="checkbox" v-model="confirmationForm.elevator" />
                            Elevator available
                        </label>
                    </div>
                    <div class="form-group">
                        <label>Call Outcome *</label>
                        <select v-model="confirmationForm.call_outcome" required>
                            <option value="">Select...</option>
                            <option value="CONFIRMED">Confirmed</option>
                            <option value="RESCHEDULE">Reschedule</option>
                            <option value="NO_ANSWER">No Answer</option>
                            <option value="CANCELLED">Cancelled</option>
                        </select>
                    </div>
                    <button type="submit" :disabled="confirmationForm.processing">
                        Save Confirmation
                    </button>
                </form>
            </section>

            <!-- Block D: Payment -->
            <section class="card" v-if="order.confirmation?.call_outcome === 'CONFIRMED'">
                <h3>Payment</h3>
                <template v-if="order.payment_status === 'NOT_INVOICED'">
                    <button @click="createInvoice('payme')">Create Payme Invoice</button>
                    <button @click="createInvoice('click')">Create Click Invoice</button>
                </template>
                <template v-else-if="order.payment_status === 'INVOICED'">
                    <p>Invoice sent. Waiting for payment...</p>
                    <p><a :href="order.pay_url" target="_blank">Payment Link</a></p>
                </template>
                <template v-else-if="order.payment_status === 'PAID'">
                    <p class="success">Payment confirmed</p>
                </template>
            </section>

            <!-- Block E: Booking -->
            <section class="card" v-if="order.payment_status === 'PAID' && order.status !== 'RESERVED'">
                <h3>Booking</h3>
                <button @click="confirmBooking">Confirm Booking (Reserve Slot)</button>
            </section>
        </div>
    </AdminLayout>
</template>

Testing

php
// tests/Feature/OrderTest.php

class OrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_client_can_create_order()
    {
        $therapist = Therapist::factory()->create();
        $slot = Slot::factory()->free()->create([
            'therapist_id' => $therapist->id,
            'date' => now()->addDay()->toDateString(),
        ]);
        $massageType = MassageType::factory()->create();

        $response = $this->postJson('/api/booking/orders', [
            'slot_id' => $slot->id,
            'massage_type_id' => $massageType->id,
            'phone' => '+998901234567',
            'privacy_consent' => true,
        ]);

        $response->assertStatus(201);
        $this->assertDatabaseHas('orders', [
            'slot_id' => $slot->id,
            'status' => 'NEW',
        ]);
        $this->assertEquals('PENDING', $slot->fresh()->status);
    }

    public function test_cannot_book_taken_slot()
    {
        $slot = Slot::factory()->pending()->create();
        $massageType = MassageType::factory()->create();

        $response = $this->postJson('/api/booking/orders', [
            'slot_id' => $slot->id,
            'massage_type_id' => $massageType->id,
            'phone' => '+998901234567',
            'privacy_consent' => true,
        ]);

        $response->assertStatus(422);
    }

    public function test_dispatcher_can_complete_order_flow()
    {
        $dispatcher = User::factory()->create();
        $dispatcher->assignRole('dispatcher');

        $order = Order::factory()->create(['status' => 'NEW']);

        // Fill confirmation
        $this->actingAs($dispatcher)
            ->post(route('admin.orders.confirmation', $order), [
                'address' => 'Test Address',
                'call_outcome' => 'CONFIRMED',
            ])
            ->assertRedirect();

        $this->assertEquals('CONFIRMING', $order->fresh()->status);
    }
}

Checklist

  • Orders table migration created
  • Order model with constants and relationships
  • OrderConfirmation model
  • OrderQuality model
  • OrderAuditLog model
  • OrderService with all business logic
  • Double-booking prevention with DB locks
  • Booking API endpoints
  • Admin order management pages
  • Status transition validation
  • Audit logging for all actions
  • Feature tests for critical flows