Files
ladill-pos/app/Http/Controllers/Pos/ProductController.php
T
isaaccladandClaude Opus 4.8 50b170b0af
Deploy Ladill POS / deploy (push) Successful in 35s
Restaurant mode Phase 2: menu depth — categories, stations, modifiers, courses
Builds on Phase 1 with the menu structure restaurants need:
- Menu setup page (restaurant mode): categories, kitchen stations, and modifier
  groups with options (price deltas).
- Products gain category, kitchen station, default course, and attached modifier
  groups (managed on the product form).
- Ordering: the ticket groups the catalog by category and, when a product has
  modifier groups, opens a picker (respects min/max select) for options + notes +
  course before adding. Effective price = base + modifier deltas (resolved
  server-side; client prices are never trusted).
- Fire-by-course: send the whole tab or just one course to the kitchen.
- KDS: filter by station; each item shows its station, course, modifiers and notes.

Schema additive (pos_categories, pos_stations, pos_modifier_groups, pos_modifiers,
product↔group pivot, pos_sale_line_modifiers; category/station/course columns on
products and lines). PosRestaurantTest covers modifiers, course and station routing;
suite green (11 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:58:57 +00:00

138 lines
4.8 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 Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
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();
return view('pos.products.index', compact('products'));
}
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),
]);
}
public function store(Request $request): RedirectResponse
{
$product = PosProduct::create([
...$this->validated($request),
'owner_ref' => $this->ownerRef($request),
]);
$product->modifierGroups()->sync($this->groupIds($request));
return redirect()->route('pos.products.index')->with('success', 'Product added.');
}
public function edit(Request $request, PosProduct $product): View
{
$this->authorizeOwner($request, $product);
return view('pos.products.edit', [
'product' => $product,
'selectedGroups' => $product->modifierGroups()->pluck('pos_modifier_groups.id')->all(),
...$this->formOptions($request),
]);
}
public function update(Request $request, PosProduct $product): RedirectResponse
{
$this->authorizeOwner($request, $product);
$product->update($this->validated($request));
$product->modifierGroups()->sync($this->groupIds($request));
return redirect()->route('pos.products.index')->with('success', 'Product updated.');
}
public function destroy(Request $request, PosProduct $product): RedirectResponse
{
$this->authorizeOwner($request, $product);
$product->delete();
return redirect()->route('pos.products.index')->with('success', 'Product removed.');
}
/** @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(),
'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();
}
/** @return array<string, mixed> */
private function validated(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,
];
}
}