diff --git a/app/Http/Controllers/Pos/ProductController.php b/app/Http/Controllers/Pos/ProductController.php index d73a8e6..42e27f1 100644 --- a/app/Http/Controllers/Pos/ProductController.php +++ b/app/Http/Controllers/Pos/ProductController.php @@ -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 */ 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 */ - private function validated(Request $request): array + /** Restaurant: local pos_products attributes. */ + private function localValidated(Request $request): array { $owner = $this->ownerRef($request); diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index 893571c..cf43d91 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -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 + */ + 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, diff --git a/app/Services/Crm/CrmClient.php b/app/Services/Crm/CrmClient.php index e7d816a..1f1e32a 100644 --- a/app/Services/Crm/CrmClient.php +++ b/app/Services/Crm/CrmClient.php @@ -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 { diff --git a/resources/views/pos/products/retail/_form.blade.php b/resources/views/pos/products/retail/_form.blade.php new file mode 100644 index 0000000..0e3ce8c --- /dev/null +++ b/resources/views/pos/products/retail/_form.blade.php @@ -0,0 +1,56 @@ +@php + /** @var array $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()) +
+
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
+
+@endif + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
diff --git a/resources/views/pos/products/retail/create.blade.php b/resources/views/pos/products/retail/create.blade.php new file mode 100644 index 0000000..6b8a1aa --- /dev/null +++ b/resources/views/pos/products/retail/create.blade.php @@ -0,0 +1,17 @@ + +
+ + + All products + +
+

New product

+

Added to your Ladill CRM catalog and the register.

+
+
+ @csrf + @include('pos.products.retail._form', ['product' => []]) + +
+
+
diff --git a/resources/views/pos/products/retail/edit.blade.php b/resources/views/pos/products/retail/edit.blade.php new file mode 100644 index 0000000..c76232c --- /dev/null +++ b/resources/views/pos/products/retail/edit.blade.php @@ -0,0 +1,18 @@ + +
+ + + All products + +
+

Edit product

+

Changes sync to your Ladill CRM catalog.

+
+
+ @csrf + @method('PUT') + @include('pos.products.retail._form', ['product' => $product]) + +
+
+
diff --git a/resources/views/pos/products/retail/index.blade.php b/resources/views/pos/products/retail/index.blade.php new file mode 100644 index 0000000..df9d0ad --- /dev/null +++ b/resources/views/pos/products/retail/index.blade.php @@ -0,0 +1,70 @@ + +
+
+
+

Products

+

Your retail catalog, synced live with Ladill CRM.

+
+ New product +
+ + @if(session('success')) +
{{ session('success') }}
+ @endif + @if(session('error')) +
{{ session('error') }}
+ @endif + +
+ + +
+ + @if(empty($products)) +
+

{{ $search !== '' ? 'No products match your search.' : 'No products yet.' }}

+ Add your first product +
+ @else +
+ + + + + + + + + + + + @foreach($products as $p) + + + + + + + + @endforeach + +
ProductSKUPriceStatus
{{ $p['name'] ?? '—' }}{{ $p['sku'] ?: '—' }} + {{ strtoupper($p['currency'] ?? 'GHS') }} {{ number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2) }} + + + {{ ($p['active'] ?? true) ? 'Active' : 'Inactive' }} + + +
+ Edit +
+ @csrf @method('DELETE') + +
+
+
+
+ @endif +
+
diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php index 06ee2b0..cb4630d 100644 --- a/resources/views/pos/register.blade.php +++ b/resources/views/pos/register.blade.php @@ -1,10 +1,6 @@
@@ -44,7 +40,7 @@

- @if ($products->isEmpty()) + @if (empty($products))

No products yet. Add products or use Quick amount.

@endif
diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index ebc90a8..17c002e 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -38,9 +38,10 @@
+ @if ($location->isRestaurant())

Import catalog

-

Pull products into your local POS catalog from CRM or Merchant storefronts.

+

Seed your local POS catalog from CRM or Merchant storefronts. (Retail mode reads products live from CRM, so no import is needed.)

@csrf @@ -58,6 +59,7 @@ @endif
+ @endif @if ($location->isRestaurant())
diff --git a/tests/Feature/PosRegisterTest.php b/tests/Feature/PosRegisterTest.php index 926135b..a3a3da0 100644 --- a/tests/Feature/PosRegisterTest.php +++ b/tests/Feature/PosRegisterTest.php @@ -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