Deploy Ladill POS / deploy (push) Successful in 43s
Wallet-backed Pro unlocks unlimited products, restaurant mode, catalog imports, and ecosystem sync features. Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
11 KiB
PHP
277 lines
11 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 App\Services\Import\PosProductCsvImportService;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\View\View;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
/**
|
|
* 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 __construct(private readonly \App\Services\Pos\SubscriptionService $subscriptions) {}
|
|
|
|
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
|
|
{
|
|
$user = ladill_account() ?? $request->user();
|
|
$restaurant = $this->isRestaurant($request);
|
|
|
|
if (! $this->subscriptions->canAddProduct($user, $this->ownerRef($request), $restaurant)) {
|
|
return redirect()->route('pos.pro.index')
|
|
->with('upsell', 'You have reached the free plan limit of '.(int) config('pos.free.max_products', 50).' products. Upgrade to Pro for an unlimited catalog.');
|
|
}
|
|
|
|
if ($restaurant) {
|
|
$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.');
|
|
}
|
|
|
|
/** Bulk CSV import — upload form (mode-aware target catalog). */
|
|
public function importForm(Request $request): View
|
|
{
|
|
return view('pos.products.import', [
|
|
'restaurant' => $this->isRestaurant($request),
|
|
]);
|
|
}
|
|
|
|
/** Bulk CSV import — handle the upload. */
|
|
public function import(Request $request, PosProductCsvImportService $import): RedirectResponse
|
|
{
|
|
$request->validate([
|
|
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
|
|
]);
|
|
|
|
try {
|
|
$result = $import->import(
|
|
$this->ownerRef($request),
|
|
$request->file('file'),
|
|
$this->isRestaurant($request),
|
|
);
|
|
} catch (RuntimeException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
$message = "Imported {$result['imported']} product(s).";
|
|
if ($result['skipped'] > 0) {
|
|
$message .= " Skipped {$result['skipped']}.";
|
|
}
|
|
|
|
return redirect()->route('pos.products.index')
|
|
->with('success', $message)
|
|
->with('import_errors', array_slice($result['errors'], 0, 50));
|
|
}
|
|
|
|
/** Bulk CSV import — downloadable template with example rows. */
|
|
public function importTemplate(Request $request): StreamedResponse
|
|
{
|
|
$restaurant = $this->isRestaurant($request);
|
|
$filename = 'pos-products-template.csv';
|
|
|
|
return response()->streamDownload(function () use ($restaurant) {
|
|
$out = fopen('php://output', 'wb');
|
|
if ($restaurant) {
|
|
fputcsv($out, ['name', 'sku', 'price', 'currency', 'category', 'active']);
|
|
fputcsv($out, ['Coffee', 'CFE-01', '15.00', 'GHS', 'Drinks', 'yes']);
|
|
fputcsv($out, ['Croissant', 'CRS-02', '8.50', 'GHS', 'Pastry', 'yes']);
|
|
} else {
|
|
fputcsv($out, ['name', 'sku', 'price', 'currency', 'description', 'tax_rate', 'active']);
|
|
fputcsv($out, ['Mug', 'MUG-01', '25.00', 'GHS', 'Ceramic mug', '0', 'yes']);
|
|
fputcsv($out, ['T-Shirt', 'TSH-02', '60.00', 'GHS', 'Cotton tee', '0', 'yes']);
|
|
}
|
|
fclose($out);
|
|
}, $filename, ['Content-Type' => 'text/csv']);
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|