Files
ladill-pos/app/Http/Controllers/Pos/KitchenController.php
T
isaaccladandClaude Opus 4.8 9780591e74
Deploy Ladill POS / deploy (push) Successful in 36s
Split bills + kitchen ingest for online orders
Split bills (restaurant tickets):
- New pos_payments ledger; a tab is settled across one or more cash/Ladill Pay
  payments. The ticket shows total / paid / balance, an amount field with Full /
  ½ / ⅓ / ¼ helpers, and the payments taken. Partial payments keep the tab open;
  the sale finalises (and frees the table) only when the balance hits zero.
  Pay splits get their own checkout + callback (pos.payments.callback).

Pipe online orders to the KDS:
- POST /api/kitchen/orders — a first-party, service-keyed ingest
  (config pos.kitchen_api_keys, scoped by owner, idempotent by external_ref) that
  creates a paid, already-fired ticket (order_type=online, lines source=online).
- The KDS feed is now payment-agnostic (any kitchen-active sale), so paid online
  orders sit on the board next to dine-in tabs and bump the same way; they're
  badged "online".

Schema additive: pos_payments, pos_sales.external_ref. Suite green (14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:49:01 +00:00

162 lines
5.7 KiB
PHP

<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosSale;
use App\Models\PosSaleLine;
use App\Models\PosStation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class KitchenController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
return view('pos.kitchen.index', [
'stations' => PosStation::owned($this->ownerRef($request))->orderBy('name')->get(['id', 'name']),
]);
}
/**
* Active kitchen tickets — open sales with fired-but-not-served lines.
* Used for the initial load and as a fallback when SSE is unavailable.
*/
public function feed(Request $request): JsonResponse
{
return response()->json($this->payload($this->ownerRef($request)));
}
/**
* Realtime feed over Server-Sent Events. Pushes the board whenever it
* changes (~1s tick), with a heartbeat. Bounded lifetime so PHP-FPM workers
* recycle — the browser's EventSource reconnects automatically.
*/
public function stream(Request $request): StreamedResponse
{
$owner = $this->ownerRef($request);
// Release the session lock early (DB driver doesn't lock, but be safe).
$request->session()->save();
$response = new StreamedResponse(function () use ($owner) {
while (ob_get_level() > 0) {
@ob_end_flush();
}
@set_time_limit(0);
$start = time();
$lastHash = null;
while (true) {
$payload = $this->payload($owner);
$json = json_encode($payload);
$hash = md5((string) $json);
if ($hash !== $lastHash) {
echo 'data: '.$json."\n\n";
$lastHash = $hash;
} else {
echo ": ping\n\n"; // heartbeat
}
@ob_flush();
@flush();
if (connection_aborted() || (time() - $start) >= 25) {
break;
}
sleep(1);
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('X-Accel-Buffering', 'no'); // disable nginx buffering
return $response;
}
/** @return array{tickets: \Illuminate\Support\Collection, server_time: string} */
private function payload(string $owner): array
{
return ['tickets' => $this->buildTickets($owner), 'server_time' => now()->toIso8601String()];
}
private function buildTickets(string $owner): Collection
{
// Payment-agnostic: dine-in tabs (pending) and paid online orders both
// sit on the board while the kitchen is still working them.
$sales = PosSale::owned($owner)
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')])
->orderBy('kitchen_sent_at')
->get();
return $sales->map(function (PosSale $sale) {
$lines = $sale->lines
->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [
PosSaleLine::KITCHEN_QUEUED,
PosSaleLine::KITCHEN_PREPARING,
PosSaleLine::KITCHEN_READY,
], true))
->values();
if ($lines->isEmpty()) {
return null;
}
return [
'id' => $sale->id,
'reference' => $sale->reference,
'order_type' => $sale->order_type,
'table' => $sale->table?->label,
'sent_at' => optional($sale->kitchen_sent_at)->toIso8601String(),
'lines' => $lines->map(fn (PosSaleLine $l) => [
'id' => $l->id,
'name' => $l->name,
'quantity' => $l->quantity,
'notes' => $l->notes,
'course' => $l->course,
'station' => $l->station?->name,
'station_id' => $l->station_id,
'source' => $l->source,
'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(),
'state' => $l->kitchen_state,
])->all(),
];
})->filter()->values();
}
/**
* Advance one line to the next kitchen state (queued → preparing → ready → served).
*/
public function bump(Request $request, PosSaleLine $line): JsonResponse
{
$owner = $this->ownerRef($request);
abort_unless($line->sale && $line->sale->owner_ref === $owner, 404);
$flow = PosSaleLine::KITCHEN_FLOW;
$idx = array_search($line->kitchen_state, $flow, true);
$next = $idx === false ? PosSaleLine::KITCHEN_PREPARING : ($flow[$idx + 1] ?? PosSaleLine::KITCHEN_SERVED);
$line->forceFill(['kitchen_state' => $next])->save();
// When every fired line is served, the ticket leaves the kitchen board.
$sale = $line->sale;
$remaining = $sale->lines()
->whereIn('kitchen_state', [PosSaleLine::KITCHEN_QUEUED, PosSaleLine::KITCHEN_PREPARING, PosSaleLine::KITCHEN_READY])
->count();
if ($remaining === 0) {
$sale->forceFill(['kitchen_status' => PosSale::KITCHEN_SERVED])->save();
}
return response()->json(['state' => $next]);
}
}