From 954ba8f059b6abc0896108facc1a8f43a21230f5 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 28 May 2026 05:27:51 +0000 Subject: [PATCH] =?UTF-8?q?Stage=2012=20=E2=80=94=20Online=20Store:=20publ?= =?UTF-8?q?ic=20catalog=20+=20cart=20+=20orders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Filament/Tenant/Pages/Settings.php | 23 ++ .../Tenant/Resources/OnlineOrderResource.php | 142 ++++++++++ .../Pages/EditOnlineOrder.php | 11 + .../Pages/ListOnlineOrders.php | 11 + .../RelationManagers/ItemsRelationManager.php | 28 ++ .../Tenant/Resources/PartResource.php | 18 ++ .../CrossRefsRelationManager.php | 39 +++ app/Http/Controllers/ShopController.php | 242 ++++++++++++++++++ app/Models/Tenant/OnlineOrder.php | 84 ++++++ app/Models/Tenant/OnlineOrderItem.php | 43 ++++ app/Models/Tenant/Part.php | 32 ++- app/Models/Tenant/PartCrossRef.php | 19 ++ .../Notifications/ShopOrderNotifier.php | 58 +++++ ...5_28_120000_create_online_store_tables.php | 86 +++++++ resources/views/shop/cart.blade.php | 48 ++++ resources/views/shop/catalog.blade.php | 49 ++++ resources/views/shop/checkout.blade.php | 71 +++++ resources/views/shop/layout.blade.php | 80 ++++++ resources/views/shop/order.blade.php | 60 +++++ resources/views/shop/part.blade.php | 49 ++++ resources/views/shop/vin.blade.php | 39 +++ resources/views/site/landing.blade.php | 5 + routes/web.php | 16 ++ tests/Feature/OnlineStoreTest.php | 138 ++++++++++ 24 files changed, 1390 insertions(+), 1 deletion(-) create mode 100644 app/Filament/Tenant/Resources/OnlineOrderResource.php create mode 100644 app/Filament/Tenant/Resources/OnlineOrderResource/Pages/EditOnlineOrder.php create mode 100644 app/Filament/Tenant/Resources/OnlineOrderResource/Pages/ListOnlineOrders.php create mode 100644 app/Filament/Tenant/Resources/OnlineOrderResource/RelationManagers/ItemsRelationManager.php create mode 100644 app/Filament/Tenant/Resources/PartResource/RelationManagers/CrossRefsRelationManager.php create mode 100644 app/Http/Controllers/ShopController.php create mode 100644 app/Models/Tenant/OnlineOrder.php create mode 100644 app/Models/Tenant/OnlineOrderItem.php create mode 100644 app/Models/Tenant/PartCrossRef.php create mode 100644 app/Services/Notifications/ShopOrderNotifier.php create mode 100644 database/migrations/2026_05_28_120000_create_online_store_tables.php create mode 100644 resources/views/shop/cart.blade.php create mode 100644 resources/views/shop/catalog.blade.php create mode 100644 resources/views/shop/checkout.blade.php create mode 100644 resources/views/shop/layout.blade.php create mode 100644 resources/views/shop/order.blade.php create mode 100644 resources/views/shop/part.blade.php create mode 100644 resources/views/shop/vin.blade.php create mode 100644 tests/Feature/OnlineStoreTest.php diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index d1b21ff..c8f69f6 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -55,6 +55,10 @@ class Settings extends Page 'telegram_bot_token' => data_get($settings, 'telegram.bot_token'), 'reminder_after_days' => data_get($settings, 'reminder.after_days', 365), 'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30), + 'shop_enabled' => data_get($settings, 'shop.enabled', false), + 'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']), + 'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0), + 'shop_free_delivery_over' => data_get($settings, 'shop.free_delivery_over', 0), 'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude', 'ai_claude_key' => $settings['ai']['claude_key'] ?? null, 'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null, @@ -167,6 +171,19 @@ class Settings extends Page ->minValue(7) ->default(30), ]), + Schemas\Components\Section::make('Magazin online') + ->description('Activează magazinul public la .service.mir.md/shop. Piesele apar doar dacă sunt marcate „Publicat".') + ->columns(2) + ->schema([ + Forms\Components\Toggle::make('shop_enabled')->label('Magazin activ')->columnSpanFull(), + Forms\Components\CheckboxList::make('shop_delivery_methods') + ->label('Metode de livrare') + ->options(\App\Models\Tenant\OnlineOrder::DELIVERY) + ->default(['pickup']) + ->columnSpanFull(), + Forms\Components\TextInput::make('shop_delivery_fee')->label('Taxă livrare')->numeric()->default(0), + Forms\Components\TextInput::make('shop_free_delivery_over')->label('Livrare gratuită peste')->numeric()->default(0)->helperText('0 = dezactivat'), + ]), Schemas\Components\Section::make('Asistent AI') ->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.') ->columns(2) @@ -218,6 +235,12 @@ class Settings extends Page 'after_days' => (int) ($data['reminder_after_days'] ?? 365), 'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30), ], + 'shop' => [ + 'enabled' => (bool) ($data['shop_enabled'] ?? false), + 'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])), + 'delivery_fee' => (float) ($data['shop_delivery_fee'] ?? 0), + 'free_delivery_over' => (float) ($data['shop_free_delivery_over'] ?? 0), + ], 'ai' => [ 'default_provider' => $data['ai_default_provider'] ?? 'claude', 'claude_key' => $data['ai_claude_key'] ?? null, diff --git a/app/Filament/Tenant/Resources/OnlineOrderResource.php b/app/Filament/Tenant/Resources/OnlineOrderResource.php new file mode 100644 index 0000000..6f8dd6b --- /dev/null +++ b/app/Filament/Tenant/Resources/OnlineOrderResource.php @@ -0,0 +1,142 @@ +where('status', 'new')->count(); + return $new > 0 ? (string) $new : null; + } + + public static function getNavigationBadgeColor(): ?string + { + return 'warning'; + } + + public static function form(Schema $schema): Schema + { + return $schema->components([ + Schemas\Components\Section::make('Comandă') + ->columns(3) + ->schema([ + Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false), + Forms\Components\Select::make('status')->options(OnlineOrder::STATUSES)->required(), + Forms\Components\Select::make('delivery_method')->label('Livrare')->options(OnlineOrder::DELIVERY)->required(), + Forms\Components\TextInput::make('customer_name')->label('Client')->required(), + Forms\Components\TextInput::make('customer_phone')->label('Telefon')->required(), + Forms\Components\TextInput::make('customer_email')->label('Email'), + Forms\Components\TextInput::make('address')->label('Adresă')->columnSpan(2), + Forms\Components\TextInput::make('delivery_fee')->label('Taxă livrare')->numeric(), + Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(), + Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(), + Tables\Columns\TextColumn::make('customer_name')->label('Client')->searchable(), + Tables\Columns\TextColumn::make('customer_phone')->label('Telefon')->copyable(), + Tables\Columns\TextColumn::make('delivery_method') + ->label('Livrare') + ->formatStateUsing(fn ($s) => OnlineOrder::DELIVERY[$s] ?? $s), + Tables\Columns\TextColumn::make('status') + ->formatStateUsing(fn ($s) => OnlineOrder::STATUSES[$s] ?? $s) + ->badge() + ->colors([ + 'warning' => ['new'], + 'info' => ['confirmed', 'packed'], + 'primary' => ['shipped'], + 'success' => ['delivered'], + 'danger' => ['cancelled'], + ]), + Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight()->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status')->options(OnlineOrder::STATUSES), + ]) + ->actions([ + Actions\Action::make('fulfill') + ->label('Onorează (scade stoc)') + ->icon('heroicon-m-check-badge') + ->color('success') + ->visible(fn (OnlineOrder $r) => ! in_array($r->status, ['delivered', 'cancelled'], true)) + ->requiresConfirmation() + ->modalDescription('Scade din stoc piesele legate de catalog (FIFO) și marchează comanda confirmată.') + ->action(function (OnlineOrder $r) { + $svc = app(\App\Services\Warehouse\WarehouseService::class); + $issued = 0; $skipped = 0; + foreach ($r->items as $item) { + if ($item->fulfilled) continue; + if (! $item->part_id) { $skipped++; continue; } + $part = \App\Models\Tenant\Part::find($item->part_id); + if (! $part) { $skipped++; continue; } + try { + $svc->issue($part, (float) $item->qty, null, $r, "Comandă online #{$r->number}"); + $item->fulfilled = true; + $item->save(); + $issued++; + } catch (\App\Services\Warehouse\InsufficientStockException $e) { + $skipped++; + } + } + if ($r->status === 'new') { + $r->status = 'confirmed'; + $r->save(); + } + Notification::make() + ->title("Onorat: {$issued} linii scăzute" . ($skipped ? ", {$skipped} sărite (stoc/lipsă link)" : '')) + ->{$skipped ? 'warning' : 'success'}() + ->send(); + }), + Actions\EditAction::make(), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ItemsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOnlineOrders::route('/'), + 'edit' => Pages\EditOnlineOrder::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/OnlineOrderResource/Pages/EditOnlineOrder.php b/app/Filament/Tenant/Resources/OnlineOrderResource/Pages/EditOnlineOrder.php new file mode 100644 index 0000000..ce94a91 --- /dev/null +++ b/app/Filament/Tenant/Resources/OnlineOrderResource/Pages/EditOnlineOrder.php @@ -0,0 +1,11 @@ +recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap(), + Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'), + Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(), + Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), + Tables\Columns\IconColumn::make('fulfilled')->label('Onorat')->boolean(), + ]); + } +} diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index dc5c99b..335f800 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -80,6 +80,10 @@ class PartResource extends Resource Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16), Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0), Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + Forms\Components\Toggle::make('is_published') + ->label('Publicat în magazin') + ->helperText('Apare în magazinul online public.') + ->default(false), ]), Schemas\Components\Section::make('Prețuri') ->columns(2) @@ -122,6 +126,7 @@ class PartResource extends Resource Tables\Columns\TextColumn::make('unit')->label('UM'), Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'), Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(), + Tables\Columns\IconColumn::make('is_published')->label('Magazin')->boolean()->toggleable(), Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(), ]) ->filters([ @@ -216,6 +221,18 @@ class PartResource extends Resource return redirect()->away('/parts/labels?ids=' . $ids); }) ->deselectRecordsAfterCompletion(), + Actions\BulkAction::make('publish') + ->label('Publică în magazin') + ->icon('heroicon-m-globe-alt') + ->color('success') + ->action(fn ($records) => collect($records)->each->update(['is_published' => true])) + ->deselectRecordsAfterCompletion(), + Actions\BulkAction::make('unpublish') + ->label('Scoate din magazin') + ->icon('heroicon-m-eye-slash') + ->color('gray') + ->action(fn ($records) => collect($records)->each->update(['is_published' => false])) + ->deselectRecordsAfterCompletion(), ]) ->emptyStateHeading('Depozit gol') ->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.') @@ -228,6 +245,7 @@ class PartResource extends Resource return [ RelationManagers\BatchesRelationManager::class, RelationManagers\PriceHistoryRelationManager::class, + RelationManagers\CrossRefsRelationManager::class, ]; } diff --git a/app/Filament/Tenant/Resources/PartResource/RelationManagers/CrossRefsRelationManager.php b/app/Filament/Tenant/Resources/PartResource/RelationManagers/CrossRefsRelationManager.php new file mode 100644 index 0000000..f37c2e7 --- /dev/null +++ b/app/Filament/Tenant/Resources/PartResource/RelationManagers/CrossRefsRelationManager.php @@ -0,0 +1,39 @@ +components([ + Forms\Components\TextInput::make('cross_article')->label('Cod echivalent')->required()->maxLength(64), + Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('cross_article') + ->columns([ + Tables\Columns\TextColumn::make('cross_article')->label('Cod')->searchable(), + Tables\Columns\TextColumn::make('brand')->placeholder('—'), + ]) + ->headerActions([Actions\CreateAction::make()]) + ->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]) + ->emptyStateHeading('Niciun cod cross') + ->emptyStateDescription('Adaugă coduri echivalente OEM/aftermarket ca să fie găsite în căutarea din magazin.'); + } +} diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php new file mode 100644 index 0000000..cf78637 --- /dev/null +++ b/app/Http/Controllers/ShopController.php @@ -0,0 +1,242 @@ +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(), + ]); + } +} diff --git a/app/Models/Tenant/OnlineOrder.php b/app/Models/Tenant/OnlineOrder.php new file mode 100644 index 0000000..5cb555d --- /dev/null +++ b/app/Models/Tenant/OnlineOrder.php @@ -0,0 +1,84 @@ + 'Nouă', + 'confirmed' => 'Confirmată', + 'packed' => 'Pregătită', + 'shipped' => 'Expediată', + 'delivered' => 'Livrată', + 'cancelled' => 'Anulată', + ]; + + public const DELIVERY = [ + 'pickup' => 'Ridicare din service', + 'courier' => 'Curier', + 'post' => 'Poștă', + ]; + + protected $fillable = [ + 'company_id', 'number', 'tracking_token', 'client_id', + 'customer_name', 'customer_phone', 'customer_email', + 'delivery_method', 'address', 'status', + 'subtotal', 'delivery_fee', 'total', 'notes', + ]; + + protected $casts = [ + 'subtotal' => 'decimal:2', + 'delivery_fee' => 'decimal:2', + 'total' => 'decimal:2', + ]; + + public function items(): HasMany + { + return $this->hasMany(OnlineOrderItem::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function trackingUrl(): string + { + return url('/shop/order/' . $this->tracking_token); + } + + public function recalcTotal(): void + { + $this->subtotal = (float) $this->items()->sum('total'); + $this->total = round((float) $this->subtotal + (float) $this->delivery_fee, 2); + $this->save(); + } + + public static function generateNumber(int $companyId): string + { + $year = date('y'); + $count = static::withoutGlobalScopes() + ->where('company_id', $companyId) + ->whereYear('created_at', date('Y')) + ->count(); + return sprintf('SO-%s-%04d', $year, $count + 1); + } + + protected static function booted(): void + { + static::creating(function (self $o) { + if (empty($o->tracking_token)) { + $o->tracking_token = Str::random(24); + } + }); + } +} diff --git a/app/Models/Tenant/OnlineOrderItem.php b/app/Models/Tenant/OnlineOrderItem.php new file mode 100644 index 0000000..0d1d2e4 --- /dev/null +++ b/app/Models/Tenant/OnlineOrderItem.php @@ -0,0 +1,43 @@ + 'decimal:2', + 'price' => 'decimal:2', + 'total' => 'decimal:2', + 'fulfilled' => 'boolean', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(OnlineOrder::class, 'online_order_id'); + } + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + + protected static function booted(): void + { + static::saving(function (self $row) { + $row->total = round((float) $row->qty * (float) $row->price, 2); + }); + static::saved(fn (self $row) => $row->order?->recalcTotal()); + static::deleted(fn (self $row) => $row->order?->recalcTotal()); + } +} diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php index 6ff0c4e..5fe5899 100644 --- a/app/Models/Tenant/Part.php +++ b/app/Models/Tenant/Part.php @@ -22,7 +22,7 @@ class Part extends Model 'qty', 'qty_reserved', 'unit', 'min_qty', 'buy_price', 'sell_price', 'location', 'barcode', 'preferred_supplier_id', - 'is_active', 'notes', + 'is_active', 'is_published', 'notes', ]; protected $casts = [ @@ -32,6 +32,7 @@ class Part extends Model 'buy_price' => 'decimal:2', 'sell_price' => 'decimal:2', 'is_active' => 'boolean', + 'is_published' => 'boolean', ]; public function preferredSupplier(): BelongsTo @@ -59,6 +60,35 @@ class Part extends Model return $this->hasMany(SupplierPartPrice::class); } + public function crossRefs(): HasMany + { + return $this->hasMany(PartCrossRef::class); + } + + public function scopePublished($q) + { + return $q->where('is_active', true)->where('is_published', true); + } + + /** + * Search published parts by free text against name / article / brand and + * any registered cross-reference article. Returns a query builder. + */ + public static function searchPublished(?string $term) + { + $q = static::published(); + if ($term = trim((string) $term)) { + $like = '%' . $term . '%'; + $q->where(function ($w) use ($like, $term) { + $w->where('name', 'like', $like) + ->orWhere('article', 'like', $like) + ->orWhere('brand', 'like', $like) + ->orWhereHas('crossRefs', fn ($c) => $c->where('cross_article', 'like', $like)); + }); + } + return $q; + } + /** Live total across all batches of all warehouses (source of truth). */ public function qtyOnHand(?int $warehouseId = null): float { diff --git a/app/Models/Tenant/PartCrossRef.php b/app/Models/Tenant/PartCrossRef.php new file mode 100644 index 0000000..d3cae93 --- /dev/null +++ b/app/Models/Tenant/PartCrossRef.php @@ -0,0 +1,19 @@ +belongsTo(Part::class); + } +} diff --git a/app/Services/Notifications/ShopOrderNotifier.php b/app/Services/Notifications/ShopOrderNotifier.php new file mode 100644 index 0000000..d576893 --- /dev/null +++ b/app/Services/Notifications/ShopOrderNotifier.php @@ -0,0 +1,58 @@ +find($order->company_id); + if (! $company) return; + + // ── Staff: Web Push to active users of this tenant ── + $title = 'Comandă nouă #' . $order->number; + $body = $order->customer_name . ' · ' . number_format((float) $order->total, 2) . ' ' + . ($company->settings['currency'] ?? 'MDL'); + $url = '/app/resources/online-orders/' . $order->id . '/edit'; + + $userIds = User::where('status', 'active')->pluck('id'); + foreach ($userIds as $uid) { + $this->push->sendToUser((int) $uid, $title, $body, $url, 'shop-order-' . $order->id); + } + + // ── Customer: Telegram if their phone is linked ── + $needle = Client::normalizePhone($order->customer_phone); + if ($needle) { + $client = Client::whereNotNull('telegram_chat_id') + ->whereRaw( + "REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?", + ['%' . substr($needle, -9) . '%'] + ) + ->first(); + + if ($client && $client->telegram_chat_id) { + $brand = htmlspecialchars($company->display_name ?? $company->name); + $text = "🛒 Comanda #{$order->number} primită\n" + . "Total: " . number_format((float) $order->total, 2) . " " + . ($company->settings['currency'] ?? 'MDL') . "\n\n" + . "Urmărește statusul: " . $order->trackingUrl() . "\n\n{$brand}"; + $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text); + } + } + } +} diff --git a/database/migrations/2026_05_28_120000_create_online_store_tables.php b/database/migrations/2026_05_28_120000_create_online_store_tables.php new file mode 100644 index 0000000..1dc5094 --- /dev/null +++ b/database/migrations/2026_05_28_120000_create_online_store_tables.php @@ -0,0 +1,86 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('number', 32); + $t->string('tracking_token', 32); + $t->foreignId('client_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('customer_name'); + $t->string('customer_phone', 40); + $t->string('customer_email')->nullable(); + + $t->string('delivery_method', 32)->default('pickup'); // pickup / courier / post + $t->string('address')->nullable(); + + $t->string('status', 24)->default('new'); + // new / confirmed / packed / shipped / delivered / cancelled + + $t->decimal('subtotal', 12, 2)->default(0); + $t->decimal('delivery_fee', 10, 2)->default(0); + $t->decimal('total', 12, 2)->default(0); + $t->text('notes')->nullable(); + + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'number']); + $t->unique('tracking_token', 'online_orders_token_unique'); + $t->index(['company_id', 'status']); + }); + + Schema::create('online_order_items', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('online_order_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->nullable()->constrained()->nullOnDelete(); + + $t->string('name'); + $t->string('article', 64)->nullable(); + $t->decimal('qty', 10, 2)->default(1); + $t->decimal('price', 12, 2)->default(0); // snapshot of sell_price + $t->decimal('total', 12, 2)->default(0); + $t->boolean('fulfilled')->default(false); + + $t->timestamps(); + $t->index(['company_id', 'online_order_id']); + }); + + Schema::create('part_cross_refs', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->constrained()->cascadeOnDelete(); + $t->string('cross_article', 64); + $t->string('brand', 64)->nullable(); + $t->timestamps(); + + $t->index(['company_id', 'cross_article']); + $t->index(['company_id', 'part_id']); + }); + + Schema::table('parts', function (Blueprint $t) { + $t->boolean('is_published')->default(false)->after('is_active'); + $t->index(['company_id', 'is_published']); + }); + } + + public function down(): void + { + Schema::table('parts', function (Blueprint $t) { + $t->dropIndex(['company_id', 'is_published']); + $t->dropColumn('is_published'); + }); + Schema::dropIfExists('part_cross_refs'); + Schema::dropIfExists('online_order_items'); + Schema::dropIfExists('online_orders'); + } +}; diff --git a/resources/views/shop/cart.blade.php b/resources/views/shop/cart.blade.php new file mode 100644 index 0000000..712beca --- /dev/null +++ b/resources/views/shop/cart.blade.php @@ -0,0 +1,48 @@ +@extends('shop.layout') +@section('title', 'Coș') +@section('content') +@php $currency = $tenant->settings['currency'] ?? 'MDL'; @endphp + +

