Files
ladill-pos/app/Http/Controllers/Pos/RegisterController.php
T
isaacclad 38b7f96714
Deploy Ladill POS / deploy (push) Successful in 56s
Add dual-screen customer display for the register.
Token-gated full-screen customer view shows order, payment QR, tips,
signature, receipt, and promo phases while the till keeps operator UI.
2026-07-15 15:35:44 +00:00

212 lines
8.1 KiB
PHP

<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Services\Pos\CustomerDisplayService;
use App\Services\Pos\PosBarcodeLookupService;
use App\Services\Pos\PosLocationService;
use App\Services\Pos\PosSaleService;
use App\Services\Crm\CrmClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class RegisterController extends Controller
{
use ScopesToAccount;
public function __construct(
private PosSaleService $sales,
private PosLocationService $locations,
private PosBarcodeLookupService $barcodes,
private CustomerDisplayService $customerDisplays,
) {}
public function index(Request $request): View
{
$owner = $this->ownerRef($request);
$location = $this->location($request);
$display = $this->customerDisplays->ensureForLocation($location);
return view('pos.register', [
'products' => $this->registerProducts($owner, $location),
'location' => $location,
'crmCustomers' => $this->crmCustomers($owner),
'barcodeLookupUrl' => route('pos.register.lookup'),
'customerDisplayUrl' => $this->customerDisplays->displayUrl($display),
'customerDisplayPushUrl' => route('pos.customer-display.push'),
'customerDisplayActionsUrl' => route('pos.customer-display.actions'),
'isRestaurant' => $location->isRestaurant(),
]);
}
public function lookup(Request $request): JsonResponse
{
$data = $request->validate([
'code' => ['required', 'string', 'max:80'],
]);
$owner = $this->ownerRef($request);
$location = $this->location($request);
$product = $this->barcodes->lookup($owner, $location, $data['code']);
if (! $product || $product['price_minor'] <= 0 || $product['name'] === '') {
return response()->json(['message' => 'No product found for that barcode.'], 404);
}
return response()->json(['product' => $product]);
}
/**
* Register catalog: local pos_products in restaurant mode, live CRM products
* in retail mode (resilient — empty if CRM is unreachable).
*
* @return list<array{id: int|string|null, name: string, price_minor: int, sku: string|null}>
*/
private function registerProducts(string $owner, PosLocation $location): array
{
if ($location->isRestaurant()) {
return PosProduct::owned($owner)
->active()
->where(function ($query) use ($location) {
$query->where('location_id', $location->id)
->orWhereNull('location_id');
})
->orderBy('name')
->get()
->map(fn (PosProduct $p) => [
'id' => $p->id,
'name' => $p->name,
'price_minor' => $p->price_minor,
'sku' => $p->sku,
])
->all();
}
try {
// Retail POS sells goods only — CRM services stay in Invoice/CRM, not the till.
$rows = (array) (CrmClient::for($owner)->products(['type' => 'product', 'active' => 1, 'per_page' => 500])['data'] ?? []);
} catch (\Throwable) {
$rows = [];
}
return collect($rows)
->map(fn ($r) => [
'id' => $r['id'] ?? null,
'name' => (string) ($r['name'] ?? ''),
'price_minor' => (int) ($r['unit_price_minor'] ?? 0),
'sku' => isset($r['sku']) && $r['sku'] !== '' ? (string) $r['sku'] : null,
])
->filter(fn ($r) => $r['name'] !== '' && $r['price_minor'] > 0)
->values()
->all();
}
/** Quick switch between retail and restaurant service from the register. */
public function setMode(Request $request): RedirectResponse
{
$data = $request->validate(['style' => ['required', 'in:retail,restaurant']]);
$location = $this->location($request);
$location->update(['service_style' => $data['style']]);
if ($data['style'] === PosLocation::STYLE_RESTAURANT) {
return redirect()->route('pos.floor')->with('success', 'Restaurant mode on — Floor, Kitchen & Menu are now in the sidebar.');
}
return redirect()->route('pos.register')->with('success', 'Retail mode on.');
}
/** @return list<array<string, mixed>> */
private function crmCustomers(string $owner): array
{
try {
return (array) ((CrmClient::for($owner)->customers(['per_page' => 200])['data']) ?? []);
} catch (\Throwable) {
return [];
}
}
public function charge(Request $request): RedirectResponse
{
$data = $request->validate([
'lines' => ['required', 'array', 'min:1'],
'lines.*.product_id' => ['nullable', 'integer'],
'lines.*.name' => ['required', 'string', 'max:200'],
'lines.*.unit_price_minor' => ['required', 'integer', 'min:1'],
'lines.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'customer_name' => ['nullable', 'string', 'max:120'],
'customer_email' => ['nullable', 'email', 'max:255'],
'customer_phone' => ['nullable', 'string', 'max:40'],
'crm_customer_id' => ['nullable', 'integer'],
'payment_method' => ['required', 'in:pay,cash'],
]);
$merchant = ladill_account() ?? $request->user();
$location = $this->location($request);
$lines = $data['lines'];
if (! $location->isRestaurant()) {
// Retail lines reference CRM products, not local pos_products — keep a name/price snapshot only.
$lines = array_map(fn ($l) => [...$l, 'product_id' => null], $lines);
}
try {
$sale = $this->sales->createSale($merchant, $lines, [
'location_id' => $location->id,
'currency' => $location->currency,
'customer_name' => $data['customer_name'] ?? null,
'customer_email' => $data['customer_email'] ?? null,
'customer_phone' => $data['customer_phone'] ?? null,
'crm_customer_id' => $data['crm_customer_id'] ?? null,
]);
if ($data['payment_method'] === 'cash') {
$this->sales->recordCashPayment($sale);
$this->customerDisplays->pushReceipt($location, $sale->fresh(['lines']));
if ($location->printer_auto_print) {
return redirect()
->route('pos.sales.receipt', ['sale' => $sale, 'autoprint' => 1])
->with('success', 'Cash sale recorded.');
}
return redirect()
->route('pos.sales.show', $sale)
->with('success', 'Cash sale recorded.');
}
$result = $this->sales->initiatePayCheckout($sale, $merchant);
$checkoutUrl = $result['checkout_url'] ?? null;
$this->customerDisplays->pushPayment($location, $sale->fresh(['lines']), [
'checkout_url' => $checkoutUrl,
'qr_url' => $checkoutUrl,
'message' => 'Complete payment with card or MoMo',
'options' => ['Card / MoMo'],
]);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'checkout_url' => $checkoutUrl,
'sale_id' => $sale->id,
]);
}
return redirect()
->route('pos.sales.show', $sale)
->with('checkout_url', $checkoutUrl)
->with('success', 'Payment sheet ready — customer screen shows the QR; till sheet is for the operator.');
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
}
}