Link retail POS products to the CRM products API (restaurant stays local)
Deploy Ladill POS / deploy (push) Successful in 28s

Products are now mode-aware:
- Retail: the catalog lives in Ladill CRM. The Products page proxies CRUD to the
  CRM products API (CrmClient gains product/create/update/delete), and the
  register reads CRM products live; a sold line is stored as a name/price
  snapshot (product_id null, since CRM products aren't local rows). Resilient —
  the register shows an empty catalog if CRM is unreachable. CRM import is hidden
  in Settings (not needed when reading live).
- Restaurant: unchanged — the local pos_products catalog keeps its POS-only depth
  (category, kitchen station, course, modifier groups).

ProductController routes take a raw {product} id (CRM id in retail, local id in
restaurant). Suite green (17), covering both modes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-25 12:27:19 +00:00
co-authored by Claude Opus 4.8
parent 0d816daed3
commit 5d8e185223
10 changed files with 377 additions and 61 deletions
+106 -36
View File
@@ -10,83 +10,153 @@ use App\Models\PosModifierGroup;
use App\Models\PosProduct;
use App\Models\PosSaleLine;
use App\Models\PosStation;
use App\Services\Crm\CrmClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Products are mode-aware:
* - Retail the catalog lives in Ladill CRM; CRUD is proxied to the CRM
* products API (no local copy), keeping one source of truth.
* - Restaurant a local pos_products catalog with POS-only depth (category,
* kitchen station, course, modifier groups), kept unique to POS.
*/
class ProductController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$products = PosProduct::owned($this->ownerRef($request))
->with('category')
->orderBy('name')
->paginate(30)
->withQueryString();
if ($this->isRestaurant($request)) {
$products = PosProduct::owned($this->ownerRef($request))
->with('category')->orderBy('name')->paginate(30)->withQueryString();
return view('pos.products.index', compact('products'));
return view('pos.products.index', compact('products'));
}
$search = trim((string) $request->query('search', ''));
$response = CrmClient::for($this->ownerRef($request))->products([
'type' => 'product',
'per_page' => 100,
'search' => $search !== '' ? $search : null,
]);
return view('pos.products.retail.index', [
'products' => (array) ($response['data'] ?? []),
'search' => $search,
]);
}
public function create(Request $request): View
{
return view('pos.products.create', [
'product' => new PosProduct([
'currency' => config('pos.default_currency', 'GHS'),
'is_active' => true,
]),
'selectedGroups' => [],
...$this->formOptions($request),
]);
if ($this->isRestaurant($request)) {
return view('pos.products.create', [
'product' => new PosProduct(['currency' => config('pos.default_currency', 'GHS'), 'is_active' => true]),
'selectedGroups' => [],
...$this->formOptions($request),
]);
}
return view('pos.products.retail.create');
}
public function store(Request $request): RedirectResponse
{
$product = PosProduct::create([
...$this->validated($request),
'owner_ref' => $this->ownerRef($request),
]);
$product->modifierGroups()->sync($this->groupIds($request));
if ($this->isRestaurant($request)) {
$product = PosProduct::create([...$this->localValidated($request), 'owner_ref' => $this->ownerRef($request)]);
$product->modifierGroups()->sync($this->groupIds($request));
return redirect()->route('pos.products.index')->with('success', 'Product added.');
}
CrmClient::for($this->ownerRef($request))->createProduct($this->crmPayload($request));
return redirect()->route('pos.products.index')->with('success', 'Product added.');
}
public function edit(Request $request, PosProduct $product): View
public function edit(Request $request, string $product): View
{
$this->authorizeOwner($request, $product);
if ($this->isRestaurant($request)) {
$model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product);
return view('pos.products.edit', [
'product' => $product,
'selectedGroups' => $product->modifierGroups()->pluck('pos_modifier_groups.id')->all(),
...$this->formOptions($request),
]);
return view('pos.products.edit', [
'product' => $model,
'selectedGroups' => $model->modifierGroups()->pluck('pos_modifier_groups.id')->all(),
...$this->formOptions($request),
]);
}
return view('pos.products.retail.edit', ['product' => CrmClient::for($this->ownerRef($request))->product($product)]);
}
public function update(Request $request, PosProduct $product): RedirectResponse
public function update(Request $request, string $product): RedirectResponse
{
$this->authorizeOwner($request, $product);
$product->update($this->validated($request));
$product->modifierGroups()->sync($this->groupIds($request));
if ($this->isRestaurant($request)) {
$model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product);
$model->update($this->localValidated($request));
$model->modifierGroups()->sync($this->groupIds($request));
return redirect()->route('pos.products.index')->with('success', 'Product updated.');
}
CrmClient::for($this->ownerRef($request))->updateProduct($product, $this->crmPayload($request));
return redirect()->route('pos.products.index')->with('success', 'Product updated.');
}
public function destroy(Request $request, PosProduct $product): RedirectResponse
public function destroy(Request $request, string $product): RedirectResponse
{
$this->authorizeOwner($request, $product);
$product->delete();
if ($this->isRestaurant($request)) {
$model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product);
$model->delete();
return redirect()->route('pos.products.index')->with('success', 'Product removed.');
}
CrmClient::for($this->ownerRef($request))->deleteProduct($product);
return redirect()->route('pos.products.index')->with('success', 'Product removed.');
}
private function isRestaurant(Request $request): bool
{
return PosLocation::owned($this->ownerRef($request))
->where('service_style', PosLocation::STYLE_RESTAURANT)->exists();
}
/** Retail: payload for the CRM products API. */
private function crmPayload(Request $request): array
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'sku' => ['nullable', 'string', 'max:80'],
'description' => ['nullable', 'string', 'max:5000'],
'unit_price' => ['nullable', 'numeric', 'min:0'],
'currency' => ['nullable', 'string', 'size:3'],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'is_active' => ['sometimes', 'boolean'],
]);
return [
'name' => $data['name'],
'sku' => $data['sku'] ?? null,
'type' => 'product',
'description' => $data['description'] ?? null,
'unit_price_minor' => (int) round(((float) ($data['unit_price'] ?? 0)) * 100),
'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')),
'tax_rate' => (float) ($data['tax_rate'] ?? 0),
'active' => $request->boolean('is_active', true),
];
}
/** @return array<string, mixed> */
private function formOptions(Request $request): array
{
$owner = $this->ownerRef($request);
return [
'restaurant' => PosLocation::owned($owner)->where('service_style', PosLocation::STYLE_RESTAURANT)->exists(),
'restaurant' => true,
'categories' => PosCategory::owned($owner)->orderBy('name')->get(),
'stations' => PosStation::owned($owner)->orderBy('name')->get(),
'modifierGroups' => PosModifierGroup::owned($owner)->orderBy('name')->get(),
@@ -102,8 +172,8 @@ class ProductController extends Controller
return PosModifierGroup::owned($owner)->whereIn('id', $ids)->pluck('id')->all();
}
/** @return array<string, mixed> */
private function validated(Request $request): array
/** Restaurant: local pos_products attributes. */
private function localValidated(Request $request): array
{
$owner = $this->ownerRef($request);
@@ -26,18 +26,44 @@ class RegisterController extends Controller
$owner = $this->ownerRef($request);
$location = $this->locations->ensureDefault($owner);
$products = PosProduct::owned($owner)
->active()
->orderBy('name')
->get();
return view('pos.register', [
'products' => $products,
'products' => $this->registerProducts($owner, $location),
'location' => $location,
'crmCustomers' => $this->crmCustomers($owner),
]);
}
/**
* 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}>
*/
private function registerProducts(string $owner, PosLocation $location): array
{
if ($location->isRestaurant()) {
return PosProduct::owned($owner)->active()->orderBy('name')->get()
->map(fn (PosProduct $p) => ['id' => $p->id, 'name' => $p->name, 'price_minor' => $p->price_minor])
->all();
}
try {
$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),
])
->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
{
@@ -81,8 +107,14 @@ class RegisterController extends Controller
$merchant = ladill_account() ?? $request->user();
$location = $this->locations->ensureDefault($this->ownerRef($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, $data['lines'], [
$sale = $this->sales->createSale($merchant, $lines, [
'location_id' => $location->id,
'currency' => $location->currency,
'customer_name' => $data['customer_name'] ?? null,
+25
View File
@@ -26,6 +26,26 @@ class CrmClient
return $this->get('products', $filters);
}
public function product(int|string $id): array
{
return $this->get("products/{$id}");
}
public function createProduct(array $data): array
{
return $this->send('post', 'products', $data);
}
public function updateProduct(int|string $id, array $data): array
{
return $this->send('patch', "products/{$id}", $data);
}
public function deleteProduct(int|string $id): array
{
return $this->send('delete', "products/{$id}", []);
}
public function pushTimeline(array $data): array
{
return $this->post('timeline', $data);
@@ -51,6 +71,11 @@ class CrmClient
return $this->handle(fn () => $this->client()->post($path, [...$data, 'owner' => $this->owner]));
}
private function send(string $method, string $path, array $data): array
{
return $this->handle(fn () => $this->client()->{$method}($path, [...$data, 'owner' => $this->owner]));
}
private function handle(callable $request): array
{
try {
@@ -0,0 +1,56 @@
@php
/** @var array<string,mixed> $product */
$product = $product ?? [];
$priceValue = old('unit_price', isset($product['unit_price_minor']) ? number_format(((int) $product['unit_price_minor']) / 100, 2, '.', '') : '');
@endphp
@if($errors->any())
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
<ul class="list-inside list-disc space-y-0.5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
</div>
@endif
<div class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
<div>
<label class="text-sm font-medium text-slate-700">Product name</label>
<input type="text" name="name" value="{{ old('name', $product['name'] ?? '') }}" required maxlength="255"
class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-slate-700">SKU <span class="font-normal text-slate-400">(optional)</span></label>
<input type="text" name="sku" value="{{ old('sku', $product['sku'] ?? '') }}" maxlength="80"
class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="text-sm font-medium text-slate-700">Currency</label>
<select name="currency" class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach(['GHS', 'USD', 'NGN', 'KES'] as $code)
<option value="{{ $code }}" @selected(old('currency', $product['currency'] ?? config('pos.default_currency', 'GHS')) === $code)>{{ $code }}</option>
@endforeach
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-slate-700">Unit price</label>
<input type="number" name="unit_price" value="{{ $priceValue }}" min="0" step="0.01" placeholder="0.00"
class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="text-sm font-medium text-slate-700">Tax rate % <span class="font-normal text-slate-400">(optional)</span></label>
<input type="number" name="tax_rate" value="{{ old('tax_rate', $product['tax_rate'] ?? '') }}" min="0" max="100" step="0.01" placeholder="0"
class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
<div>
<label class="text-sm font-medium text-slate-700">Description <span class="font-normal text-slate-400">(optional)</span></label>
<textarea name="description" rows="3" maxlength="5000"
class="mt-1 w-full rounded-xl border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('description', $product['description'] ?? '') }}</textarea>
</div>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $product['active'] ?? true)) class="rounded border-slate-300 text-indigo-600">
Show on the register
</label>
</div>
@@ -0,0 +1,17 @@
<x-app-layout title="New product">
<div class="mx-auto max-w-2xl space-y-6">
<a href="{{ route('pos.products.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
All products
</a>
<div>
<h1 class="text-lg font-semibold text-slate-900">New product</h1>
<p class="mt-1 text-sm text-slate-500">Added to your Ladill CRM catalog and the register.</p>
</div>
<form method="post" action="{{ route('pos.products.store') }}" class="space-y-5">
@csrf
@include('pos.products.retail._form', ['product' => []])
<button type="submit" class="btn-primary w-full">Create product</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,18 @@
<x-app-layout title="Edit product">
<div class="mx-auto max-w-2xl space-y-6">
<a href="{{ route('pos.products.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
All products
</a>
<div>
<h1 class="text-lg font-semibold text-slate-900">Edit product</h1>
<p class="mt-1 text-sm text-slate-500">Changes sync to your Ladill CRM catalog.</p>
</div>
<form method="post" action="{{ route('pos.products.update', $product['id']) }}" class="space-y-5">
@csrf
@method('PUT')
@include('pos.products.retail._form', ['product' => $product])
<button type="submit" class="btn-primary w-full">Save changes</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,70 @@
<x-app-layout title="Products">
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold text-slate-900">Products</h1>
<p class="mt-1 text-sm text-slate-500">Your retail catalog, synced live with Ladill CRM.</p>
</div>
<a href="{{ route('pos.products.create') }}" class="btn-primary">New product</a>
</div>
@if(session('success'))
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
<form method="get" class="flex max-w-sm items-center gap-2">
<input type="search" name="search" value="{{ $search }}" placeholder="Search products…"
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<button class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Search</button>
</form>
@if(empty($products))
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
<p class="text-sm text-slate-500">{{ $search !== '' ? 'No products match your search.' : 'No products yet.' }}</p>
<a href="{{ route('pos.products.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Add your first product</a>
</div>
@else
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">SKU</th>
<th class="px-4 py-3 text-right">Price</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach($products as $p)
<tr class="hover:bg-slate-50/60">
<td class="px-4 py-3 font-medium text-slate-900">{{ $p['name'] ?? '—' }}</td>
<td class="px-4 py-3 text-slate-500">{{ $p['sku'] ?: '—' }}</td>
<td class="px-4 py-3 text-right font-medium text-slate-900">
{{ strtoupper($p['currency'] ?? 'GHS') }} {{ number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2) }}
</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ ($p['active'] ?? true) ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500' }}">
{{ ($p['active'] ?? true) ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-3">
<a href="{{ route('pos.products.edit', $p['id']) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Edit</a>
<form method="post" action="{{ route('pos.products.destroy', $p['id']) }}" onsubmit="return confirm('Delete this product?');">
@csrf @method('DELETE')
<button class="text-sm font-medium text-red-500 hover:text-red-700">Delete</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</x-app-layout>
+2 -6
View File
@@ -1,10 +1,6 @@
<x-app-layout title="Register">
<div class="mx-auto max-w-6xl" x-data="posRegister(@js([
'products' => $products->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'price_minor' => $p->price_minor,
])->values()->all(),
'products' => $products,
]))">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -44,7 +40,7 @@
<p class="mt-1 text-sm text-indigo-600" x-text="formatMoney(product.price_minor)"></p>
</button>
</template>
@if ($products->isEmpty())
@if (empty($products))
<p class="col-span-full text-sm text-slate-500">No products yet. <a href="{{ route('pos.products.create') }}" class="text-indigo-600">Add products</a> or use Quick amount.</p>
@endif
</div>
+3 -1
View File
@@ -38,9 +38,10 @@
</div>
</form>
@if ($location->isRestaurant())
<section class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold text-slate-900">Import catalog</h2>
<p class="mt-1 text-sm text-slate-500">Pull products into your local POS catalog from CRM or Merchant storefronts.</p>
<p class="mt-1 text-sm text-slate-500">Seed your local POS catalog from CRM or Merchant storefronts. (Retail mode reads products live from CRM, so no import is needed.)</p>
<div class="mt-4 flex flex-wrap gap-3">
<form method="POST" action="{{ route('pos.settings.import-crm') }}">
@csrf
@@ -58,6 +59,7 @@
@endif
</div>
</section>
@endif
@if ($location->isRestaurant())
<section class="rounded-2xl border border-slate-200 bg-white p-6">
+41 -11
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Models\User;
@@ -49,21 +50,50 @@ class PosRegisterTest extends TestCase
->assertSee('favicon.ico', false);
}
public function test_register_renders_products(): void
public function test_register_renders_local_products_in_restaurant_mode(): void
{
$user = $this->user();
PosProduct::create([
'owner_ref' => $user->public_id,
'name' => 'Coffee',
'price_minor' => 1500,
'currency' => 'GHS',
'is_active' => true,
// ensureDefault() matches owner+name 'Main register'.
PosLocation::create(['owner_ref' => $user->public_id, 'name' => 'Main register', 'currency' => 'GHS', 'service_style' => 'restaurant']);
PosProduct::create(['owner_ref' => $user->public_id, 'name' => 'Coffee', 'price_minor' => 1500, 'currency' => 'GHS', 'is_active' => true]);
$this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Coffee');
}
public function test_retail_register_reads_crm_products_and_snapshots_lines(): void
{
Http::fake([
'crm.test/api/products*' => Http::response(['data' => [
['id' => 7, 'name' => 'Mug', 'unit_price_minor' => 2500, 'currency' => 'GHS', 'active' => true],
]], 200),
'crm.test/api/customers*' => Http::response(['data' => []], 200),
'crm.test/api/*' => Http::response(['id' => 1], 201),
]);
$this->actingAs($user)
->get(route('pos.register'))
->assertOk()
->assertSee('Coffee');
$user = $this->user(); // retail by default (no restaurant location)
$this->actingAs($user)->get(route('pos.register'))->assertOk()->assertSee('Mug');
// A line built from a CRM product is stored as a snapshot — no local FK.
$this->actingAs($user)->post(route('pos.register.charge'), [
'payment_method' => 'cash',
'lines' => [['product_id' => 7, 'name' => 'Mug', 'unit_price_minor' => 2500, 'quantity' => 1]],
])->assertRedirect();
$line = PosSale::where('owner_ref', $user->public_id)->firstOrFail()->lines()->firstOrFail();
$this->assertNull($line->product_id);
$this->assertSame('Mug', $line->name);
$this->assertSame(2500, $line->unit_price_minor);
}
public function test_retail_products_page_lists_crm_products(): void
{
Http::fake([
'crm.test/api/products*' => Http::response(['data' => [
['id' => 7, 'name' => 'Mug', 'sku' => 'M1', 'unit_price_minor' => 2500, 'currency' => 'GHS', 'active' => true],
]], 200),
]);
$this->actingAs($this->user())->get(route('pos.products.index'))->assertOk()->assertSee('Mug');
}
public function test_cash_sale_is_recorded(): void