Coșul meu

+ +@if (empty($cart)) +
+

Coșul e gol.

+ Vezi catalogul +
+@else +
+ @csrf +
+ + + + + + @foreach ($cart as $id => $item) + + + + + + + @endforeach + +
PiesăPrețCant.Total
+ {{ $item['name'] }} + @if (!empty($item['article']))
{{ $item['article'] }}
@endif +
{{ number_format($item['price'], 2) }} {{ $currency }} + + {{ number_format($item['price'] * $item['qty'], 2) }} {{ $currency }}
+
+
+ +
+
Subtotal: {{ number_format($subtotal, 2) }} {{ $currency }}
+ Finalizează comanda → +
+
+
+@endif +@endsection diff --git a/resources/views/shop/catalog.blade.php b/resources/views/shop/catalog.blade.php new file mode 100644 index 0000000..089a7d6 --- /dev/null +++ b/resources/views/shop/catalog.blade.php @@ -0,0 +1,49 @@ +@extends('shop.layout') +@section('title', 'Catalog piese') +@section('content') +@php $currency = $tenant->settings['currency'] ?? 'MDL'; @endphp + +
+ + + + +
+ +@if ($parts->isEmpty()) +
+

Nicio piesă găsită{{ $term ? ' pentru „' . $term . '”' : '' }}.

