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>
208 lines
7.9 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|