Deploy Ladill POS / deploy (push) Successful in 30s
Gated by a per-location service_style (retail | restaurant). Restaurant mode adds: - Floor screen with tables (areas, seats, status) — tap a free table to open a dine-in tab; takeaway/counter orders open from the same screen. - Open tickets (tabs): a sale stays pending while items are added; lines persist as you go (posTicket Alpine component posts each change to the server). - Send-to-kitchen fires un-sent lines; a polling Kitchen Display (KDS) shows active tickets and bumps items queued → preparing → ready → served. - Settlement reuses the existing cash / Ladill Pay flow; paying closes the tab and frees the table (PosSaleService::closeTicket, wired into both pay paths). - Settings gains the mode toggle and a tables manager. Schema is additive (new pos_tables; service_style on locations; order/kitchen columns on sales + lines). Retail flow is untouched. Sidebar surfaces Floor + Kitchen only in restaurant mode. New PosRestaurantTest covers the dine-in lifecycle end to end; suite green (10 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
12 KiB
PHP
347 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Pos;
|
|
|
|
use App\Models\PosSale;
|
|
use App\Models\PosSaleLine;
|
|
use App\Models\PosTable;
|
|
use App\Models\User;
|
|
use App\Services\Pay\PayClient;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class PosSaleService
|
|
{
|
|
public function __construct(
|
|
private PayClient $pay,
|
|
private PosTimelineService $timeline,
|
|
) {}
|
|
|
|
/**
|
|
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
|
|
* @param array{customer_name?: ?string, customer_email?: ?string, customer_phone?: ?string, crm_customer_id?: ?int, location_id?: ?int, currency?: string} $meta
|
|
*/
|
|
public function createSale(User $merchant, array $lines, array $meta = []): PosSale
|
|
{
|
|
$normalized = $this->normalizeLines($lines);
|
|
if ($normalized === []) {
|
|
throw new RuntimeException('Add at least one item to the sale.');
|
|
}
|
|
|
|
$subtotal = collect($normalized)->sum('line_total_minor');
|
|
if ($subtotal <= 0) {
|
|
throw new RuntimeException('Sale total must be greater than zero.');
|
|
}
|
|
|
|
$currency = strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS')));
|
|
|
|
$sale = PosSale::create([
|
|
'owner_ref' => $merchant->public_id,
|
|
'location_id' => $meta['location_id'] ?? null,
|
|
'reference' => 'POS-'.strtoupper(Str::random(12)),
|
|
'status' => PosSale::STATUS_PENDING,
|
|
'payment_method' => PosSale::METHOD_PAY,
|
|
'customer_name' => $meta['customer_name'] ?? null,
|
|
'customer_email' => $meta['customer_email'] ?? null,
|
|
'customer_phone' => $meta['customer_phone'] ?? null,
|
|
'crm_customer_id' => $meta['crm_customer_id'] ?? null,
|
|
'subtotal_minor' => $subtotal,
|
|
'total_minor' => $subtotal,
|
|
'currency' => $currency,
|
|
]);
|
|
|
|
foreach ($normalized as $index => $line) {
|
|
PosSaleLine::create([
|
|
'pos_sale_id' => $sale->id,
|
|
'product_id' => $line['product_id'] ?? null,
|
|
'name' => $line['name'],
|
|
'unit_price_minor' => $line['unit_price_minor'],
|
|
'quantity' => $line['quantity'],
|
|
'line_total_minor' => $line['line_total_minor'],
|
|
'position' => $index,
|
|
]);
|
|
}
|
|
|
|
return $sale->fresh('lines');
|
|
}
|
|
|
|
/**
|
|
* Open an empty restaurant ticket (a tab) that stays pending while items are
|
|
* added. Occupies the table for dine-in orders.
|
|
*
|
|
* @param array{order_type?: string, table_id?: ?int, location_id?: ?int, currency?: string, covers?: ?int, customer_name?: ?string} $meta
|
|
*/
|
|
public function openTicket(User $merchant, array $meta = []): PosSale
|
|
{
|
|
$orderType = in_array($meta['order_type'] ?? '', [PosSale::ORDER_DINE_IN, PosSale::ORDER_TAKEAWAY, PosSale::ORDER_COUNTER], true)
|
|
? $meta['order_type']
|
|
: PosSale::ORDER_COUNTER;
|
|
|
|
$table = null;
|
|
if ($orderType === PosSale::ORDER_DINE_IN && ! empty($meta['table_id'])) {
|
|
$table = PosTable::owned($merchant->public_id)->find($meta['table_id']);
|
|
if ($table && ! $table->isFree()) {
|
|
throw new RuntimeException('That table already has an open ticket.');
|
|
}
|
|
}
|
|
|
|
$sale = PosSale::create([
|
|
'owner_ref' => $merchant->public_id,
|
|
'location_id' => $meta['location_id'] ?? null,
|
|
'table_id' => $table?->id,
|
|
'reference' => 'POS-'.strtoupper(Str::random(12)),
|
|
'status' => PosSale::STATUS_PENDING,
|
|
'payment_method' => PosSale::METHOD_PAY,
|
|
'order_type' => $orderType,
|
|
'kitchen_status' => PosSale::KITCHEN_NONE,
|
|
'covers' => $meta['covers'] ?? null,
|
|
'customer_name' => $meta['customer_name'] ?? null,
|
|
'subtotal_minor' => 0,
|
|
'total_minor' => 0,
|
|
'currency' => strtoupper((string) ($meta['currency'] ?? config('pos.default_currency', 'GHS'))),
|
|
'opened_at' => now(),
|
|
]);
|
|
|
|
if ($table) {
|
|
$table->forceFill([
|
|
'status' => PosTable::STATUS_OCCUPIED,
|
|
'current_sale_id' => $sale->id,
|
|
])->save();
|
|
}
|
|
|
|
return $sale->fresh('lines');
|
|
}
|
|
|
|
/**
|
|
* @param array{product_id?: ?int, name: string, unit_price_minor: int, quantity?: int, notes?: ?string} $line
|
|
*/
|
|
public function addLine(PosSale $sale, array $line): PosSaleLine
|
|
{
|
|
$name = trim((string) ($line['name'] ?? ''));
|
|
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
|
|
$qty = max(1, (int) ($line['quantity'] ?? 1));
|
|
|
|
if ($name === '' || $unit <= 0) {
|
|
throw new RuntimeException('Choose an item with a price.');
|
|
}
|
|
|
|
$position = (int) $sale->lines()->max('position') + 1;
|
|
|
|
$created = PosSaleLine::create([
|
|
'pos_sale_id' => $sale->id,
|
|
'product_id' => $line['product_id'] ?? null,
|
|
'name' => $name,
|
|
'unit_price_minor' => $unit,
|
|
'quantity' => $qty,
|
|
'line_total_minor' => $unit * $qty,
|
|
'position' => $position,
|
|
'kitchen_state' => PosSaleLine::KITCHEN_NEW,
|
|
'notes' => isset($line['notes']) ? trim((string) $line['notes']) ?: null : null,
|
|
]);
|
|
|
|
$this->recomputeTotals($sale);
|
|
|
|
return $created;
|
|
}
|
|
|
|
public function updateLine(PosSaleLine $line, int $quantity, ?string $notes = null): void
|
|
{
|
|
if ($quantity <= 0) {
|
|
$this->removeLine($line);
|
|
|
|
return;
|
|
}
|
|
|
|
$line->forceFill([
|
|
'quantity' => $quantity,
|
|
'line_total_minor' => $line->unit_price_minor * $quantity,
|
|
'notes' => $notes !== null ? (trim($notes) ?: null) : $line->notes,
|
|
])->save();
|
|
|
|
$this->recomputeTotals($line->sale);
|
|
}
|
|
|
|
public function removeLine(PosSaleLine $line): void
|
|
{
|
|
$sale = $line->sale;
|
|
$line->delete();
|
|
$this->recomputeTotals($sale);
|
|
}
|
|
|
|
public function recomputeTotals(PosSale $sale): PosSale
|
|
{
|
|
$subtotal = (int) $sale->lines()->sum('line_total_minor');
|
|
|
|
$sale->forceFill([
|
|
'subtotal_minor' => $subtotal,
|
|
'total_minor' => $subtotal,
|
|
])->save();
|
|
|
|
return $sale;
|
|
}
|
|
|
|
/**
|
|
* Fire all not-yet-sent lines to the kitchen.
|
|
*
|
|
* @return int number of lines fired
|
|
*/
|
|
public function sendToKitchen(PosSale $sale): int
|
|
{
|
|
$fired = $sale->lines()
|
|
->where('kitchen_state', PosSaleLine::KITCHEN_NEW)
|
|
->update(['kitchen_state' => PosSaleLine::KITCHEN_QUEUED]);
|
|
|
|
if ($fired > 0) {
|
|
$sale->forceFill([
|
|
'kitchen_status' => PosSale::KITCHEN_ACTIVE,
|
|
'kitchen_sent_at' => $sale->kitchen_sent_at ?? now(),
|
|
])->save();
|
|
}
|
|
|
|
return $fired;
|
|
}
|
|
|
|
/**
|
|
* Close a ticket once it's settled: free its table and mark kitchen done.
|
|
*/
|
|
public function closeTicket(PosSale $sale): void
|
|
{
|
|
$sale->forceFill([
|
|
'kitchen_status' => $sale->kitchen_status === PosSale::KITCHEN_NONE ? PosSale::KITCHEN_NONE : PosSale::KITCHEN_SERVED,
|
|
'closed_at' => now(),
|
|
])->save();
|
|
|
|
$sale->lines()
|
|
->whereNotIn('kitchen_state', [PosSaleLine::KITCHEN_NEW, PosSaleLine::KITCHEN_SERVED])
|
|
->update(['kitchen_state' => PosSaleLine::KITCHEN_SERVED]);
|
|
|
|
if ($sale->table_id) {
|
|
PosTable::where('id', $sale->table_id)
|
|
->where('current_sale_id', $sale->id)
|
|
->update(['status' => PosTable::STATUS_FREE, 'current_sale_id' => null]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{sale: PosSale, checkout_url: string}
|
|
*/
|
|
public function initiatePayCheckout(PosSale $sale, User $merchant): array
|
|
{
|
|
if ($sale->isPaid()) {
|
|
throw new RuntimeException('This sale is already paid.');
|
|
}
|
|
|
|
$sale->load('lines');
|
|
$callbackUrl = route('pos.sales.callback', $sale);
|
|
|
|
$payOrder = $this->pay->createCheckout([
|
|
'merchant' => $merchant->public_id,
|
|
'fee_tier' => 'sales',
|
|
'source_service' => 'pos',
|
|
'source_ref' => (string) $sale->id,
|
|
'callback_url' => $callbackUrl,
|
|
'customer_name' => $sale->customer_name,
|
|
'customer_email' => $sale->customer_email,
|
|
'customer_phone' => $sale->customer_phone,
|
|
'line_items' => $sale->lines->map(fn (PosSaleLine $line) => [
|
|
'name' => $line->name,
|
|
'unit_price_minor' => $line->unit_price_minor,
|
|
'quantity' => $line->quantity,
|
|
])->all(),
|
|
'metadata' => [
|
|
'pos_sale_id' => $sale->id,
|
|
'pos_reference' => $sale->reference,
|
|
],
|
|
]);
|
|
|
|
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
|
if ($checkoutUrl === '') {
|
|
throw new RuntimeException('Could not start checkout. Please try again.');
|
|
}
|
|
|
|
$sale->forceFill([
|
|
'payment_method' => PosSale::METHOD_PAY,
|
|
'pay_order_id' => $payOrder['id'] ?? null,
|
|
'payment_reference' => $payOrder['reference'] ?? null,
|
|
])->save();
|
|
|
|
return [
|
|
'sale' => $sale->fresh('lines'),
|
|
'checkout_url' => $checkoutUrl,
|
|
];
|
|
}
|
|
|
|
public function recordCashPayment(PosSale $sale): PosSale
|
|
{
|
|
if ($sale->isPaid()) {
|
|
throw new RuntimeException('This sale is already paid.');
|
|
}
|
|
|
|
$sale->forceFill([
|
|
'payment_method' => PosSale::METHOD_CASH,
|
|
'status' => PosSale::STATUS_PAID,
|
|
'paid_at' => now(),
|
|
])->save();
|
|
|
|
$this->closeTicket($sale);
|
|
|
|
$sale = $sale->fresh('lines');
|
|
$this->timeline->pushPaidSale($sale);
|
|
|
|
return $sale;
|
|
}
|
|
|
|
public function completePayCheckout(string $paymentReference): PosSale
|
|
{
|
|
$sale = PosSale::query()
|
|
->where('payment_reference', $paymentReference)
|
|
->where('status', PosSale::STATUS_PENDING)
|
|
->firstOrFail();
|
|
|
|
$payOrder = $this->pay->verify($paymentReference);
|
|
|
|
$sale->forceFill([
|
|
'status' => PosSale::STATUS_PAID,
|
|
'total_minor' => (int) ($payOrder['amount_minor'] ?? $sale->total_minor),
|
|
'pay_order_id' => $payOrder['id'] ?? $sale->pay_order_id,
|
|
'paid_at' => now(),
|
|
])->save();
|
|
|
|
$this->closeTicket($sale);
|
|
|
|
$sale = $sale->fresh('lines');
|
|
$this->timeline->pushPaidSale($sale);
|
|
|
|
return $sale;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int}> $lines
|
|
* @return list<array{product_id?: ?int, name: string, unit_price_minor: int, quantity: int, line_total_minor: int}>
|
|
*/
|
|
private function normalizeLines(array $lines): array
|
|
{
|
|
$out = [];
|
|
|
|
foreach ($lines as $line) {
|
|
$qty = max(1, (int) ($line['quantity'] ?? 1));
|
|
$unit = max(0, (int) ($line['unit_price_minor'] ?? 0));
|
|
$name = trim((string) ($line['name'] ?? ''));
|
|
|
|
if ($name === '' || $unit <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$out[] = [
|
|
'product_id' => $line['product_id'] ?? null,
|
|
'name' => $name,
|
|
'unit_price_minor' => $unit,
|
|
'quantity' => $qty,
|
|
'line_total_minor' => $unit * $qty,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
}
|