Deploy Ladill POS / deploy (push) Has been cancelled
Redeem at charge, earn on payment, reverse on cancel, and surface balances on the till, customer display, sales, and receipts.
260 lines
10 KiB
PHP
260 lines
10 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\PosLoyaltyService;
|
|
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,
|
|
private PosLoyaltyService $loyalty,
|
|
) {}
|
|
|
|
public function index(Request $request): View
|
|
{
|
|
$owner = $this->ownerRef($request);
|
|
$location = $this->location($request);
|
|
$display = $this->customerDisplays->ensureForLocation($location);
|
|
$loyaltyProgram = $this->loyalty->program($owner);
|
|
|
|
return view('pos.register', [
|
|
'products' => $this->registerProducts($owner, $location),
|
|
'location' => $location,
|
|
'crmCustomers' => $this->crmCustomers($owner),
|
|
'barcodeLookupUrl' => route('pos.register.lookup'),
|
|
'loyaltyUrl' => route('pos.register.loyalty'),
|
|
'loyaltyEnabled' => (bool) ($loyaltyProgram['enabled'] ?? false),
|
|
'loyaltyProgram' => $loyaltyProgram,
|
|
'customerDisplayUrl' => $this->customerDisplays->displayUrl($display),
|
|
'customerDisplayPushUrl' => route('pos.customer-display.push'),
|
|
'customerDisplayActionsUrl' => route('pos.customer-display.actions'),
|
|
'isRestaurant' => $location->isRestaurant(),
|
|
]);
|
|
}
|
|
|
|
/** Live loyalty quote for the register (CRM-backed). */
|
|
public function loyalty(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'crm_customer_id' => ['required', 'integer'],
|
|
'subtotal_minor' => ['nullable', 'integer', 'min:0'],
|
|
'redeem_points' => ['nullable', 'integer', 'min:0'],
|
|
]);
|
|
|
|
$owner = $this->ownerRef($request);
|
|
$subtotal = (int) ($data['subtotal_minor'] ?? 0);
|
|
$redeem = (int) ($data['redeem_points'] ?? 0);
|
|
|
|
$snapshot = $this->loyalty->snapshot($owner, (int) $data['crm_customer_id'], $subtotal);
|
|
if (! $snapshot) {
|
|
return response()->json(['enabled' => false, 'message' => 'Loyalty unavailable.'], 200);
|
|
}
|
|
|
|
$quote = $this->loyalty->quote($owner, (int) $data['crm_customer_id'], max(1, $subtotal), $redeem);
|
|
|
|
return response()->json([
|
|
'enabled' => (bool) ($snapshot['program']['enabled'] ?? false),
|
|
'snapshot' => $snapshot,
|
|
'quote' => $quote,
|
|
]);
|
|
}
|
|
|
|
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'],
|
|
'loyalty_redeem_points' => ['nullable', 'integer', 'min:0'],
|
|
'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,
|
|
'loyalty_redeem_points' => (int) ($data['loyalty_redeem_points'] ?? 0),
|
|
]);
|
|
|
|
if ($data['payment_method'] === 'cash') {
|
|
$this->sales->recordCashPayment($sale);
|
|
$sale = $sale->fresh(['lines']);
|
|
$loyaltyPayload = null;
|
|
if ($sale->crm_customer_id) {
|
|
$snap = $this->loyalty->snapshot($merchant->public_id, (int) $sale->crm_customer_id, (int) $sale->total_minor);
|
|
$loyaltyPayload = $this->loyalty->displayPayload(
|
|
$snap,
|
|
(int) $sale->loyalty_points_redeemed,
|
|
(int) $sale->loyalty_points_earned,
|
|
);
|
|
}
|
|
$this->customerDisplays->pushReceipt($location, $sale, array_filter([
|
|
'loyalty' => $loyaltyPayload,
|
|
'discount_minor' => (int) $sale->loyalty_discount_minor,
|
|
]));
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|