Files
autocrm/app/Models/Tenant/Part.php
T
Vasyka 954ba8f059 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>
2026-05-28 05:27:51 +00:00

127 lines
3.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
class Part extends Model
{
use BelongsToTenant, SoftDeletes;
public const CATEGORIES = [
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
'Distribuție', 'Anvelope', 'Electrică', 'Caroserie', 'Altele',
];
protected $fillable = [
'company_id', 'name', 'article', 'brand', 'category',
'qty', 'qty_reserved', 'unit', 'min_qty',
'buy_price', 'sell_price',
'location', 'barcode', 'preferred_supplier_id',
'is_active', 'is_published', 'notes',
];
protected $casts = [
'qty' => 'decimal:2',
'qty_reserved' => 'decimal:3',
'min_qty' => 'decimal:2',
'buy_price' => 'decimal:2',
'sell_price' => 'decimal:2',
'is_active' => 'boolean',
'is_published' => 'boolean',
];
public function preferredSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
}
public function batches(): HasMany
{
return $this->hasMany(PartBatch::class);
}
public function reservations(): HasMany
{
return $this->hasMany(PartReservation::class);
}
public function events(): HasMany
{
return $this->hasMany(WarehouseEvent::class);
}
public function priceHistory(): HasMany
{
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
{
$q = $this->batches()->newQuery()->where('part_id', $this->id);
if ($warehouseId) $q->where('warehouse_id', $warehouseId);
return (float) $q->sum('qty_remaining');
}
/** Available for new reservations = on hand already reserved. */
public function qtyAvailable(?int $warehouseId = null): float
{
return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved);
}
public function isLow(): bool
{
return (float) $this->qty <= (float) $this->min_qty;
}
public function isOut(): bool
{
return (float) $this->qty <= 0;
}
/**
* Legacy direct-stock adjustment.
* NOTE: this only moves the cached `qty` column. Real stock changes
* should go through WarehouseService so batches + events stay in sync.
*/
public function adjustStock(float $delta, ?string $reason = null): void
{
$this->qty = max(0, (float) $this->qty + $delta);
$this->save();
}
}