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:
2026-05-28 05:27:51 +00:00
parent c413004930
commit 954ba8f059
24 changed files with 1390 additions and 1 deletions
+23
View File
@@ -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 <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')
->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,
@@ -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;
}
@@ -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('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,
];
}
@@ -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.');
}
}
+242
View File
@@ -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(),
]);
}
}
+84
View File
@@ -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);
}
});
}
}
+43
View File
@@ -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());
}
}
+31 -1
View File
@@ -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
{
+19
View File
@@ -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);
}
}
}
}