954ba8f059
Schema: - online_orders (token-tracked, status workflow, delivery method/fee) - online_order_items (price snapshot, fulfilled flag) - part_cross_refs (OEM/equivalent codes for search) - parts.is_published (shop visibility) Storefront (ShopController, tenant subdomain, /shop): - Catalog with search across name/article/brand/cross-refs, category + in-stock filters, live stock, white-label themed layout - Part detail page with cross-ref codes - VIN search → VinDecoder → guided catalog search - Session cart (per-tenant key), guest checkout, order confirmation page - Respects settings.shop.enabled (404 when off); tenant-guarded Part::searchPublished matches cross-ref articles via whereHas. Order notifications (ShopOrderNotifier, best-effort): - Staff: Web Push to active users - Customer: Telegram if phone matches a linked client Filament (tenant): - OnlineOrderResource under "Magazin" nav group, status workflow, items relation, "Onorează" action issues stock via WarehouseService (FIFO) - PartResource: is_published toggle + column + bulk publish/unpublish + CrossRefsRelationManager - Settings: shop section (enable, delivery methods, fee, free-over) - Landing page: shop button when enabled Tests (6 new): - catalog 404 when disabled; lists published only; cross-ref search; order placement (token + items + total); fulfill issues stock; cross-tenant token isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
8.0 KiB
PHP
243 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Tenant\OnlineOrder;
|
|
use App\Models\Tenant\OnlineOrderItem;
|
|
use App\Models\Tenant\Part;
|
|
use App\Services\Ai\VinDecoder;
|
|
use App\Tenancy\TenantManager;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ShopController extends Controller
|
|
{
|
|
private function tenantOrFail()
|
|
{
|
|
$tenant = app(TenantManager::class)->current();
|
|
if (! $tenant) {
|
|
throw new NotFoundHttpException('Magazinul e disponibil doar pe subdomeniul service-ului.');
|
|
}
|
|
if (! data_get($tenant->settings, 'shop.enabled')) {
|
|
throw new NotFoundHttpException('Magazinul online nu este activ.');
|
|
}
|
|
return $tenant;
|
|
}
|
|
|
|
public function catalog(Request $request)
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
|
|
$term = $request->query('q');
|
|
$category = $request->query('cat');
|
|
$inStock = $request->boolean('in_stock');
|
|
|
|
$query = Part::searchPublished($term);
|
|
if ($category) $query->where('category', $category);
|
|
if ($inStock) $query->where('qty', '>', 0);
|
|
|
|
$parts = $query->orderBy('name')->paginate(24)->withQueryString();
|
|
$categories = Part::published()->distinct()->pluck('category')->filter()->sort()->values();
|
|
|
|
return view('shop.catalog', [
|
|
'tenant' => $tenant,
|
|
'parts' => $parts,
|
|
'categories' => $categories,
|
|
'term' => $term,
|
|
'category' => $category,
|
|
'inStock' => $inStock,
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
|
|
public function part(Request $request, int $id)
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$part = Part::published()->with('crossRefs')->find($id);
|
|
if (! $part) throw new NotFoundHttpException('Piesa nu există sau nu e publicată.');
|
|
|
|
return view('shop.part', [
|
|
'tenant' => $tenant,
|
|
'part' => $part,
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
|
|
public function vin(Request $request)
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$vin = strtoupper(trim((string) $request->query('vin', '')));
|
|
$decoded = null;
|
|
if ($vin !== '') {
|
|
$decoded = app(VinDecoder::class)->decode($vin);
|
|
}
|
|
|
|
return view('shop.vin', [
|
|
'tenant' => $tenant,
|
|
'vin' => $vin,
|
|
'decoded' => $decoded,
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
|
|
// ─── Cart (session) ───────────────────────────────────────────
|
|
|
|
private function cartKey(): string
|
|
{
|
|
$tenant = app(TenantManager::class)->current();
|
|
return 'shop_cart_' . ($tenant?->id ?? '0');
|
|
}
|
|
|
|
private function cart(): array
|
|
{
|
|
return (array) session($this->cartKey(), []);
|
|
}
|
|
|
|
private function cartCount(): int
|
|
{
|
|
return (int) collect($this->cart())->sum('qty');
|
|
}
|
|
|
|
public function addToCart(Request $request, int $id)
|
|
{
|
|
$this->tenantOrFail();
|
|
$part = Part::published()->findOrFail($id);
|
|
$qty = max(1, (int) $request->input('qty', 1));
|
|
|
|
$cart = $this->cart();
|
|
$cart[$id] = [
|
|
'part_id' => $part->id,
|
|
'name' => $part->name,
|
|
'article' => $part->article,
|
|
'price' => (float) $part->sell_price,
|
|
'qty' => ($cart[$id]['qty'] ?? 0) + $qty,
|
|
];
|
|
session([$this->cartKey() => $cart]);
|
|
|
|
return redirect('/shop/cart');
|
|
}
|
|
|
|
public function updateCart(Request $request)
|
|
{
|
|
$this->tenantOrFail();
|
|
$cart = $this->cart();
|
|
foreach ((array) $request->input('qty', []) as $id => $qty) {
|
|
$qty = (int) $qty;
|
|
if ($qty <= 0) {
|
|
unset($cart[$id]);
|
|
} elseif (isset($cart[$id])) {
|
|
$cart[$id]['qty'] = $qty;
|
|
}
|
|
}
|
|
session([$this->cartKey() => $cart]);
|
|
return redirect('/shop/cart');
|
|
}
|
|
|
|
public function showCart()
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$cart = $this->cart();
|
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
|
|
|
return view('shop.cart', [
|
|
'tenant' => $tenant,
|
|
'cart' => $cart,
|
|
'subtotal' => $subtotal,
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
|
|
public function checkout()
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$cart = $this->cart();
|
|
if (empty($cart)) return redirect('/shop');
|
|
|
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
|
|
|
return view('shop.checkout', [
|
|
'tenant' => $tenant,
|
|
'cart' => $cart,
|
|
'subtotal' => $subtotal,
|
|
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
|
|
public function placeOrder(Request $request)
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$cart = $this->cart();
|
|
if (empty($cart)) return redirect('/shop');
|
|
|
|
$data = $request->validate([
|
|
'customer_name' => 'required|string|max:160',
|
|
'customer_phone' => 'required|string|max:40',
|
|
'customer_email' => 'nullable|email|max:160',
|
|
'delivery_method' => 'required|in:pickup,courier,post',
|
|
'address' => 'nullable|string|max:255',
|
|
'notes' => 'nullable|string|max:1000',
|
|
]);
|
|
|
|
$deliveryFee = 0.0;
|
|
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
|
if ($data['delivery_method'] !== 'pickup') {
|
|
$fee = (float) data_get($tenant->settings, 'shop.delivery_fee', 0);
|
|
$freeOver = (float) data_get($tenant->settings, 'shop.free_delivery_over', 0);
|
|
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
|
|
}
|
|
|
|
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee) {
|
|
$order = OnlineOrder::create([
|
|
'number' => OnlineOrder::generateNumber($tenant->id),
|
|
'customer_name' => $data['customer_name'],
|
|
'customer_phone' => $data['customer_phone'],
|
|
'customer_email' => $data['customer_email'] ?? null,
|
|
'delivery_method' => $data['delivery_method'],
|
|
'address' => $data['address'] ?? null,
|
|
'notes' => $data['notes'] ?? null,
|
|
'status' => 'new',
|
|
'delivery_fee' => $deliveryFee,
|
|
]);
|
|
|
|
foreach ($cart as $item) {
|
|
OnlineOrderItem::create([
|
|
'online_order_id' => $order->id,
|
|
'part_id' => $item['part_id'] ?? null,
|
|
'name' => $item['name'],
|
|
'article' => $item['article'] ?? null,
|
|
'qty' => $item['qty'],
|
|
'price' => $item['price'],
|
|
]);
|
|
}
|
|
$order->refresh()->recalcTotal();
|
|
return $order;
|
|
});
|
|
|
|
session()->forget($this->cartKey());
|
|
|
|
// Notify (best-effort): customer + shop staff.
|
|
try {
|
|
app(\App\Services\Notifications\ShopOrderNotifier::class)->placed($order);
|
|
} catch (\Throwable $e) {
|
|
\Illuminate\Support\Facades\Log::debug('shop order notify skipped: ' . $e->getMessage());
|
|
}
|
|
|
|
return redirect('/shop/order/' . $order->tracking_token);
|
|
}
|
|
|
|
public function orderStatus(Request $request, string $token)
|
|
{
|
|
$tenant = $this->tenantOrFail();
|
|
$order = OnlineOrder::with('items')->where('tracking_token', $token)->first();
|
|
if (! $order) throw new NotFoundHttpException('Comanda nu a fost găsită.');
|
|
|
|
return view('shop.order', [
|
|
'tenant' => $tenant,
|
|
'order' => $order,
|
|
'cartCount' => $this->cartCount(),
|
|
]);
|
|
}
|
|
}
|