tsett
This commit is contained in:
62
app/Models/Brand.php
Normal file
62
app/Models/Brand.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Brand extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'logo',
|
||||
'website',
|
||||
'country',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
public function activeProducts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
public function getProductsCountAttribute(): int
|
||||
{
|
||||
return $this->activeProducts()->count();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopePopular($query)
|
||||
{
|
||||
return $query->withCount('products')->orderBy('products_count', 'desc');
|
||||
}
|
||||
|
||||
public function getLogoUrlAttribute(): string
|
||||
{
|
||||
if ($this->logo) {
|
||||
$logoPath = 'assets/images/brands/' . $this->logo;
|
||||
if (file_exists(public_path($logoPath))) {
|
||||
return asset($logoPath);
|
||||
}
|
||||
}
|
||||
|
||||
return asset('assets/images/brands/default.svg');
|
||||
}
|
||||
}
|
||||
97
app/Models/CartItem.php
Normal file
97
app/Models/CartItem.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CartItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'session_id',
|
||||
'variation_id',
|
||||
'quantity',
|
||||
'price'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'price' => 'decimal:2'
|
||||
];
|
||||
|
||||
public function variation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariation::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getTotalAttribute(): float
|
||||
{
|
||||
return $this->quantity * $this->price;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить корзину для текущего пользователя/сессии
|
||||
*/
|
||||
public static function getCartQuery()
|
||||
{
|
||||
return self::with('variation.product')
|
||||
->where(function ($query) {
|
||||
if (Auth::check()) {
|
||||
$query->where('user_id', Auth::id());
|
||||
} else {
|
||||
$query->where('session_id', session()->getId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество товаров в корзине
|
||||
*/
|
||||
public static function getCount()
|
||||
{
|
||||
return self::getCartQuery()->sum('quantity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить общую сумму корзины
|
||||
*/
|
||||
public static function getTotal()
|
||||
{
|
||||
return self::getCartQuery()->get()->sum('total');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить товар в корзине по вариации
|
||||
*/
|
||||
public static function findByVariation($variationId)
|
||||
{
|
||||
return self::getCartQuery()
|
||||
->where('variation_id', $variationId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить товар в корзине по ID
|
||||
*/
|
||||
public static function findCartItem($id)
|
||||
{
|
||||
return self::getCartQuery()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить корзину
|
||||
*/
|
||||
public static function clear()
|
||||
{
|
||||
return self::getCartQuery()->delete();
|
||||
}
|
||||
}
|
||||
243
app/Models/Category.php
Normal file
243
app/Models/Category.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'image',
|
||||
'parent_id',
|
||||
'sort_order',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'parent_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function descendants()
|
||||
{
|
||||
return $this->children()->with('descendants');
|
||||
}
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
public function getFullSlugAttribute(): string
|
||||
{
|
||||
$slugs = [];
|
||||
$current = $this;
|
||||
|
||||
while ($current) {
|
||||
$slugs[] = $current->slug;
|
||||
$current = $current->parent;
|
||||
}
|
||||
|
||||
return implode('/', array_reverse($slugs));
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return route('category.show', $this->full_slug);
|
||||
}
|
||||
|
||||
public function getBreadcrumbsAttribute()
|
||||
{
|
||||
$breadcrumbs = [];
|
||||
$current = $this;
|
||||
|
||||
while ($current) {
|
||||
array_unshift($breadcrumbs, [
|
||||
'name' => $current->name,
|
||||
'slug' => $current->slug,
|
||||
'url' => $current->url
|
||||
]);
|
||||
$current = $current->parent;
|
||||
}
|
||||
|
||||
return $breadcrumbs;
|
||||
}
|
||||
|
||||
public function getPathAttribute()
|
||||
{
|
||||
$path = [];
|
||||
$current = $this;
|
||||
|
||||
while ($current) {
|
||||
array_unshift($path, $current->slug);
|
||||
$current = $current->parent;
|
||||
}
|
||||
|
||||
return implode('/', $path);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeRoot($query)
|
||||
{
|
||||
return $query->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public static function getTreeMenu()
|
||||
{
|
||||
return self::with('descendants')->root()->active()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
public static function findByPath($path)
|
||||
{
|
||||
$slugs = explode('/', trim($path, '/'));
|
||||
$category = null;
|
||||
|
||||
foreach ($slugs as $slug) {
|
||||
if (!$category) {
|
||||
$category = self::where('slug', $slug)
|
||||
->whereNull('parent_id')
|
||||
->active()
|
||||
->first();
|
||||
} else {
|
||||
$category = $category->children()
|
||||
->where('slug', $slug)
|
||||
->active()
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$category) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
public static function getSelectTree($maxLevel = 1)
|
||||
{
|
||||
$categories = self::with('parent')->get();
|
||||
|
||||
return $categories->map(function ($cat) {
|
||||
$cat->level = $cat->calculateLevel();
|
||||
return $cat;
|
||||
})->filter(function ($cat) use ($maxLevel) {
|
||||
return $cat->level < $maxLevel;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getFlatTree()
|
||||
{
|
||||
$categories = self::with('parent')
|
||||
->orderBy('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return self::buildFlatTree($categories);
|
||||
}
|
||||
|
||||
private static function buildFlatTree($categories, $parentId = null, $level = 0)
|
||||
{
|
||||
$result = collect();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
if ($category->parent_id == $parentId) {
|
||||
$category->level = $level;
|
||||
$result->push($category);
|
||||
|
||||
$children = self::buildFlatTree($categories, $category->id, $level + 1);
|
||||
$result = $result->concat($children);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getLevelAttribute()
|
||||
{
|
||||
return $this->calculateLevel();
|
||||
}
|
||||
|
||||
public function calculateLevel($level = 0)
|
||||
{
|
||||
if ($this->parent) {
|
||||
return $this->parent->calculateLevel($level + 1);
|
||||
}
|
||||
return $level;
|
||||
}
|
||||
|
||||
public function isDescendantOf($ancestorId): bool
|
||||
{
|
||||
$current = $this;
|
||||
while ($current) {
|
||||
if ($current->id == $ancestorId) {
|
||||
return true;
|
||||
}
|
||||
$current = $current->parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getIconUrlAttribute(): string
|
||||
{
|
||||
if ($this->icon !== NULL) {
|
||||
$iconPath = 'assets/images/categories/icons/' . $this->icon;
|
||||
if (file_exists(public_path($iconPath))) {
|
||||
return asset($iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return asset('assets/images/categories/icons/default.svg');
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): string
|
||||
{
|
||||
if ($this->image !== NULL) {
|
||||
$imagePath = 'assets/images/categories/images/' . $this->image;
|
||||
if (file_exists(public_path($imagePath))) {
|
||||
return asset($imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
return asset('assets/images/categories/images/default.svg');
|
||||
}
|
||||
|
||||
public function getAllCategoryIds(): array
|
||||
{
|
||||
$ids = [$this->id];
|
||||
|
||||
foreach ($this->children as $child) {
|
||||
$ids = array_merge($ids, $child->getAllCategoryIds());
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
// В модели Category
|
||||
public function productsWithChildren()
|
||||
{
|
||||
$categoryIds = $this->getAllCategoryIds();
|
||||
|
||||
return Product::whereIn('category_id', $categoryIds);
|
||||
}
|
||||
}
|
||||
31
app/Models/Contact.php
Normal file
31
app/Models/Contact.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'logo',
|
||||
'favicon',
|
||||
'phone',
|
||||
'email',
|
||||
'address',
|
||||
'work_hours',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'vkontakte',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'meta_keywords',
|
||||
];
|
||||
|
||||
public static function getData()
|
||||
{
|
||||
return self::first() ?? self::create();
|
||||
}
|
||||
}
|
||||
203
app/Models/Order.php
Normal file
203
app/Models/Order.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'order_number',
|
||||
'user_id',
|
||||
'customer_name',
|
||||
'customer_email',
|
||||
'customer_phone',
|
||||
'shipping_address',
|
||||
'subtotal',
|
||||
'shipping_cost',
|
||||
'discount',
|
||||
'total',
|
||||
'payment_method',
|
||||
'payment_status',
|
||||
'delivery_method',
|
||||
'delivery_status',
|
||||
'comment'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal' => 'decimal:2',
|
||||
'shipping_cost' => 'decimal:2',
|
||||
'discount' => 'decimal:2',
|
||||
'total' => 'decimal:2'
|
||||
];
|
||||
|
||||
const DELIVERY_STATUSES = [
|
||||
'pending' => 'Ожидает обработки',
|
||||
'processing' => 'В обработке',
|
||||
'shipped' => 'Отправлен',
|
||||
'delivered' => 'Доставлен',
|
||||
'cancelled' => 'Отменён'
|
||||
];
|
||||
|
||||
const PAYMENT_STATUSES = [
|
||||
'pending' => 'Ожидает оплаты',
|
||||
'paid' => 'Оплачен',
|
||||
'failed' => 'Ошибка оплаты'
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public static function generateOrderNumber(): string
|
||||
{
|
||||
return 'ORD-' . date('Ymd') . '-' . strtoupper(uniqid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать заказ из корзины
|
||||
*/
|
||||
public static function createFromCart($data, $cartItems)
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$subtotal = $cartItems->sum('total');
|
||||
$shippingCost = self::calculateShipping($data['delivery_method']);
|
||||
$total = $subtotal + $shippingCost;
|
||||
|
||||
$shippingAddress = null;
|
||||
if (in_array($data['delivery_method'], ['courier', 'express'])) {
|
||||
$shippingAddress = $data['shipping_address'];
|
||||
}
|
||||
|
||||
$order = self::create([
|
||||
'order_number' => self::generateOrderNumber(),
|
||||
'user_id' => auth()->id(),
|
||||
'customer_name' => $data['customer_name'],
|
||||
'customer_email' => $data['customer_email'],
|
||||
'customer_phone' => $data['customer_phone'],
|
||||
'shipping_address' => $shippingAddress,
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'payment_method' => $data['payment_method'],
|
||||
'delivery_method' => $data['delivery_method'],
|
||||
'comment' => $data['comment'] ?? null
|
||||
]);
|
||||
|
||||
foreach ($cartItems as $item) {
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'variation_id' => $item->variation_id,
|
||||
'product_name' => $item->variation->product->name,
|
||||
'variation_name' => $item->variation->name,
|
||||
'sku' => $item->variation->sku,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
'total' => $item->total
|
||||
]);
|
||||
|
||||
$item->variation->decrement('stock', $item->quantity);
|
||||
}
|
||||
|
||||
CartItem::clear();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return ['success' => true, 'order' => $order];
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитать стоимость доставки
|
||||
*/
|
||||
private static function calculateShipping($method)
|
||||
{
|
||||
return match ($method) {
|
||||
'express' => 300,
|
||||
'courier' => 0,
|
||||
'pickup' => 0,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
public function getDeliveryStatusNameAttribute(): string
|
||||
{
|
||||
return self::DELIVERY_STATUSES[$this->delivery_status] ?? $this->delivery_status;
|
||||
}
|
||||
|
||||
public function getPaymentStatusNameAttribute(): string
|
||||
{
|
||||
return self::PAYMENT_STATUSES[$this->payment_status] ?? $this->payment_status;
|
||||
}
|
||||
|
||||
public function getDeliveryStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->delivery_status) {
|
||||
'pending' => 'warning',
|
||||
'processing' => 'info',
|
||||
'shipped' => 'primary',
|
||||
'delivered' => 'success',
|
||||
'cancelled' => 'danger',
|
||||
default => 'secondary'
|
||||
};
|
||||
}
|
||||
|
||||
public function getDeliveryStatusIconAttribute(): string
|
||||
{
|
||||
return match ($this->delivery_status) {
|
||||
'pending' => 'bi-clock',
|
||||
'processing' => 'bi-arrow-repeat',
|
||||
'shipped' => 'bi-truck',
|
||||
'delivered' => 'bi-check-circle',
|
||||
'cancelled' => 'bi-x-circle',
|
||||
default => 'bi-question-circle'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все возможные статусы доставки для выпадающего списка
|
||||
*/
|
||||
public static function getDeliveryStatuses(): array
|
||||
{
|
||||
return self::DELIVERY_STATUSES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все возможные статусы оплаты для выпадающего списка
|
||||
*/
|
||||
public static function getPaymentStatuses(): array
|
||||
{
|
||||
return self::PAYMENT_STATUSES;
|
||||
}
|
||||
|
||||
public static function getRevenue($startDate, $endDate, $paidOnly = true)
|
||||
{
|
||||
$query = self::query();
|
||||
|
||||
if ($paidOnly) {
|
||||
$query->where('payment_status', 'paid');
|
||||
}
|
||||
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate])
|
||||
->sum('total');
|
||||
}
|
||||
|
||||
public static function getMonthlyRevenue()
|
||||
{
|
||||
return self::getRevenue(now()->startOfMonth(), now()->endOfMonth());
|
||||
}
|
||||
}
|
||||
65
app/Models/OrderItem.php
Normal file
65
app/Models/OrderItem.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'variation_id',
|
||||
'product_name',
|
||||
'variation_name',
|
||||
'sku',
|
||||
'quantity',
|
||||
'price',
|
||||
'total'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'price' => 'decimal:2',
|
||||
'total' => 'decimal:2'
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function variation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariation::class);
|
||||
}
|
||||
|
||||
public static function createFromCartItem($orderId, $cartItem)
|
||||
{
|
||||
return self::create([
|
||||
'order_id' => $orderId,
|
||||
'variation_id' => $cartItem->variation_id,
|
||||
'product_name' => $cartItem->variation->product->name,
|
||||
'variation_name' => $cartItem->variation->name,
|
||||
'sku' => $cartItem->variation->sku,
|
||||
'quantity' => $cartItem->quantity,
|
||||
'price' => $cartItem->price,
|
||||
'total' => $cartItem->total
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopePopular($query, $limit = 5)
|
||||
{
|
||||
return $query->select(
|
||||
'product_name',
|
||||
'variation_name',
|
||||
'variation_id',
|
||||
DB::raw('SUM(quantity) as total_quantity'),
|
||||
DB::raw('SUM(total) as total_revenue')
|
||||
)
|
||||
->groupBy('product_name', 'variation_name', 'variation_id')
|
||||
->orderBy('total_quantity', 'desc')
|
||||
->limit($limit);
|
||||
}
|
||||
}
|
||||
28
app/Models/Permission.php
Normal file
28
app/Models/Permission.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'group',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function scopeInGroup($query, string $group)
|
||||
{
|
||||
$query->where('group', $group);
|
||||
}
|
||||
}
|
||||
183
app/Models/Product.php
Normal file
183
app/Models/Product.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'brand_id',
|
||||
'category_id',
|
||||
'is_active',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'meta_keywords',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function variations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariation::class);
|
||||
}
|
||||
|
||||
public function attributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttribute::class);
|
||||
}
|
||||
|
||||
public function getDefaultVariationAttribute()
|
||||
{
|
||||
$defaultInStock = $this->variations->firstWhere(function ($variation) {
|
||||
return $variation->is_default && $variation->stock > 0;
|
||||
});
|
||||
|
||||
if ($defaultInStock) {
|
||||
return $defaultInStock;
|
||||
}
|
||||
|
||||
$anyInStock = $this->variations->firstWhere('stock', '>', 0);
|
||||
|
||||
if ($anyInStock) {
|
||||
return $anyInStock;
|
||||
}
|
||||
|
||||
return $this->variations->firstWhere('is_default', true) ?? $this->variations->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить URL главного изображения товара (из дефолтной вариации)
|
||||
*/
|
||||
public function getMainImageAttribute(): string
|
||||
{
|
||||
$defaultVariation = $this->default_variation;
|
||||
|
||||
if ($defaultVariation) {
|
||||
return $defaultVariation->main_image_url;
|
||||
}
|
||||
|
||||
return asset('assets/images/products/default.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить изображения дефолтной вариации
|
||||
*/
|
||||
public function getDefaultVariationImages()
|
||||
{
|
||||
$defaultVariation = $this->default_variation;
|
||||
|
||||
if ($defaultVariation) {
|
||||
return $defaultVariation->images;
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
public static function getForCarousel($categoryId = null, $limit = 9, $itemsPerSlide = 3)
|
||||
{
|
||||
$query = self::with(['category', 'brand', 'variations'])
|
||||
->active();
|
||||
|
||||
if ($categoryId) {
|
||||
$query->byCategory($categoryId);
|
||||
}
|
||||
|
||||
$products = $query->limit($limit)->get();
|
||||
|
||||
$products = $products->filter(function ($product) {
|
||||
return $product->variations->isNotEmpty();
|
||||
});
|
||||
|
||||
$products = $products->sortByDesc(function ($product) {
|
||||
$defaultVariation = $product->default_variation;
|
||||
if ($defaultVariation && $defaultVariation->stock > 0) {
|
||||
return 3;
|
||||
} elseif ($defaultVariation) {
|
||||
return 2;
|
||||
} elseif ($product->variations->firstWhere('stock', '>', 0)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return $products->chunk($itemsPerSlide);
|
||||
}
|
||||
|
||||
public function getRatingAttribute(): array
|
||||
{
|
||||
return [
|
||||
'score' => 4.9,
|
||||
'count' => 1000
|
||||
];
|
||||
}
|
||||
|
||||
public function getMinPriceAttribute(): float
|
||||
{
|
||||
return $this->variations->min('price') ?? 0;
|
||||
}
|
||||
|
||||
public function getMaxPriceAttribute(): float
|
||||
{
|
||||
return $this->variations->max('price') ?? 0;
|
||||
}
|
||||
|
||||
public function getPriceRangeAttribute(): string
|
||||
{
|
||||
$min = $this->min_price;
|
||||
$max = $this->max_price;
|
||||
|
||||
if ($min == $max) {
|
||||
return number_format($min, 0, '.', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
return 'от ' . number_format($min, 0, '.', ' ') . ' ₽ до ' . number_format($max, 0, '.', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
public function getTotalStockAttribute(): int
|
||||
{
|
||||
return $this->variations->sum('stock');
|
||||
}
|
||||
|
||||
public function getHasStockAttribute(): bool
|
||||
{
|
||||
return $this->total_stock > 0;
|
||||
}
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return $this->brand ? "[{$this->brand->name}] {$this->name}" : $this->name;
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByBrand($query, $brandId)
|
||||
{
|
||||
return $query->where('brand_id', $brandId);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, $categoryId)
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
}
|
||||
16
app/Models/ProductAttribute.php
Normal file
16
app/Models/ProductAttribute.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductAttribute extends Model
|
||||
{
|
||||
protected $fillable = ['product_id', 'key', 'value'];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
160
app/Models/ProductVariation.php
Normal file
160
app/Models/ProductVariation.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductVariation extends Model
|
||||
{
|
||||
protected $table = 'product_variations';
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'sku',
|
||||
'name',
|
||||
'price',
|
||||
'old_price',
|
||||
'stock',
|
||||
'is_default',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'price' => 'float',
|
||||
'old_price' => 'float',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function attributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariationAttribute::class, 'variation_id');
|
||||
}
|
||||
|
||||
public function images(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariationImage::class, 'variation_id')
|
||||
->ordered();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить URL главного изображения вариации
|
||||
*/
|
||||
public function getMainImageUrlAttribute(): string
|
||||
{
|
||||
$image = $this->images()->first();
|
||||
|
||||
if ($image) {
|
||||
return $image->url;
|
||||
}
|
||||
|
||||
return asset('assets/images/products/default.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все изображения вариации
|
||||
*/
|
||||
public function getAllImages()
|
||||
{
|
||||
return $this->images;
|
||||
}
|
||||
|
||||
public function getHasDiscountAttribute(): bool
|
||||
{
|
||||
return $this->old_price && $this->old_price > $this->price;
|
||||
}
|
||||
|
||||
public function getDiscountPercentAttribute(): int
|
||||
{
|
||||
if (!$this->has_discount) return 0;
|
||||
return round(($this->old_price - $this->price) / $this->old_price * 100);
|
||||
}
|
||||
|
||||
public function getInStockAttribute(): bool
|
||||
{
|
||||
return $this->stock > 0;
|
||||
}
|
||||
|
||||
public function getFormattedPriceAttribute(): string
|
||||
{
|
||||
return number_format($this->price, 0, '.', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
public function getFormattedOldPriceAttribute(): string
|
||||
{
|
||||
if (!$this->old_price) return '';
|
||||
return number_format($this->old_price, 0, '.', ' ') . ' ₽';
|
||||
}
|
||||
|
||||
public static function isSkuUnique($sku, $excludeId = null, $productId = null)
|
||||
{
|
||||
$query = self::where('sku', $sku);
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
if ($productId) {
|
||||
$query->where('product_id', $productId);
|
||||
}
|
||||
|
||||
return !$query->exists();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeInStock($query)
|
||||
{
|
||||
return $query->where('stock', '>', 0);
|
||||
}
|
||||
|
||||
public function scopeDiscounted($query)
|
||||
{
|
||||
return $query->whereNotNull('old_price')
|
||||
->whereColumn('old_price', '>', 'price');
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
// ========== МЕТОДЫ ДЛЯ AJAX ==========
|
||||
|
||||
public static function getImagesByVariationId($variationId)
|
||||
{
|
||||
$variation = self::with(['images' => function ($query) {
|
||||
$query->active()->ordered();
|
||||
}])->find($variationId);
|
||||
|
||||
if (!$variation) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $variation->images;
|
||||
}
|
||||
|
||||
public static function getImagesDataById($variationId)
|
||||
{
|
||||
$images = self::getImagesByVariationId($variationId);
|
||||
|
||||
return $images->map(function ($image) {
|
||||
return [
|
||||
'id' => $image->id,
|
||||
'url' => $image->url,
|
||||
'sort_order' => $image->sort_order,
|
||||
'variation_id' => $image->variation_id
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
74
app/Models/Review.php
Normal file
74
app/Models/Review.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Review extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'product_id',
|
||||
'rating',
|
||||
'title',
|
||||
'comment',
|
||||
'advantages',
|
||||
'disadvantages',
|
||||
'helpful_count',
|
||||
'unhelpful_count',
|
||||
'is_approved'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function getDateAttribute(): string
|
||||
{
|
||||
return $this->created_at->format('d.m.Y');
|
||||
}
|
||||
|
||||
public function getIsFreshAttribute(): bool
|
||||
{
|
||||
return $this->created_at->gt(now()->subWeek());
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('is_approved', true);
|
||||
}
|
||||
|
||||
public function scopeByRating($query, $rating)
|
||||
{
|
||||
return $query->where('rating', $rating);
|
||||
}
|
||||
|
||||
public function scopeHelpful($query)
|
||||
{
|
||||
return $query->orderBy('helpful_count', 'desc');
|
||||
}
|
||||
|
||||
public function scopeLatest($query)
|
||||
{
|
||||
return $query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function scopeUserProduct($query, $userId, $productId)
|
||||
{
|
||||
return $query->where('user_id', $userId)
|
||||
->where('product_id', $productId);
|
||||
}
|
||||
}
|
||||
32
app/Models/Role.php
Normal file
32
app/Models/Role.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function permissions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Permission::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function hasPermission(string $permissionSlug): bool
|
||||
{
|
||||
return $this->permissions()->where('slug', $permissionSlug)->exists();
|
||||
}
|
||||
}
|
||||
110
app/Models/User.php
Normal file
110
app/Models/User.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'role_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
public function permissions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Permission::class, 'role_user', 'user_id', 'role_id')
|
||||
->join('permission_role', 'role_user.role_id', '=', 'permission_role.role_id')
|
||||
->join('permissions', 'permission_role.permission_id', '=', 'permissions.id')
|
||||
->select('permissions.*')
|
||||
->distinct()
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function hasRole(string $roleSlug): bool
|
||||
{
|
||||
return $this->role()->where('slug', $roleSlug)->exists();
|
||||
}
|
||||
|
||||
public function hasPermission(string $permissionSlug): bool
|
||||
{
|
||||
if ($this->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$role = $this->role;
|
||||
|
||||
return $role && $role->hasPermission($permissionSlug);
|
||||
}
|
||||
|
||||
public function hasAnyPermission(array $permissionSlugs): bool
|
||||
{
|
||||
if ($this->is_super_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($permissionSlugs as $permissionSlug) {
|
||||
if ($this->hasPermission($permissionSlug)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasAllPermissions(array $permissionSlugs): bool
|
||||
{
|
||||
if ($this->is_super_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($permissionSlugs as $permissionSlug) {
|
||||
if (!$this->hasPermission($permissionSlug)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
16
app/Models/VariationAttribute.php
Normal file
16
app/Models/VariationAttribute.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VariationAttribute extends Model
|
||||
{
|
||||
protected $fillable = ['variation_id', 'key', 'value'];
|
||||
|
||||
public function variation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariation::class, 'variation_id');
|
||||
}
|
||||
}
|
||||
47
app/Models/VariationImage.php
Normal file
47
app/Models/VariationImage.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VariationImage extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'variation_id',
|
||||
'path',
|
||||
'sort_order',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean'
|
||||
];
|
||||
|
||||
public function variation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariation::class, 'variation_id');
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
if ($this->path && file_exists(public_path($this->path))) {
|
||||
return asset($this->path);
|
||||
}
|
||||
|
||||
return asset('assets/images/products/default.svg');
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order', 'asc');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user