Stage 12 — Online Store: public catalog + cart + orders
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>
This commit is contained in:
@@ -55,6 +55,10 @@ class Settings extends Page
|
|||||||
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
|
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
|
||||||
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
|
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
|
||||||
'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30),
|
'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_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
|
||||||
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
|
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
|
||||||
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
|
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
|
||||||
@@ -167,6 +171,19 @@ class Settings extends Page
|
|||||||
->minValue(7)
|
->minValue(7)
|
||||||
->default(30),
|
->default(30),
|
||||||
]),
|
]),
|
||||||
|
Schemas\Components\Section::make('Magazin online')
|
||||||
|
->description('Activează magazinul public la <slug>.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')
|
Schemas\Components\Section::make('Asistent AI')
|
||||||
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
|
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -218,6 +235,12 @@ class Settings extends Page
|
|||||||
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
|
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
|
||||||
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
|
'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' => [
|
'ai' => [
|
||||||
'default_provider' => $data['ai_default_provider'] ?? 'claude',
|
'default_provider' => $data['ai_default_provider'] ?? 'claude',
|
||||||
'claude_key' => $data['ai_claude_key'] ?? null,
|
'claude_key' => $data['ai_claude_key'] ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||||
|
use App\Models\Tenant\OnlineOrder;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class OnlineOrderResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = OnlineOrder::class;
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shopping-bag';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Comenzi online';
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = 'Magazin';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'comandă';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'comenzi online';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 50;
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
$new = static::getModel()::query()->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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditOnlineOrder extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OnlineOrderResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListOnlineOrders extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OnlineOrderResource::class;
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ItemsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'items';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Produse';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,10 @@ class PartResource extends Resource
|
|||||||
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
||||||
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
|
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_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')
|
Schemas\Components\Section::make('Prețuri')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
@@ -122,6 +126,7 @@ class PartResource extends Resource
|
|||||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||||
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
|
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
|
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(),
|
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@@ -216,6 +221,18 @@ class PartResource extends Resource
|
|||||||
return redirect()->away('/parts/labels?ids=' . $ids);
|
return redirect()->away('/parts/labels?ids=' . $ids);
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->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')
|
->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.')
|
->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 [
|
return [
|
||||||
RelationManagers\BatchesRelationManager::class,
|
RelationManagers\BatchesRelationManager::class,
|
||||||
RelationManagers\PriceHistoryRelationManager::class,
|
RelationManagers\PriceHistoryRelationManager::class,
|
||||||
|
RelationManagers\CrossRefsRelationManager::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class CrossRefsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'crossRefs';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Coduri cross (OEM/echivalente)';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OnlineOrder extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant, SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUSES = [
|
||||||
|
'new' => '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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OnlineOrderItem extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'company_id', 'online_order_id', 'part_id',
|
||||||
|
'name', 'article', 'qty', 'price', 'total', 'fulfilled',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'qty' => '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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ class Part extends Model
|
|||||||
'qty', 'qty_reserved', 'unit', 'min_qty',
|
'qty', 'qty_reserved', 'unit', 'min_qty',
|
||||||
'buy_price', 'sell_price',
|
'buy_price', 'sell_price',
|
||||||
'location', 'barcode', 'preferred_supplier_id',
|
'location', 'barcode', 'preferred_supplier_id',
|
||||||
'is_active', 'notes',
|
'is_active', 'is_published', 'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -32,6 +32,7 @@ class Part extends Model
|
|||||||
'buy_price' => 'decimal:2',
|
'buy_price' => 'decimal:2',
|
||||||
'sell_price' => 'decimal:2',
|
'sell_price' => 'decimal:2',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'is_published' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function preferredSupplier(): BelongsTo
|
public function preferredSupplier(): BelongsTo
|
||||||
@@ -59,6 +60,35 @@ class Part extends Model
|
|||||||
return $this->hasMany(SupplierPartPrice::class);
|
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). */
|
/** Live total across all batches of all warehouses (source of truth). */
|
||||||
public function qtyOnHand(?int $warehouseId = null): float
|
public function qtyOnHand(?int $warehouseId = null): float
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PartCrossRef extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $fillable = ['company_id', 'part_id', 'cross_article', 'brand'];
|
||||||
|
|
||||||
|
public function part(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Part::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\OnlineOrder;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies staff of a new online order (Web Push) and confirms to the customer
|
||||||
|
* via Telegram if they have a linked chat. Best-effort — never throws.
|
||||||
|
*/
|
||||||
|
class ShopOrderNotifier
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WebPushService $push,
|
||||||
|
private TelegramService $telegram,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function placed(OnlineOrder $order): void
|
||||||
|
{
|
||||||
|
$company = Company::withoutGlobalScopes()->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 = "🛒 <b>Comanda #{$order->number} primită</b>\n"
|
||||||
|
. "Total: <b>" . number_format((float) $order->total, 2) . " "
|
||||||
|
. ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
|
||||||
|
. "Urmărește statusul: " . $order->trackingUrl() . "\n\n{$brand}";
|
||||||
|
$this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('online_orders', function (Blueprint $t) {
|
||||||
|
$t->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@extends('shop.layout')
|
||||||
|
@section('title', 'Coș')
|
||||||
|
@section('content')
|
||||||
|
@php $currency = $tenant->settings['currency'] ?? 'MDL'; @endphp
|
||||||
|
|
||||||
|
<h1 style="font-size:22px;margin-bottom:16px;">Coșul meu</h1>
|
||||||
|
|
||||||
|
@if (empty($cart))
|
||||||
|
<div class="card" style="text-align:center;padding:40px;">
|
||||||
|
<p class="muted">Coșul e gol.</p>
|
||||||
|
<a class="btn" href="/shop" style="margin-top:12px;">Vezi catalogul</a>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<form method="POST" action="/shop/cart/update">
|
||||||
|
@csrf
|
||||||
|
<div class="card">
|
||||||
|
<table class="cart">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Piesă</th><th class="r">Preț</th><th class="r">Cant.</th><th class="r">Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($cart as $id => $item)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/shop/part/{{ $item['part_id'] }}">{{ $item['name'] }}</a>
|
||||||
|
@if (!empty($item['article']))<div class="muted">{{ $item['article'] }}</div>@endif
|
||||||
|
</td>
|
||||||
|
<td class="r">{{ number_format($item['price'], 2) }} {{ $currency }}</td>
|
||||||
|
<td class="r">
|
||||||
|
<input type="number" name="qty[{{ $id }}]" value="{{ $item['qty'] }}" min="0"
|
||||||
|
style="width:64px;padding:6px;border:1px solid #d1d5db;border-radius:6px;text-align:right;">
|
||||||
|
</td>
|
||||||
|
<td class="r">{{ number_format($item['price'] * $item['qty'], 2) }} {{ $currency }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;flex-wrap:wrap;gap:12px;">
|
||||||
|
<button class="btn outline" type="submit">Actualizează coșul</button>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div style="font-size:20px;font-weight:700;">Subtotal: {{ number_format($subtotal, 2) }} {{ $currency }}</div>
|
||||||
|
<a class="btn" href="/shop/checkout" style="margin-top:8px;">Finalizează comanda →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@extends('shop.layout')
|
||||||
|
@section('title', 'Catalog piese')
|
||||||
|
@section('content')
|
||||||
|
@php $currency = $tenant->settings['currency'] ?? 'MDL'; @endphp
|
||||||
|
|
||||||
|
<form method="GET" action="/shop" class="filters">
|
||||||
|
<input type="text" name="q" value="{{ $term }}" placeholder="Caută denumire, cod, brand, cod cross…">
|
||||||
|
<select name="cat" onchange="this.form.submit()">
|
||||||
|
<option value="">Toate categoriile</option>
|
||||||
|
@foreach ($categories as $c)
|
||||||
|
<option value="{{ $c }}" {{ $category === $c ? 'selected' : '' }}>{{ $c }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:14px;">
|
||||||
|
<input type="checkbox" name="in_stock" value="1" {{ $inStock ? 'checked' : '' }} onchange="this.form.submit()">
|
||||||
|
Doar în stoc
|
||||||
|
</label>
|
||||||
|
<button class="btn" type="submit">Caută</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if ($parts->isEmpty())
|
||||||
|
<div class="card" style="text-align:center;padding:48px;">
|
||||||
|
<p class="muted">Nicio piesă găsită{{ $term ? ' pentru „' . $term . '”' : '' }}.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid">
|
||||||
|
@foreach ($parts as $p)
|
||||||
|
@php $stock = (float) $p->qty; @endphp
|
||||||
|
<div class="product">
|
||||||
|
<a href="/shop/part/{{ $p->id }}">
|
||||||
|
<h3>{{ $p->name }}</h3>
|
||||||
|
</a>
|
||||||
|
<div class="meta">
|
||||||
|
{{ $p->brand ? $p->brand . ' · ' : '' }}{{ $p->article ?? '' }}
|
||||||
|
</div>
|
||||||
|
<div class="stock {{ $stock > 0 ? 'in' : 'out' }}">
|
||||||
|
{{ $stock > 0 ? '● În stoc' : '○ La comandă' }}
|
||||||
|
</div>
|
||||||
|
<div class="price">{{ number_format((float) $p->sell_price, 2) }} {{ $currency }}</div>
|
||||||
|
<form method="POST" action="/shop/part/{{ $p->id }}/add" style="margin-top:10px;">
|
||||||
|
@csrf
|
||||||
|
<button class="btn block" type="submit">Adaugă în coș</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:20px;">{{ $parts->links() }}</div>
|
||||||
|
@endif
|
||||||
|
@endsection
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<h1 style="font-size:22px;margin-bottom:16px;">Finalizează comanda</h1>
|
||||||
|
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="card" style="border-color:#fca5a5;background:#fef2f2;margin-bottom:14px;">
|
||||||
|
<ul style="margin:0;padding-left:18px;color:#991b1b;font-size:14px;">
|
||||||
|
@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start;">
|
||||||
|
<form method="POST" action="/shop/checkout" class="card">
|
||||||
|
@csrf
|
||||||
|
<div class="field">
|
||||||
|
<label>Nume complet *</label>
|
||||||
|
<input type="text" name="customer_name" value="{{ old('customer_name') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telefon *</label>
|
||||||
|
<input type="text" name="customer_phone" value="{{ old('customer_phone') }}" required placeholder="+373…">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="customer_email" value="{{ old('customer_email') }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Livrare *</label>
|
||||||
|
<select name="delivery_method" required>
|
||||||
|
@foreach ($deliveryOptions as $opt)
|
||||||
|
<option value="{{ $opt }}">{{ $labels[$opt] ?? $opt }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Adresă (pentru curier/poștă)</label>
|
||||||
|
<input type="text" name="address" value="{{ old('address') }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Observații</label>
|
||||||
|
<textarea name="notes" rows="2">{{ old('notes') }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn block" type="submit">Plasează comanda</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:15px;margin-bottom:10px;">Sumar</h3>
|
||||||
|
<table class="cart">
|
||||||
|
@foreach ($cart as $item)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $item['name'] }} <span class="muted">×{{ $item['qty'] }}</span></td>
|
||||||
|
<td class="r">{{ number_format($item['price'] * $item['qty'], 2) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:12px;font-size:18px;font-weight:700;text-align:right;">
|
||||||
|
{{ number_format($subtotal, 2) }} {{ $currency }}
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:6px;">Taxa de livrare se calculează în funcție de metoda aleasă.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>@media (max-width:720px){ .wrap div[style*="grid-template-columns:1fr 320px"]{ grid-template-columns:1fr !important; } }</style>
|
||||||
|
@endsection
|
||||||
@@ -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
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ app()->getLocale() }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>@yield('title', 'Magazin') — {{ $brand }}</title>
|
||||||
|
@if ($faviconUrl)<link rel="icon" href="{{ $faviconUrl }}">@endif
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #1f2937; background: #f3f4f6; line-height: 1.5; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
header { background: {{ $themeColor }}; color: #fff; }
|
||||||
|
.hd { max-width: 1100px; margin: 0 auto; padding: 14px 16px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
.hd .logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; }
|
||||||
|
.hd .logo img { max-height: 34px; }
|
||||||
|
.hd nav { margin-left: auto; display: flex; gap: 16px; font-size: 14px; align-items: center; }
|
||||||
|
.hd .cart-badge { background: #fff; color: {{ $themeColor }}; border-radius: 999px; padding: 2px 9px; font-weight: 700; font-size: 12px; margin-left: 4px; }
|
||||||
|
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 20px 16px 64px; }
|
||||||
|
.btn { display: inline-block; background: {{ $themeColor }}; color: #fff; border: 0; border-radius: 8px; padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn.outline { background: transparent; border: 1px solid {{ $themeColor }}; color: {{ $themeColor }}; }
|
||||||
|
.btn.block { display: block; width: 100%; text-align: center; }
|
||||||
|
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||||
|
.product { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; display: flex; flex-direction: column; }
|
||||||
|
.product h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; min-height: 38px; }
|
||||||
|
.product .meta { font-size: 12px; color: #6b7280; margin-bottom: 8px; }
|
||||||
|
.product .price { font-size: 18px; font-weight: 700; color: {{ $themeColor }}; margin-top: auto; }
|
||||||
|
.product .stock { font-size: 12px; margin: 6px 0; }
|
||||||
|
.stock.in { color: #059669; } .stock.out { color: #dc2626; }
|
||||||
|
|
||||||
|
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.filters input, .filters select { padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; }
|
||||||
|
.filters input[type=text] { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
table.cart { width: 100%; border-collapse: collapse; }
|
||||||
|
table.cart th, table.cart td { padding: 10px; border-bottom: 1px solid #e5e7eb; font-size: 14px; text-align: left; }
|
||||||
|
table.cart td.r, table.cart th.r { text-align: right; }
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.field label { display: block; font-size: 13px; color: #4b5563; margin-bottom: 4px; }
|
||||||
|
.field input, .field select, .field textarea { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; }
|
||||||
|
|
||||||
|
.status-pill { display: inline-block; padding: 5px 12px; border-radius: 999px; font-size: 13px; font-weight: 600; background: {{ $themeColor }}; color: #fff; }
|
||||||
|
.muted { color: #6b7280; font-size: 13px; }
|
||||||
|
footer { text-align: center; padding: 24px; color: #9ca3af; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) { .hd .logo span { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="hd">
|
||||||
|
<a class="logo" href="/shop">
|
||||||
|
@if ($logoUrl)<img src="{{ $logoUrl }}" alt="">@endif
|
||||||
|
<span>{{ $brand }}</span>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/shop">Catalog</a>
|
||||||
|
<a href="/shop/vin">Caută după VIN</a>
|
||||||
|
<a href="/shop/cart">🛒 Coș
|
||||||
|
@if (($cartCount ?? 0) > 0)<span class="cart-badge">{{ $cartCount }}</span>@endif
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="wrap">
|
||||||
|
@yield('content')
|
||||||
|
</div>
|
||||||
|
<footer>{{ $brand }} · Powered by AutoCRM</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<div class="card" style="text-align:center;">
|
||||||
|
<div style="font-size:14px;color:#6b7280;">Comanda</div>
|
||||||
|
<div style="font-size:24px;font-weight:700;margin:4px 0;">#{{ $order->number }}</div>
|
||||||
|
<span class="status-pill">{{ $statuses[$order->status] ?? $order->status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($order->status !== 'cancelled')
|
||||||
|
<div class="card" style="margin-top:14px;">
|
||||||
|
<h3 style="font-size:15px;margin-bottom:12px;">Status</h3>
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:4px;">
|
||||||
|
@foreach ($flow as $i => $st)
|
||||||
|
<div style="flex:1;text-align:center;">
|
||||||
|
<div style="width:22px;height:22px;border-radius:50%;margin:0 auto 6px;
|
||||||
|
background:{{ $idx !== false && $i <= $idx ? ($tenant->settings['theme_color'] ?? '#3B82F6') : '#e5e7eb' }};"></div>
|
||||||
|
<div style="font-size:11px;color:{{ $idx !== false && $i <= $idx ? '#111827' : '#9ca3af' }};">
|
||||||
|
{{ $statuses[$st] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:14px;">
|
||||||
|
<h3 style="font-size:15px;margin-bottom:10px;">Produse</h3>
|
||||||
|
<table class="cart">
|
||||||
|
@foreach ($order->items as $it)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $it->name }} <span class="muted">×{{ rtrim(rtrim(number_format((float)$it->qty,2),'0'),'.') }}</span></td>
|
||||||
|
<td class="r">{{ number_format((float) $it->total, 2) }} {{ $currency }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
<tr><td class="muted">Livrare ({{ $delivery[$order->delivery_method] ?? $order->delivery_method }})</td>
|
||||||
|
<td class="r">{{ number_format((float) $order->delivery_fee, 2) }} {{ $currency }}</td></tr>
|
||||||
|
<tr><td style="font-weight:700;">Total</td>
|
||||||
|
<td class="r" style="font-weight:700;font-size:18px;">{{ number_format((float) $order->total, 2) }} {{ $currency }}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:14px;">
|
||||||
|
<h3 style="font-size:15px;margin-bottom:8px;">Date livrare</h3>
|
||||||
|
<p class="muted">{{ $order->customer_name }} · {{ $order->customer_phone }}</p>
|
||||||
|
@if ($order->address)<p class="muted">{{ $order->address }}</p>@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;text-align:center;">
|
||||||
|
<a class="btn outline" href="/shop">← Continuă cumpărăturile</a>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@extends('shop.layout')
|
||||||
|
@section('title', $part->name)
|
||||||
|
@section('content')
|
||||||
|
@php $currency = $tenant->settings['currency'] ?? 'MDL'; $stock = (float) $part->qty; @endphp
|
||||||
|
|
||||||
|
<a href="/shop" class="muted">← Înapoi la catalog</a>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:12px;">
|
||||||
|
<h1 style="font-size:22px;margin-bottom:8px;">{{ $part->name }}</h1>
|
||||||
|
<div class="muted" style="margin-bottom:14px;">
|
||||||
|
{{ $part->brand ? 'Brand: ' . $part->brand : '' }}
|
||||||
|
{{ $part->article ? ' · Cod: ' . $part->article : '' }}
|
||||||
|
{{ $part->category ? ' · ' . $part->category : '' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stock {{ $stock > 0 ? 'in' : 'out' }}" style="margin-bottom:8px;">
|
||||||
|
{{ $stock > 0 ? '● În stoc (' . rtrim(rtrim(number_format($stock, 2), '0'), '.') . ' ' . ($part->unit ?? 'buc') . ')' : '○ La comandă' }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:26px;font-weight:700;color:{{ $tenant->settings['theme_color'] ?? '#3B82F6' }};margin-bottom:16px;">
|
||||||
|
{{ number_format((float) $part->sell_price, 2) }} {{ $currency }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/shop/part/{{ $part->id }}/add" style="display:flex;gap:8px;align-items:center;max-width:320px;">
|
||||||
|
@csrf
|
||||||
|
<input type="number" name="qty" value="1" min="1" style="width:80px;padding:10px;border:1px solid #d1d5db;border-radius:8px;">
|
||||||
|
<button class="btn" type="submit">Adaugă în coș</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if ($part->crossRefs->isNotEmpty())
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:6px;">Coduri echivalente (cross)</h3>
|
||||||
|
<div class="muted">
|
||||||
|
@foreach ($part->crossRefs as $cr)
|
||||||
|
<span style="display:inline-block;background:#f3f4f6;border-radius:6px;padding:3px 8px;margin:2px;">
|
||||||
|
{{ $cr->cross_article }}{{ $cr->brand ? ' (' . $cr->brand . ')' : '' }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($part->notes)
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:6px;">Descriere</h3>
|
||||||
|
<p class="muted" style="white-space:pre-wrap;">{{ $part->notes }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@extends('shop.layout')
|
||||||
|
@section('title', 'Căutare după VIN')
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1 style="font-size:20px;margin-bottom:6px;">Caută piese după VIN</h1>
|
||||||
|
<p class="muted" style="margin-bottom:16px;">Introdu codul VIN (17 caractere) ca să identificăm mașina. Apoi caută piesele în catalog.</p>
|
||||||
|
|
||||||
|
<form method="GET" action="/shop/vin" style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<input type="text" name="vin" value="{{ $vin }}" maxlength="17" placeholder="ex: WVWZZZ1JZXW000001"
|
||||||
|
style="flex:1;min-width:240px;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-family:monospace;text-transform:uppercase;">
|
||||||
|
<button class="btn" type="submit">Decodează</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($decoded)
|
||||||
|
<div class="card" style="margin-top:14px;">
|
||||||
|
@if (! ($decoded['valid_length'] ?? false))
|
||||||
|
<p class="stock out">{{ $decoded['reason'] ?? 'VIN invalid — trebuie 17 caractere.' }}</p>
|
||||||
|
@else
|
||||||
|
<h3 style="font-size:16px;margin-bottom:10px;">Mașină identificată</h3>
|
||||||
|
<table class="cart">
|
||||||
|
<tr><td>Producător</td><td class="r"><strong>{{ $decoded['manufacturer'] ?? '—' }}</strong></td></tr>
|
||||||
|
<tr><td>An model</td><td class="r"><strong>{{ $decoded['year'] ?? '—' }}</strong></td></tr>
|
||||||
|
<tr><td>Țară</td><td class="r">{{ $decoded['country'] ?? '—' }}</td></tr>
|
||||||
|
<tr><td>Regiune</td><td class="r">{{ $decoded['region'] ?? '—' }}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:14px;">
|
||||||
|
<a class="btn outline" href="/shop?q={{ urlencode($decoded['manufacturer'] ?? '') }}">
|
||||||
|
Caută piese pentru {{ $decoded['manufacturer'] ?? 'această mașină' }} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:12px;">
|
||||||
|
Pentru compatibilitate exactă pe model/motorizare, contactează service-ul cu acest VIN.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endsection
|
||||||
@@ -58,6 +58,11 @@
|
|||||||
@endif
|
@endif
|
||||||
<h1>{{ $name }}</h1>
|
<h1>{{ $name }}</h1>
|
||||||
<p>Autoservice profesional{{ $city ? ' — ' . $city : '' }}. Diagnostic, reparații, piese, ITP.</p>
|
<p>Autoservice profesional{{ $city ? ' — ' . $city : '' }}. Diagnostic, reparații, piese, ITP.</p>
|
||||||
|
@if (! empty($shopEnabled))
|
||||||
|
<a href="/shop" style="display:inline-block;margin-top:20px;background:#fff;color:{{ $themeColor }};padding:12px 28px;border-radius:8px;font-weight:700;text-decoration:none;">
|
||||||
|
🛒 Magazin piese online
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (! empty($services))
|
@if (! empty($services))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Route::get('/', function () {
|
|||||||
'cars' => (array) ($tenant->settings['cars'] ?? []),
|
'cars' => (array) ($tenant->settings['cars'] ?? []),
|
||||||
'logoUrl' => $tenant->getLogoUrl(),
|
'logoUrl' => $tenant->getLogoUrl(),
|
||||||
'faviconUrl' => $tenant->getFaviconUrl(),
|
'faviconUrl' => $tenant->getFaviconUrl(),
|
||||||
|
'shopEnabled' => (bool) data_get($tenant->settings, 'shop.enabled', false),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
// On the central domain → redirect to admin.
|
// On the central domain → redirect to admin.
|
||||||
@@ -77,6 +78,21 @@ Route::post('/telegram/webhook/{slug}', [\App\Http\Controllers\TelegramWebhookCo
|
|||||||
])
|
])
|
||||||
->name('telegram.webhook');
|
->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) ──────
|
// ─── Public WO tracking (no auth, tenant-scoped via subdomain) ──────
|
||||||
Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show'])
|
Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show'])
|
||||||
->where('token', '[A-Za-z0-9]{16,32}')
|
->where('token', '[A-Za-z0-9]{16,32}')
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\OnlineOrder;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\PartCrossRef;
|
||||||
|
use App\Models\Tenant\Warehouse;
|
||||||
|
use App\Models\Tenant\WarehouseEvent;
|
||||||
|
use App\Services\Warehouse\WarehouseService;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class OnlineStoreTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_catalog_404_when_shop_disabled(): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user