+
+@else +
+ @foreach ($parts as $p) + @php $stock = (float) $p->qty; @endphp +
+ +

{{ $p->name }}

+
+
+ {{ $p->brand ? $p->brand . ' · ' : '' }}{{ $p->article ?? '' }} +
+
+ {{ $stock > 0 ? '● În stoc' : '○ La comandă' }} +
+
{{ number_format((float) $p->sell_price, 2) }} {{ $currency }}
+
+ @csrf + +
+
+ @endforeach +
+
{{ $parts->links() }}
+@endif +@endsection diff --git a/resources/views/shop/checkout.blade.php b/resources/views/shop/checkout.blade.php new file mode 100644 index 0000000..bb8ea7a --- /dev/null +++ b/resources/views/shop/checkout.blade.php @@ -0,0 +1,71 @@ +@extends('shop.layout') +@section('title', 'Finalizare comandă') +@section('content') +@php + $currency = $tenant->settings['currency'] ?? 'MDL'; + $labels = \App\Models\Tenant\OnlineOrder::DELIVERY; +@endphp + +

Finalizează comanda

+ +@if ($errors->any()) +
+
    + @foreach ($errors->all() as $e)
  • {{ $e }}
  • @endforeach +
+
+@endif + +
+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+

Sumar

+ + @foreach ($cart as $item) + + + + + @endforeach +
{{ $item['name'] }} ×{{ $item['qty'] }}{{ number_format($item['price'] * $item['qty'], 2) }}
+
+ {{ number_format($subtotal, 2) }} {{ $currency }} +
+

