Files
ladill-pos/app/Http/Controllers/Pos/ProductController.php
T
isaaccladandClaude Opus 4.8 5d8e185223
Deploy Ladill POS / deploy (push) Successful in 28s
Link retail POS products to the CRM products API (restaurant stays local)
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>
2026-06-25 12:27:19 +00:00

208 lines
7.9 KiB
PHP

<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosCategory;
use App\Models\PosLocation;
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
{
if ($this->isRestaurant($request)) {
$products = PosProduct::owned($this->ownerRef($request))
->with('category')->orderBy('name')->paginate(30)->withQueryString();
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
{
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
{
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, string $product): View
{
if ($this->isRestaurant($request)) {
$model = PosProduct::owned($this->ownerRef($request))->findOrFail((int) $product);
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, string $product): RedirectResponse
{
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, string $product): RedirectResponse
{
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' => true,
'categories' => PosCategory::owned($owner)->orderBy('name')->get(),
'stations' => PosStation::owned($owner)->orderBy('name')->get(),
'modifierGroups' => PosModifierGroup::owned($owner)->orderBy('name')->get(),
];
}
/** @return list<int> */
private function groupIds(Request $request): array
{
$owner = $this->ownerRef($request);
$ids = array_map('intval', (array) $request->input('modifier_group_ids', []));
return PosModifierGroup::owned($owner)->whereIn('id', $ids)->pluck('id')->all();
}
/** Restaurant: local pos_products attributes. */
private function localValidated(Request $request): array
{
$owner = $this->ownerRef($request);
$data = $request->validate([
'name' => ['required', 'string', 'max:200'],
'sku' => ['nullable', 'string', 'max:80'],
'price' => ['required', 'numeric', 'min:0.01'],
'currency' => ['nullable', 'string', 'size:3'],
'is_active' => ['sometimes', 'boolean'],
'category_id' => ['nullable', 'integer'],
'station_id' => ['nullable', 'integer'],
'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))],
]);
$categoryId = ($data['category_id'] ?? null) && PosCategory::owned($owner)->whereKey($data['category_id'])->exists()
? (int) $data['category_id'] : null;
$stationId = ($data['station_id'] ?? null) && PosStation::owned($owner)->whereKey($data['station_id'])->exists()
? (int) $data['station_id'] : null;
return [
'name' => $data['name'],
'sku' => $data['sku'] ?? null,
'price_minor' => (int) round(((float) $data['price']) * 100),
'currency' => strtoupper($data['currency'] ?? config('pos.default_currency', 'GHS')),
'is_active' => $request->boolean('is_active', true),
'category_id' => $categoryId,
'station_id' => $stationId,
'course' => $data['course'] ?? null,
];
}
}