Files
ladill-pos/app/Http/Controllers/Pos/ProductController.php
T
isaacclad e2b608a506
Deploy Ladill POS / deploy (push) Successful in 46s
Keep POS retail catalog product-only and raise Pro pricing.
Retail still reads live CRM products only (not services), products mode follows the acting branch, and suite Pro/Business defaults match the tenfold price update.
2026-07-15 13:54:53 +00:00

278 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\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', ''));
// Live CRM products only (not services) — same filter as the register.
$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
{
// Use the acting branch — not "any restaurant location exists" — so a
// retail register still sees the live CRM product catalog.
return $this->location($request)->isRestaurant();
}
/** Retail: payload for the CRM products API (always type=product for POS). */
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,
];
}
}