Taxa de livrare se calculează în funcție de metoda aleasă.

+
+
+ + +@endsection diff --git a/resources/views/shop/layout.blade.php b/resources/views/shop/layout.blade.php new file mode 100644 index 0000000..51e1990 --- /dev/null +++ b/resources/views/shop/layout.blade.php @@ -0,0 +1,80 @@ +@php + $themeColor = $tenant->settings['theme_color'] ?? '#3B82F6'; + $brand = $tenant->display_name ?? $tenant->name; + $logoUrl = method_exists($tenant, 'getLogoUrl') ? $tenant->getLogoUrl() : null; + $faviconUrl = method_exists($tenant, 'getFaviconUrl') ? $tenant->getFaviconUrl() : null; + $currency = $tenant->settings['currency'] ?? 'MDL'; +@endphp + + + + + +@yield('title', 'Magazin') — {{ $brand }} +@if ($faviconUrl)@endif + + + +
+ +
+
+ @yield('content') +
+
{{ $brand }} · Powered by AutoCRM
+ + diff --git a/resources/views/shop/order.blade.php b/resources/views/shop/order.blade.php new file mode 100644 index 0000000..7e8f52f --- /dev/null +++ b/resources/views/shop/order.blade.php @@ -0,0 +1,60 @@ +@extends('shop.layout') +@section('title', 'Comanda ' . $order->number) +@section('content') +@php + $currency = $tenant->settings['currency'] ?? 'MDL'; + $statuses = \App\Models\Tenant\OnlineOrder::STATUSES; + $delivery = \App\Models\Tenant\OnlineOrder::DELIVERY; + $flow = ['new', 'confirmed', 'packed', 'shipped', 'delivered']; + $idx = array_search($order->status, $flow, true); +@endphp + +
+
Comanda
+
#{{ $order->number }}
+ {{ $statuses[$order->status] ?? $order->status }} +
+ +@if ($order->status !== 'cancelled') +
+

