Skip to content

Commit f66bfa0

Browse files
feat: add order queue
1 parent 487e628 commit f66bfa0

7 files changed

Lines changed: 817 additions & 48 deletions

File tree

app/Http/Controllers/Orders/OrderItemController.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,49 @@
22

33
namespace App\Http\Controllers\Orders;
44

5+
use App\Http\Concerns\ExtractsFilters;
56
use App\Http\Controllers\Controller;
67
use App\Http\Requests\Orders\CancelOrderItemRequest;
78
use App\Http\Requests\Orders\StoreOrderItemRequest;
89
use App\Http\Requests\Orders\UpdateOrderItemRequest;
910
use App\Models\Order;
1011
use App\Models\OrderItem;
12+
use App\Services\AccessControlService;
1113
use App\Services\OrderService;
1214
use Illuminate\Http\JsonResponse;
1315
use Illuminate\Http\RedirectResponse;
1416
use Illuminate\Http\Request;
17+
use Inertia\Inertia;
18+
use Inertia\Response;
1519

1620
class OrderItemController extends Controller
1721
{
22+
use ExtractsFilters;
23+
1824
public function __construct(
1925
private OrderService $orderService,
26+
private AccessControlService $accessControl,
2027
) {}
2128

29+
public function index(Request $request): Response
30+
{
31+
$filters = $this->extractFilters($request, [
32+
'outlet_id',
33+
'department_id',
34+
'status',
35+
'date',
36+
'per_page',
37+
]);
38+
39+
if ($filters['date'] === '') {
40+
$filters['date'] = today()->toDateString();
41+
}
42+
43+
$scope = $this->accessControl->resolveSessionScope($request);
44+
45+
return Inertia::render('orders/items', $this->orderService->getItemsData($filters, $scope));
46+
}
47+
2248
public function store(StoreOrderItemRequest $request, Order $order): RedirectResponse
2349
{
2450
abort_if(
@@ -71,6 +97,19 @@ public function updateStatus(Request $request, Order $order, OrderItem $orderIte
7197
return response()->json(['success' => true, 'status' => $orderItem->status]);
7298
}
7399

100+
public function transferDepartment(Request $request, Order $order, OrderItem $orderItem): JsonResponse
101+
{
102+
abort_if($orderItem->order_id !== $order->id, 404, 'Item does not belong to this order.');
103+
104+
$validated = $request->validate([
105+
'department_id' => ['required', 'integer', 'exists:outlet_departments,id'],
106+
]);
107+
108+
$this->orderService->transferItemDepartment($orderItem, $validated['department_id']);
109+
110+
return response()->json(['success' => true]);
111+
}
112+
74113
public function cancel(CancelOrderItemRequest $request, Order $order, OrderItem $orderItem): RedirectResponse
75114
{
76115
abort_if(

app/Services/OrderService.php

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,41 @@ public function __construct(
6363
'cancelled' => 'cancelled_at',
6464
];
6565

66+
public function getItemsData(array $filters, array $scope): array
67+
{
68+
$scopedOutletId = $this->outletIdFromScope($scope);
69+
70+
$query = OrderItem::with([
71+
'order:id,order_number,order_type,outlet_id,status',
72+
'order.orderTables.diningTable:id,name',
73+
'food:id,name',
74+
'foodVariant:id,name',
75+
'preparationDepartment:id,name',
76+
'addons.addon:id,name',
77+
])
78+
->whereHas('order', function ($q) use ($scopedOutletId, $filters) {
79+
$q->whereNotIn('status', ['completed', 'cancelled'])
80+
->when($scopedOutletId !== null, fn ($b) => $b->where('outlet_id', $scopedOutletId))
81+
->when($scopedOutletId === null && $filters['outlet_id'] !== '', fn ($b) => $b->where('outlet_id', $filters['outlet_id']));
82+
})
83+
->when($filters['department_id'] !== '', fn ($b) => $b->where('preparation_department_id', $filters['department_id']))
84+
->when($filters['status'] !== '', fn ($b) => $b->where('status', $filters['status']))
85+
->when($filters['date'] !== '', fn ($b) => $b->whereDate('created_at', $filters['date']))
86+
->orderBy('created_at');
87+
88+
$items = $query->paginate($this->perPage($query, $filters['per_page']))->withQueryString();
89+
90+
$outlets = $this->scopeOutlets($scope);
91+
92+
$departments = OutletDepartment::where('can_prepare_order', true)
93+
->where('is_active', true)
94+
->when($scopedOutletId !== null, fn ($b) => $b->where('outlet_id', $scopedOutletId))
95+
->orderBy('name')
96+
->get(['id', 'name']);
97+
98+
return compact('items', 'outlets', 'departments', 'filters');
99+
}
100+
66101
public function getIndexData(array $filters, array $scope): array
67102
{
68103
$scopedOutletId = $this->outletIdFromScope($scope);
@@ -157,7 +192,13 @@ public function getShowData(Order $order): array
157192

158193
$availableTables = $this->getAvailableDiningTables($order->outlet_id, $order->id);
159194

160-
return compact('order', 'availableFoods', 'availableTables');
195+
$departments = OutletDepartment::where('outlet_id', $order->outlet_id)
196+
->where('can_prepare_order', true)
197+
->where('is_active', true)
198+
->orderBy('name')
199+
->get(['id', 'name']);
200+
201+
return compact('order', 'availableFoods', 'availableTables', 'departments');
161202
}
162203

163204
public function createOrder(array $data): Order
@@ -488,6 +529,109 @@ public function changeItemStatus(OrderItem $item, string $toStatus): void
488529
});
489530
}
490531

532+
public function transferItemDepartment(OrderItem $item, int $targetDepartmentId): void
533+
{
534+
abort_if(
535+
! in_array($item->status, ['stock_reserved', 'sent_to_kitchen']),
536+
422,
537+
'Item can only be transferred before preparation starts.'
538+
);
539+
540+
DB::transaction(function () use ($item, $targetDepartmentId) {
541+
$item->loadMissing(['order', 'food', 'addons.addon']);
542+
543+
$targetDept = OutletDepartment::where('id', $targetDepartmentId)
544+
->where('outlet_id', $item->order->outlet_id)
545+
->where('can_prepare_order', true)
546+
->where('is_active', true)
547+
->firstOrFail();
548+
549+
abort_if(
550+
$targetDept->id === $item->preparation_department_id,
551+
422,
552+
'Item is already assigned to this department.'
553+
);
554+
555+
$deductions = $this->orderItemDeductions($item);
556+
557+
if ($deductions === []) {
558+
$item->update(['preparation_department_id' => $targetDept->id]);
559+
return;
560+
}
561+
562+
$targetWh = $this->findDepartmentWarehouse($targetDept);
563+
564+
if (! $targetWh) {
565+
throw ValidationException::withMessages([
566+
'department' => "Department \"{$targetDept->name}\" has no active warehouse configured.",
567+
]);
568+
}
569+
570+
if (! $this->warehouseCanFulfill($targetWh, $deductions)) {
571+
$this->autoTransferIngredientsToWarehouse($targetWh, $deductions, $item->order);
572+
}
573+
574+
// Release stock reserved in the current department
575+
$this->releaseOrderItemStock($item);
576+
577+
// Assign to target department and reserve stock there
578+
$item->update(['preparation_department_id' => $targetDept->id]);
579+
580+
$this->reserveOrderItemStock($item->fresh(['food', 'addons.addon', 'order']));
581+
});
582+
}
583+
584+
private function autoTransferIngredientsToWarehouse(Warehouse $targetWh, array $deductions, Order $order): void
585+
{
586+
// Pass 1: outlet warehouse
587+
$outletWarehouses = Warehouse::where('outlet_id', $order->outlet_id)
588+
->where('type', 'outlet')
589+
->where('is_active', true)
590+
->orderByDesc('is_default')
591+
->get();
592+
593+
foreach ($outletWarehouses as $outletWh) {
594+
if ($this->warehouseCanFulfill($outletWh, $deductions)) {
595+
$this->autoTransferIngredients($outletWh, $targetWh, $deductions, $order);
596+
return;
597+
}
598+
}
599+
600+
// Pass 2: central warehouse → outlet warehouse → target
601+
$intermediateWh = $outletWarehouses->first()
602+
?? Warehouse::where('outlet_id', $order->outlet_id)
603+
->where('is_active', true)
604+
->orderByDesc('is_default')
605+
->first();
606+
607+
if ($intermediateWh) {
608+
$centralWarehouses = Warehouse::where('type', 'central')
609+
->where('is_active', true)
610+
->orderByDesc('is_default')
611+
->get();
612+
613+
foreach ($centralWarehouses as $centralWh) {
614+
if ($this->warehouseCanFulfill($centralWh, $deductions)) {
615+
$this->autoTransferIngredients($centralWh, $intermediateWh, $deductions, $order);
616+
$this->autoTransferIngredients($intermediateWh, $targetWh, $deductions, $order);
617+
return;
618+
}
619+
}
620+
}
621+
622+
// Nothing had stock — throw detailed error
623+
foreach ($deductions as $ingredientId => $deduction) {
624+
$ingredient = Ingredient::findOrFail((int) $ingredientId);
625+
$available = $this->warehouseAvailableStock($targetWh->id, $ingredientId);
626+
627+
if ($available < (float) $deduction['total_quantity']) {
628+
throw ValidationException::withMessages([
629+
'stock' => "Insufficient stock for \"{$ingredient->name}\" across all warehouses. Available: {$available}, Requested: {$deduction['total_quantity']}.",
630+
]);
631+
}
632+
}
633+
}
634+
491635
public function syncOrderStatus(Order $order): void
492636
{
493637
$items = $order->items()->get(['status']);
@@ -1029,59 +1173,23 @@ private function selectPreparationDepartment(OrderItem $item): OutletDepartment
10291173
}
10301174
}
10311175

1032-
// Pass 2: outlet warehouse has stock — instant-transfer to department warehouse
1033-
$outletWarehouses = Warehouse::where('outlet_id', $item->order->outlet_id)
1034-
->where('type', 'outlet')
1035-
->where('is_active', true)
1036-
->orderByDesc('is_default')
1037-
->get();
1038-
1039-
foreach ($outletWarehouses as $outletWh) {
1040-
if (! $this->warehouseCanFulfill($outletWh, $deductions)) {
1176+
// Passes 2 & 3: try to get stock into a department warehouse via outlet/central transfer
1177+
foreach ($departments as $dept) {
1178+
$deptWh = $this->findDepartmentWarehouse($dept);
1179+
if (! $deptWh) {
10411180
continue;
10421181
}
1043-
foreach ($departments as $dept) {
1044-
$deptWh = $this->findDepartmentWarehouse($dept);
1045-
if (! $deptWh) {
1046-
continue;
1047-
}
1048-
$this->autoTransferIngredients($outletWh, $deptWh, $deductions, $item->order);
1182+
try {
1183+
$this->autoTransferIngredientsToWarehouse($deptWh, $deductions, $item->order);
10491184
return $dept;
1050-
}
1051-
}
1052-
1053-
// Pass 3: central warehouse has stock — instant-transfer central → outlet → department
1054-
$centralWarehouses = Warehouse::where('type', 'central')
1055-
->where('is_active', true)
1056-
->orderByDesc('is_default')
1057-
->get();
1058-
1059-
$intermediateOutletWh = $outletWarehouses->first()
1060-
?? Warehouse::where('outlet_id', $item->order->outlet_id)
1061-
->where('is_active', true)
1062-
->orderByDesc('is_default')
1063-
->first();
1064-
1065-
if ($intermediateOutletWh) {
1066-
foreach ($centralWarehouses as $centralWh) {
1067-
if (! $this->warehouseCanFulfill($centralWh, $deductions)) {
1068-
continue;
1069-
}
1070-
foreach ($departments as $dept) {
1071-
$deptWh = $this->findDepartmentWarehouse($dept);
1072-
if (! $deptWh) {
1073-
continue;
1074-
}
1075-
$this->autoTransferIngredients($centralWh, $intermediateOutletWh, $deductions, $item->order);
1076-
$this->autoTransferIngredients($intermediateOutletWh, $deptWh, $deductions, $item->order);
1077-
return $dept;
1078-
}
1185+
} catch (ValidationException) {
1186+
continue;
10791187
}
10801188
}
10811189

10821190
// All passes failed — report the specific shortage
1083-
$fallback = $departments->first();
1084-
$fallbackWh = $this->findDepartmentWarehouse($fallback);
1191+
$fallback = $departments->first();
1192+
$fallbackWh = $this->findDepartmentWarehouse($fallback);
10851193

10861194
foreach ($deductions as $ingredientId => $deduction) {
10871195
$ingredient = Ingredient::findOrFail((int) $ingredientId);

database/seeders/OrderPermissionsSeeder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public function run(): void
1717
['module' => 'orders', 'action' => 'delete', 'name' => 'Orders Delete'],
1818
['module' => 'orders', 'action' => 'cancel', 'name' => 'Orders Cancel'],
1919
['module' => 'orders', 'action' => 'status-update', 'name' => 'Orders Status Update'],
20+
['module' => 'order-items', 'action' => 'view', 'name' => 'Order Items View'],
2021
['module' => 'order-items', 'action' => 'create', 'name' => 'Order Items Create'],
2122
['module' => 'order-items', 'action' => 'update', 'name' => 'Order Items Update'],
2223
['module' => 'order-items', 'action' => 'cancel', 'name' => 'Order Items Cancel'],

resources/js/components/app-sidebar.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { index as diningTablesIndex } from '@/routes/dining-tables';
5151
import { index as diningTableLayoutIndex } from '@/routes/dining-table-layout';
5252
import { index as reservationsIndex } from '@/routes/reservations';
5353
import { index as ordersIndex } from '@/routes/orders';
54+
import { index as orderItemsIndex } from '@/routes/order-items';
5455
import { index as posIndex } from '@/routes/pos';
5556
import { index as qrOrdersIndex } from '@/routes/qr-orders';
5657
import type { Auth } from '@/types';
@@ -197,6 +198,17 @@ function buildDynamicGroups(auth: Auth): MenuGroup[] {
197198
});
198199
}
199200

201+
if (can('order-items-view')) {
202+
groups.push({
203+
id: 'order-items',
204+
label: 'Operations',
205+
title: 'Order Queue',
206+
icon: 'restaurant',
207+
href: orderItemsIndex.url(),
208+
activeMatch: [orderItemsIndex.url()],
209+
});
210+
}
211+
200212
if (can('reservations-view')) {
201213
groups.push({
202214
id: 'reservations',

0 commit comments

Comments
 (0)