feat: Implement QR code generation and ops interface scaffold
Phase 1: QR Code Generation
- Create GenerateQrCodeOnOrderCreated listener
- Generate secure random token on order creation
- Create SVG QR codes pointing to /ops/orders/{token}
- Store QR token and generation timestamp on order record
- Register listener in EventServiceProvider
Phase 2: Ops Controller & Routes
- Create OpsController with showOrder() landing page
- Implement confirmPacking() to capture dimensions via form
- Add markInspectionPassed() and flagInspectionIssue() methods
- Implement getAvailableActions() state machine for UI
- Add routes: GET /ops/orders/{token}, POST /ops/orders/{id}/pack, etc.
- Token-gated access, requires auth middleware
Next: Create Blade templates for state-driven UI
This commit is contained in:
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class OpsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display order details via QR token (state-machine driven UI)
|
||||||
|
*
|
||||||
|
* GET /ops/orders/{token}
|
||||||
|
*/
|
||||||
|
public function showOrder(Request $request, $token)
|
||||||
|
{
|
||||||
|
// Find order by QR token
|
||||||
|
$order = Order::where('qr_token', $token)->first();
|
||||||
|
|
||||||
|
if (! $order) {
|
||||||
|
Log::warning('QR token not found', ['token' => substr($token, 0, 8) . '...']);
|
||||||
|
|
||||||
|
return response()->view('ops.order-not-found', [], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user authorization - must be logged in ops user
|
||||||
|
// TODO: Add middleware to enforce this
|
||||||
|
if (! auth()->check() || ! auth()->user()->can('access-ops')) {
|
||||||
|
Log::warning('Unauthorized QR access attempt', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->view('ops.unauthorized', [], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('QR order accessed', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'status' => $order->status,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Determine available actions based on order status
|
||||||
|
$availableActions = $this->getAvailableActions($order);
|
||||||
|
|
||||||
|
return view('ops.order-detail', [
|
||||||
|
'order' => $order,
|
||||||
|
'availableActions' => $availableActions,
|
||||||
|
'qrUrl' => route('ops.order.show', ['token' => $order->qr_token]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm packing via QR form
|
||||||
|
*
|
||||||
|
* POST /ops/orders/{id}/pack
|
||||||
|
*/
|
||||||
|
public function confirmPacking(Request $request, Order $order)
|
||||||
|
{
|
||||||
|
// Validate input
|
||||||
|
$validated = $request->validate([
|
||||||
|
'weight' => ['required', 'numeric', 'min:0.1'],
|
||||||
|
'width' => ['required', 'numeric', 'min:1'],
|
||||||
|
'length' => ['required', 'numeric', 'min:1'],
|
||||||
|
'height' => ['required', 'numeric', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure order is in packing state
|
||||||
|
if ($order->status !== 'packing' && $order->status !== 'inspection') {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Order cannot be packed in current state',
|
||||||
|
'current_status' => $order->status,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save packing dimensions
|
||||||
|
$order->update([
|
||||||
|
'packing_width' => $validated['width'],
|
||||||
|
'packing_length' => $validated['length'],
|
||||||
|
'packing_weight' => $validated['weight'],
|
||||||
|
'packing_completed_at' => now(),
|
||||||
|
'packed_by' => auth()->id(),
|
||||||
|
'status' => 'packing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Order packed via QR', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'packed_by' => auth()->user()->name,
|
||||||
|
'dimensions' => "{$validated['width']}x{$validated['length']}cm, {$validated['weight']}kg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emit event to trigger Slack notification and Trello update
|
||||||
|
\App\Events\OrderPacked::dispatch($order);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Order packed successfully',
|
||||||
|
'order' => [
|
||||||
|
'uuid' => $order->uuid,
|
||||||
|
'number' => $order->order_number,
|
||||||
|
'status' => $order->status,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark inspection passed via QR
|
||||||
|
*
|
||||||
|
* POST /ops/orders/{id}/inspection-passed
|
||||||
|
*/
|
||||||
|
public function markInspectionPassed(Request $request, Order $order)
|
||||||
|
{
|
||||||
|
if ($order->status !== 'inspection') {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Order is not in inspection state',
|
||||||
|
'current_status' => $order->status,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and emit event
|
||||||
|
$order->update(['status' => 'packing']);
|
||||||
|
|
||||||
|
Log::info('Inspection passed via QR', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'marked_by' => auth()->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emit event for listeners to handle Slack/Trello updates
|
||||||
|
// TODO: Create InspectionPassed event
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Inspection passed',
|
||||||
|
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag inspection issue via QR
|
||||||
|
*
|
||||||
|
* POST /ops/orders/{id}/inspection-failed
|
||||||
|
*/
|
||||||
|
public function flagInspectionIssue(Request $request, Order $order)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'issue_description' => ['required', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($order->status !== 'inspection') {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Order is not in inspection state',
|
||||||
|
'current_status' => $order->status,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and store issue
|
||||||
|
$order->update([
|
||||||
|
'status' => 'review_required',
|
||||||
|
'notes' => $validated['issue_description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::warning('Inspection issue flagged via QR', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'flagged_by' => auth()->user()->name,
|
||||||
|
'issue' => $validated['issue_description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Emit event for listeners to alert ops
|
||||||
|
// TODO: Create InspectionFailed event
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Issue flagged - order moved to review',
|
||||||
|
'order' => ['uuid' => $order->uuid, 'status' => $order->status],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which actions are available based on order status
|
||||||
|
*/
|
||||||
|
private function getAvailableActions(Order $order): array
|
||||||
|
{
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
match ($order->status) {
|
||||||
|
'inspection' => [
|
||||||
|
$actions['markInspectionPassed'] = true,
|
||||||
|
$actions['flagInspectionIssue'] = true,
|
||||||
|
],
|
||||||
|
'packing' => [
|
||||||
|
$actions['confirmPacking'] = true,
|
||||||
|
],
|
||||||
|
'ready_to_ship', 'awaiting_collection', 'in_transit' => [
|
||||||
|
$actions['readOnly'] = true,
|
||||||
|
],
|
||||||
|
default => []
|
||||||
|
};
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Events\OrderCreated;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
|
class GenerateQrCodeOnOrderCreated
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the event.
|
||||||
|
*/
|
||||||
|
public function handle(OrderCreated $event): void
|
||||||
|
{
|
||||||
|
$order = $event->order;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate secure token if not already set
|
||||||
|
if (! $order->qr_token) {
|
||||||
|
$order->qr_token = Str::random(32);
|
||||||
|
$order->qr_generated_at = now();
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code pointing to ops interface
|
||||||
|
$qrUrl = route('ops.order.show', ['token' => $order->qr_token]);
|
||||||
|
|
||||||
|
Log::debug('Generating QR code', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'qr_url' => $qrUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate QR code SVG
|
||||||
|
$qrCode = QrCode::size(300)
|
||||||
|
->errorCorrection('H')
|
||||||
|
->generate($qrUrl);
|
||||||
|
|
||||||
|
// Save QR code image
|
||||||
|
$path = "qr-codes/{$order->uuid}.svg";
|
||||||
|
Storage::disk('public')->put($path, $qrCode);
|
||||||
|
|
||||||
|
Log::info('QR code generated successfully', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'path' => $path,
|
||||||
|
'token' => substr($order->qr_token, 0, 8) . '...',
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to generate QR code', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use App\Events\ReadyToShipIntent;
|
|||||||
use App\Events\ShipmentCreated;
|
use App\Events\ShipmentCreated;
|
||||||
use App\Events\ShipmentCreationFailed;
|
use App\Events\ShipmentCreationFailed;
|
||||||
use App\Listeners\CreateShipmentOnReadyToShip;
|
use App\Listeners\CreateShipmentOnReadyToShip;
|
||||||
|
use App\Listeners\GenerateQrCodeOnOrderCreated;
|
||||||
use App\Listeners\NotifySlackOnOrderCreated;
|
use App\Listeners\NotifySlackOnOrderCreated;
|
||||||
use App\Listeners\NotifySlackOnOrderPacked;
|
use App\Listeners\NotifySlackOnOrderPacked;
|
||||||
use App\Listeners\NotifySlackOnParcelCollected;
|
use App\Listeners\NotifySlackOnParcelCollected;
|
||||||
@@ -39,6 +40,7 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
// Order events
|
// Order events
|
||||||
OrderCreated::class => [
|
OrderCreated::class => [
|
||||||
NotifySlackOnOrderCreated::class,
|
NotifySlackOnOrderCreated::class,
|
||||||
|
GenerateQrCodeOnOrderCreated::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Packing events
|
// Packing events
|
||||||
|
|||||||
+10
-1
@@ -10,6 +10,7 @@ use App\Http\Controllers\CartController;
|
|||||||
use App\Http\Controllers\OrderController;
|
use App\Http\Controllers\OrderController;
|
||||||
use App\Http\Controllers\CustomOrderController;
|
use App\Http\Controllers\CustomOrderController;
|
||||||
use App\Http\Controllers\Auth\GoogleAuthController;
|
use App\Http\Controllers\Auth\GoogleAuthController;
|
||||||
|
use App\Http\Controllers\OpsController;
|
||||||
use App\Http\Controllers\PackingController;
|
use App\Http\Controllers\PackingController;
|
||||||
use App\Http\Controllers\ShippingController;
|
use App\Http\Controllers\ShippingController;
|
||||||
|
|
||||||
@@ -71,7 +72,15 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/orders/{order:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('orders.pack');
|
Route::post('/orders/{order:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('orders.pack');
|
||||||
Route::post('/custom-orders/{customOrder:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('custom-orders.pack');
|
Route::post('/custom-orders/{customOrder:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('custom-orders.pack');
|
||||||
Route::post('/orders/{order:uuid}/ship', [ShippingController::class, 'createShipment'])->name('orders.ship');
|
Route::post('/orders/{order:uuid}/ship', [ShippingController::class, 'createShipment'])->name('orders.ship');
|
||||||
Route::post('/custom-orders/{customOrder:uuid}/ship', [ShippingController::class, 'createShipment'])->name('custom-orders.ship');
|
|
||||||
|
// QR-based operations (ops interface)
|
||||||
|
Route::post('/ops/orders/{order:uuid}/pack', [OpsController::class, 'confirmPacking'])->name('ops.order.pack');
|
||||||
|
Route::post('/ops/orders/{order:uuid}/inspection-passed', [OpsController::class, 'markInspectionPassed'])->name('ops.order.inspection-passed');
|
||||||
|
Route::post('/ops/orders/{order:uuid}/inspection-failed', [OpsController::class, 'flagInspectionIssue'])->name('ops.order.inspection-failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// QR landing page (public but token-gated)
|
||||||
|
Route::get('/ops/orders/{token}', [OpsController::class, 'showOrder'])->name('ops.order.show' Route::post('/custom-orders/{customOrder:uuid}/ship', [ShippingController::class, 'createShipment'])->name('custom-orders.ship');
|
||||||
});
|
});
|
||||||
|
|
||||||
// use Illuminate\Support\Facades\Route;
|
// use Illuminate\Support\Facades\Route;
|
||||||
|
|||||||
Reference in New Issue
Block a user