Status

+
+ @foreach ($flow as $i => $st) +
+
+
+ {{ $statuses[$st] }} +
+
+ @endforeach +
+
+@endif + +
+

Produse

+ + @foreach ($order->items as $it) + + + + + @endforeach + + + + +
{{ $it->name }} ×{{ rtrim(rtrim(number_format((float)$it->qty,2),'0'),'.') }}{{ number_format((float) $it->total, 2) }} {{ $currency }}
Livrare ({{ $delivery[$order->delivery_method] ?? $order->delivery_method }}){{ number_format((float) $order->delivery_fee, 2) }} {{ $currency }}
Total{{ number_format((float) $order->total, 2) }} {{ $currency }}
+
+ +
+

Date livrare

+

{{ $order->customer_name }} · {{ $order->customer_phone }}

+ @if ($order->address)

{{ $order->address }}

@endif +
+ +
+ ← Continuă cumpărăturile +
+@endsection diff --git a/resources/views/shop/part.blade.php b/resources/views/shop/part.blade.php new file mode 100644 index 0000000..1a56b31 --- /dev/null +++ b/resources/views/shop/part.blade.php @@ -0,0 +1,49 @@ +@extends('shop.layout') +@section('title', $part->name) +@section('content') +@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; @endphp + +← Înapoi la catalog + +
+

