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 */ 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> */ 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()); } } }