Link retail POS products to the CRM products API (restaurant stays local)
Deploy Ladill POS / deploy (push) Successful in 28s
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:
co-authored by
Claude Opus 4.8
parent
0d816daed3
commit
5d8e185223
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user