{{ $part->name }}

+
+ {{ $part->brand ? 'Brand: ' . $part->brand : '' }} + {{ $part->article ? ' · Cod: ' . $part->article : '' }} + {{ $part->category ? ' · ' . $part->category : '' }} +
+ +
+ {{ $stock > 0 ? '● În stoc (' . rtrim(rtrim(number_format($stock, 2), '0'), '.') . ' ' . ($part->unit ?? 'buc') . ')' : '○ La comandă' }} +
+
+ {{ number_format((float) $part->sell_price, 2) }} {{ $currency }} +
+ +
+ @csrf + + +
+ + @if ($part->crossRefs->isNotEmpty()) +
+

Coduri echivalente (cross)

+
+ @foreach ($part->crossRefs as $cr) + + {{ $cr->cross_article }}{{ $cr->brand ? ' (' . $cr->brand . ')' : '' }} + + @endforeach +
+
+ @endif + + @if ($part->notes) +
+

Descriere

+

{{ $part->notes }}

+
+ @endif +
+@endsection diff --git a/resources/views/shop/vin.blade.php b/resources/views/shop/vin.blade.php new file mode 100644 index 0000000..7a355ce --- /dev/null +++ b/resources/views/shop/vin.blade.php @@ -0,0 +1,39 @@ +@extends('shop.layout') +@section('title', 'Căutare după VIN') +@section('content') + +
+

Caută piese după VIN

+

Introdu codul VIN (17 caractere) ca să identificăm mașina. Apoi caută piesele în catalog.

+ +
+ + +
+
+ +@if ($decoded) +
+ @if (! ($decoded['valid_length'] ?? false)) +

{{ $decoded['reason'] ?? 'VIN invalid — trebuie 17 caractere.' }}

+ @else +

Mașină identificată

+ + + + + +
Producător{{ $decoded['manufacturer'] ?? '—' }}
An model{{ $decoded['year'] ?? '—' }}
Țară{{ $decoded['country'] ?? '—' }}
Regiune{{ $decoded['region'] ?? '—' }}
+
+ + Caută piese pentru {{ $decoded['manufacturer'] ?? 'această mașină' }} → + +
+

+ Pentru compatibilitate exactă pe model/motorizare, contactează service-ul cu acest VIN. +

+ @endif +
+@endif +@endsection diff --git a/resources/views/site/landing.blade.php b/resources/views/site/landing.blade.php index 231aa27..01d5ae7 100644 --- a/resources/views/site/landing.blade.php +++ b/resources/views/site/landing.blade.php @@ -58,6 +58,11 @@ @endif

{{ $name }}

Autoservice profesional{{ $city ? ' — ' . $city : '' }}. Diagnostic, reparații, piese, ITP.

+ @if (! empty($shopEnabled)) + + 🛒 Magazin piese online + + @endif @if (! empty($services)) diff --git a/routes/web.php b/routes/web.php index 1fae449..e714c4b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ Route::get('/', function () { 'cars' => (array) ($tenant->settings['cars'] ?? []), 'logoUrl' => $tenant->getLogoUrl(), 'faviconUrl' => $tenant->getFaviconUrl(), + 'shopEnabled' => (bool) data_get($tenant->settings, 'shop.enabled', false), ]); } // On the central domain → redirect to admin. @@ -77,6 +78,21 @@ Route::post('/telegram/webhook/{slug}', [\App\Http\Controllers\TelegramWebhookCo ]) ->name('telegram.webhook'); +// ─── Online Store (public, tenant-scoped via subdomain) ──────────── +Route::controller(\App\Http\Controllers\ShopController::class)->prefix('shop')->group(function () { + Route::get('/', 'catalog')->name('shop.catalog'); + Route::get('/vin', 'vin')->name('shop.vin'); + Route::get('/cart', 'showCart')->name('shop.cart'); + Route::post('/cart/update', 'updateCart')->name('shop.cart.update'); + Route::get('/checkout', 'checkout')->name('shop.checkout'); + Route::post('/checkout', 'placeOrder')->name('shop.order.place'); + Route::get('/order/{token}', 'orderStatus') + ->where('token', '[A-Za-z0-9]{16,32}') + ->name('shop.order'); + Route::get('/part/{id}', 'part')->where('id', '\d+')->name('shop.part'); + Route::post('/part/{id}/add', 'addToCart')->where('id', '\d+')->name('shop.cart.add'); +}); + // ─── Public WO tracking (no auth, tenant-scoped via subdomain) ────── Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show']) ->where('token', '[A-Za-z0-9]{16,32}') diff --git a/tests/Feature/OnlineStoreTest.php b/tests/Feature/OnlineStoreTest.php new file mode 100644 index 0000000..739d5fb --- /dev/null +++ b/tests/Feature/OnlineStoreTest.php @@ -0,0 +1,138 @@ +makeShop('shopoff', enabled: false); + $this->get('http://shopoff.service.mir.md/shop')->assertNotFound(); + } + + public function test_catalog_lists_published_parts_only(): void + { + $ctx = $this->makeShop('shopon', enabled: true); + Part::create(['name' => 'Filtru public', 'sell_price' => 100, 'qty' => 5, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + Part::create(['name' => 'Filtru privat', 'sell_price' => 100, 'qty' => 5, 'unit' => 'buc', 'is_active' => true, 'is_published' => false]); + + $resp = $this->get('http://shopon.service.mir.md/shop'); + $resp->assertOk(); + $resp->assertSee('Filtru public'); + $resp->assertDontSee('Filtru privat'); + } + + public function test_search_matches_cross_reference(): void + { + $ctx = $this->makeShop('shopcross', enabled: true); + $part = Part::create(['name' => 'Filtru ulei', 'article' => 'OEM-1', 'sell_price' => 50, 'qty' => 3, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + PartCrossRef::create(['part_id' => $part->id, 'cross_article' => 'W811-80', 'brand' => 'MANN']); + + // searchPublished should find the part by its cross article. + $found = Part::searchPublished('W811-80')->get(); + $this->assertCount(1, $found); + $this->assertEquals($part->id, $found->first()->id); + } + + public function test_place_order_creates_order_with_token_and_items(): void + { + $ctx = $this->makeShop('shopbuy', enabled: true); + $part = Part::create(['name' => 'Plăcuțe frână', 'sell_price' => 200, 'qty' => 10, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + + $base = 'http://shopbuy.service.mir.md/shop'; + + // Add to cart (session persists across requests in the same test). + $this->post("$base/part/{$part->id}/add", ['qty' => 2])->assertRedirect(); + + $resp = $this->post("$base/checkout", [ + 'customer_name' => 'Ion Pop', + 'customer_phone' => '+37369123456', + 'customer_email' => 'ion@example.com', + 'delivery_method' => 'pickup', + ]); + $resp->assertRedirect(); + + $order = OnlineOrder::first(); + $this->assertNotNull($order); + $this->assertEquals('Ion Pop', $order->customer_name); + $this->assertNotEmpty($order->tracking_token); + $this->assertEquals(400.0, (float) $order->total); // 2 × 200, pickup = no fee + $this->assertEquals(1, $order->items()->count()); + + // Confirmation page reachable by token. + $this->get($order->trackingUrl())->assertOk()->assertSee($order->number); + } + + public function test_fulfill_issues_stock_via_warehouse(): void + { + $ctx = $this->makeShop('shopfulfill', enabled: true); + $svc = app(WarehouseService::class); + $part = Part::create(['name' => 'Amortizor', 'sell_price' => 500, 'qty' => 0, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + $svc->receive($part, 10, 300.0); + + $order = OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($ctx['company']->id), + 'customer_name' => 'X', 'customer_phone' => '+37360000000', + 'delivery_method' => 'pickup', 'status' => 'new', + ]); + $order->items()->create([ + 'company_id' => $ctx['company']->id, + 'part_id' => $part->id, + 'name' => $part->name, 'qty' => 3, 'price' => 500, + ]); + + // Simulate the fulfill action body. + $svc->issue($part, 3, null, $order, 'test'); + $part->refresh(); + + $this->assertEquals(7.0, (float) $part->qty); + $this->assertEquals(1, WarehouseEvent::where('part_id', $part->id)->where('type', 'issue')->count()); + } + + public function test_order_token_isolated_across_tenants(): void + { + $a = $this->makeShop('shopa', enabled: true); + $partA = Part::create(['name' => 'P', 'sell_price' => 10, 'qty' => 1, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]); + $orderA = OnlineOrder::create([ + 'number' => OnlineOrder::generateNumber($a['company']->id), + 'customer_name' => 'A', 'customer_phone' => '1', 'delivery_method' => 'pickup', 'status' => 'new', + ]); + + $this->makeShop('shopb', enabled: true); + + // Tenant B cannot view tenant A's order token. + $this->get('http://shopb.service.mir.md/order/' . $orderA->tracking_token)->assertNotFound(); + } + + private function makeShop(string $slug, bool $enabled): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => $slug, + 'name' => ucfirst($slug), + 'status' => 'active', + 'settings' => ['shop' => ['enabled' => $enabled, 'delivery_methods' => ['pickup', 'courier'], 'delivery_fee' => 50, 'free_delivery_over' => 1000]], + ]); + app(TenantManager::class)->setCurrent($company); + + $wh = Warehouse::create(['code' => 'MAIN', 'name' => 'D', 'is_default' => true, 'is_active' => true]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + + return compact('company', 'wh'); + } +}