refactor(catalog): Complete catalog refactor to simplify its data model and its querying.
source commits: refactor/catalog
This commit is contained in:
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
@@ -52,27 +51,4 @@ class Attribute extends Model
|
||||
{
|
||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttribute, $this>
|
||||
*/
|
||||
public function productAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttribute::class, 'attribute_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
|
||||
*/
|
||||
public function variantDefinitions(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
ProductVariantDefinition::class,
|
||||
ProductAttribute::class,
|
||||
'attribute_id',
|
||||
'products_attribute_id',
|
||||
'id',
|
||||
'id'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
@@ -26,4 +27,10 @@ class Brand extends Model
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/** @return HasMany<CatalogItem, $this> */
|
||||
public function catalogItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
|
||||
49
app/Domains/Catalog/Models/BundleComponent.php
Normal file
49
app/Domains/Catalog/Models/BundleComponent.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'bundle_catalog_item_id',
|
||||
'component_catalog_item_id',
|
||||
'component_variant_id',
|
||||
'quantity',
|
||||
])]
|
||||
class BundleComponent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bundle_catalog_item_id' => 'integer',
|
||||
'component_catalog_item_id' => 'integer',
|
||||
'component_variant_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function bundle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Variant::class, 'component_variant_id');
|
||||
}
|
||||
}
|
||||
170
app/Domains/Catalog/Models/CatalogItem.php
Normal file
170
app/Domains/Catalog/Models/CatalogItem.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'category_id',
|
||||
'brand_id',
|
||||
'inventory_id',
|
||||
'type',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'inventory_policy',
|
||||
'has_tickets',
|
||||
'maximum_use_date',
|
||||
'minimum_use_date',
|
||||
])]
|
||||
class CatalogItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'catalog_items';
|
||||
|
||||
protected $attributes = [
|
||||
'type' => CatalogItemType::Standard->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'type' => CatalogItemType::class,
|
||||
'precio' => 'decimal:2',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'has_tickets' => 'boolean',
|
||||
'maximum_use_date' => 'datetime',
|
||||
'minimum_use_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Category, $this> */
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Brand, $this> */
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<BundleComponent, $this> */
|
||||
public function bundleComponents(): HasMany
|
||||
{
|
||||
return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<BundleComponent, $this> */
|
||||
public function bundleComponentUsages(): HasMany
|
||||
{
|
||||
return $this->hasMany(BundleComponent::class, 'component_catalog_item_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Variant, $this> */
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Variant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attribute, $this> */
|
||||
public function attributes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Attribute::class, 'item_attributes')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/** @return HasMany<ItemAttribute, $this> */
|
||||
public function itemAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ItemAttribute::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<FeaturedItem, $this> */
|
||||
public function featuredItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeaturedItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'catalog_items_attachments',
|
||||
'catalog_item_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->wherePivotNull('variant_id')
|
||||
->orderByPivot('orden');
|
||||
}
|
||||
|
||||
public function availableStock(): ?int
|
||||
{
|
||||
return app(CatalogInventoryService::class)->availableQuantity($this);
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if ($this->type === CatalogItemType::Bundle) {
|
||||
$availableStock = $this->availableStock();
|
||||
|
||||
return $availableStock === null || $availableStock > 0;
|
||||
}
|
||||
|
||||
if ($this->inventory_policy === InventoryPolicy::Unlimited) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ($this->availableStock() ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) $this->precio;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->nombre;
|
||||
}
|
||||
|
||||
public function isBundle(): bool
|
||||
{
|
||||
return $this->type === CatalogItemType::Bundle;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ class Category extends Model
|
||||
|
||||
protected $table = 'categorias';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -55,4 +58,10 @@ class Category extends Model
|
||||
{
|
||||
return $this->hasMany(self::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<CatalogItem, $this> */
|
||||
public function catalogItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,45 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'product_layout',
|
||||
'group_name',
|
||||
'group_order',
|
||||
])]
|
||||
class FeaturedGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_codigo',
|
||||
'group_name',
|
||||
'product_layout',
|
||||
'group_order',
|
||||
];
|
||||
use HasFactory;
|
||||
|
||||
public function groupItems(): HasMany
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'featured_groups';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return $this->hasMany(GroupItem::class);
|
||||
return [
|
||||
'product_layout' => ProductLayout::class,
|
||||
'group_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return HasMany<FeaturedItem, $this> */
|
||||
public function featuredItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeaturedItem::class)->orderBy('order');
|
||||
}
|
||||
}
|
||||
|
||||
43
app/Domains/Catalog/Models/FeaturedItem.php
Normal file
43
app/Domains/Catalog/Models/FeaturedItem.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'featured_group_id',
|
||||
'catalog_item_id',
|
||||
'order',
|
||||
])]
|
||||
class FeaturedItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'featured_items';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'featured_group_id' => 'integer',
|
||||
'catalog_item_id' => 'integer',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<FeaturedGroup, $this> */
|
||||
public function featuredGroup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeaturedGroup::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class GroupItem extends Model
|
||||
{
|
||||
protected $table = 'group_items';
|
||||
|
||||
protected $fillable = [
|
||||
'featured_group_id',
|
||||
'groupable_type',
|
||||
'groupable_id',
|
||||
'order',
|
||||
];
|
||||
|
||||
public function featuredGroup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeaturedGroup::class);
|
||||
}
|
||||
|
||||
public function groupable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
97
app/Domains/Catalog/Models/Inventory.php
Normal file
97
app/Domains/Catalog/Models/Inventory.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable([
|
||||
'sold_units',
|
||||
'reserved_stock',
|
||||
'real_stock',
|
||||
])]
|
||||
class Inventory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'inventories';
|
||||
|
||||
protected $attributes = [
|
||||
'sold_units' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'real_stock' => 0,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sold_units' => 'integer',
|
||||
'reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return HasOne<CatalogItem, $this> */
|
||||
public function catalogItem(): HasOne
|
||||
{
|
||||
return $this->hasOne(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<Variant, $this> */
|
||||
public function variant(): HasOne
|
||||
{
|
||||
return $this->hasOne(Variant::class);
|
||||
}
|
||||
|
||||
public function availableStock(): int
|
||||
{
|
||||
return max(0, $this->real_stock - $this->reserved_stock);
|
||||
}
|
||||
|
||||
public function reserve(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->availableStock() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
$this->reserved_stock += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function release(int $amount): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function buy(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||
}
|
||||
|
||||
if ($tracksInventory && $this->real_stock < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
|
||||
if ($tracksInventory) {
|
||||
$this->real_stock -= $amount;
|
||||
}
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->sold_units += $amount;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
38
app/Domains/Catalog/Models/ItemAttribute.php
Normal file
38
app/Domains/Catalog/Models/ItemAttribute.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'catalog_item_id',
|
||||
'attribute_id',
|
||||
])]
|
||||
class ItemAttribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'item_attributes';
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Attribute, $this> */
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attribute::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<VariantDefinition, $this> */
|
||||
public function variantDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariantDefinition::class);
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Brand;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\ProductAttribute;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'categoria_id',
|
||||
'brand_id',
|
||||
'slug',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'precio',
|
||||
])]
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos';
|
||||
|
||||
protected ?ProductVariant $selectedVariant = null;
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'categoria_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Category, $this>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Brand, $this>
|
||||
*/
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class, 'brand_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariant, $this>
|
||||
*/
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariant::class, 'producto_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attribute, $this>
|
||||
*/
|
||||
public function attributes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attribute::class,
|
||||
'products_attributes',
|
||||
'product_id',
|
||||
'attribute_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductAttribute, $this>
|
||||
*/
|
||||
public function productAttributes(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductAttribute::class, 'product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'productos_attachments',
|
||||
'producto_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variant for this product with its definitions.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createVariant(array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
|
||||
/** @var ProductVariant $variant */
|
||||
$variant = $this->variants()->create($data);
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple variants for this product.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $variantsData
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
|
||||
*/
|
||||
public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$variants = new \Illuminate\Database\Eloquent\Collection();
|
||||
|
||||
foreach ($variantsData as $variantData) {
|
||||
$variants->push($this->createVariant($variantData));
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a product variant.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
$definitions = $data['definitions'] ?? [];
|
||||
$this->validateVariantDefinitions($definitions);
|
||||
|
||||
unset($data['definitions']);
|
||||
unset($data['producto_id']);
|
||||
|
||||
$variant->update($data);
|
||||
$variant->definitions()->delete();
|
||||
$variant->definitions()->createMany($definitions);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product variant.
|
||||
*/
|
||||
public function deleteVariant(ProductVariant $variant): void
|
||||
{
|
||||
$variant->definitions()->delete();
|
||||
$variant->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate variant definitions options against Attribute configuration.
|
||||
*
|
||||
* @param array $definitions
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected function validateVariantDefinitions(array $definitions): void
|
||||
{
|
||||
foreach ($definitions as $definition) {
|
||||
$productAttributeId = $definition['products_attribute_id'] ?? null;
|
||||
if (! $productAttributeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productAttribute = $this->productAttributes()
|
||||
->with('attribute.options')
|
||||
->find($productAttributeId);
|
||||
|
||||
$attribute = $productAttribute?->attribute;
|
||||
|
||||
if (! $productAttribute || ! $attribute) {
|
||||
throw new \InvalidArgumentException("Product attribute with ID {$productAttributeId} not found for product {$this->id}.");
|
||||
}
|
||||
|
||||
if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null && ! in_array($val, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
} elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
|
||||
$allowedValues = $attribute->options()->pluck('value')->toArray();
|
||||
$val = $definition['value'] ?? null;
|
||||
if ($val !== null) {
|
||||
$values = [];
|
||||
if (is_array($val)) {
|
||||
$values = $val;
|
||||
} else {
|
||||
$decoded = json_decode($val, true);
|
||||
if (is_array($decoded)) {
|
||||
$values = $decoded;
|
||||
} else {
|
||||
$values = array_map('trim', explode(',', $val));
|
||||
}
|
||||
}
|
||||
foreach ($values as $v) {
|
||||
if (! in_array($v, $allowedValues, true)) {
|
||||
throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
/** @var Attribute $attribute */
|
||||
$attribute = Attribute::query()->create([
|
||||
...$data,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribute.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
||||
{
|
||||
$options = $data['options'] ?? [];
|
||||
unset($data['options']);
|
||||
|
||||
$typeStr = $data['type'] ?? $attribute->type->value;
|
||||
$type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr);
|
||||
if (! $type->supportsOptions() && ! empty($options)) {
|
||||
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
|
||||
}
|
||||
|
||||
if (! $type->supportsOptions()) {
|
||||
$data['metadata_schema'] = null;
|
||||
}
|
||||
|
||||
$attribute->update($data);
|
||||
$attribute->options()->delete();
|
||||
$attribute->options()->createMany($options);
|
||||
|
||||
return $attribute->load('options');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attribute.
|
||||
*/
|
||||
public static function deleteAttribute(Attribute $attribute): void
|
||||
{
|
||||
$attribute->options()->delete();
|
||||
$attribute->delete();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function setSelectedVariant(ProductVariant $variant): void
|
||||
{
|
||||
$this->selectedVariant = $variant;
|
||||
}
|
||||
|
||||
public function getSelectedVariant(): ?ProductVariant
|
||||
{
|
||||
return $this->selectedVariant;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'product_id',
|
||||
'attribute_id',
|
||||
])]
|
||||
class ProductAttribute extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'products_attributes';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Product, $this>
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Attribute, $this>
|
||||
*/
|
||||
public function attribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariantDefinition, $this>
|
||||
*/
|
||||
public function variantDefinitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id');
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Shared\Contracts\Buyable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
#[Fillable([
|
||||
'producto_id',
|
||||
'inventory_policy',
|
||||
'stock_real',
|
||||
'stock_reservado',
|
||||
'stock',
|
||||
'is_placeholder',
|
||||
'has_tickets',
|
||||
'minimum_use_date',
|
||||
'maximum_use_date',
|
||||
])]
|
||||
class ProductVariant extends Model implements Buyable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes';
|
||||
|
||||
protected $appends = ['stock_tecnico'];
|
||||
|
||||
protected $attributes = [
|
||||
'inventory_policy' => 'tracked',
|
||||
'stock_real' => 0,
|
||||
'stock_reservado' => 0,
|
||||
'cantidad_vendida' => 0,
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (ProductVariant $variant) {
|
||||
if ($variant->exists && $variant->isDirty('inventory_policy')) {
|
||||
throw new \InvalidArgumentException('La politica de inventario no puede modificarse.');
|
||||
}
|
||||
|
||||
$variant->validateStock();
|
||||
});
|
||||
}
|
||||
|
||||
public function validateStock(): void
|
||||
{
|
||||
if ($this->stock_real < 0) {
|
||||
throw new \InvalidArgumentException('El stock real no puede ser negativo.');
|
||||
}
|
||||
|
||||
if ($this->stock_reservado < 0) {
|
||||
throw new \InvalidArgumentException('El stock reservado no puede ser negativo.');
|
||||
}
|
||||
|
||||
if ($this->cantidad_vendida < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad vendida no puede ser negativa.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->stock_reservado > $this->stock_real) {
|
||||
throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.');
|
||||
}
|
||||
}
|
||||
|
||||
public function tracksInventory(): bool
|
||||
{
|
||||
return $this->inventory_policy === InventoryPolicy::Tracked;
|
||||
}
|
||||
|
||||
public function availableQuantity(): ?int
|
||||
{
|
||||
if (! $this->tracksInventory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->stock_real - $this->stock_reservado;
|
||||
}
|
||||
|
||||
public function isAvailableForSale(): bool
|
||||
{
|
||||
return ! $this->tracksInventory() || $this->availableQuantity() > 0;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) ($this->product->precio ?? 0.0);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->product?->nombre ?? 'Producto';
|
||||
|
||||
if ($this->relationLoaded('definitions') && $this->definitions->isNotEmpty()) {
|
||||
$definitions = $this->definitions->map(function ($def) {
|
||||
$attributeName = $def->productAttribute?->attribute?->nombre;
|
||||
$value = $def->value;
|
||||
|
||||
return $attributeName ? "{$attributeName}: {$value}" : $value;
|
||||
})->filter()->implode(', ');
|
||||
|
||||
if ($definitions !== '') {
|
||||
$name .= " ({$definitions})";
|
||||
}
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
public function reserveStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->availableQuantity() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
|
||||
$this->stock_reservado += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function decrementReservedStock(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
||||
}
|
||||
$this->stock_reservado -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function buy(int $amount): void
|
||||
{
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory() && $this->stock_real < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.');
|
||||
}
|
||||
|
||||
if ($this->stock_reservado < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.');
|
||||
}
|
||||
|
||||
if ($this->tracksInventory()) {
|
||||
$this->stock_real -= $amount;
|
||||
}
|
||||
|
||||
$this->stock_reservado -= $amount;
|
||||
$this->cantidad_vendida += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
protected function stockTecnico(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): ?int => $this->availableQuantity());
|
||||
}
|
||||
|
||||
protected function stock(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->stock_real,
|
||||
set: fn ($value) => [
|
||||
'stock_real' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'producto_id' => 'integer',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'stock_real' => 'integer',
|
||||
'stock_reservado' => 'integer',
|
||||
'cantidad_vendida' => 'integer',
|
||||
'is_placeholder' => 'boolean',
|
||||
'has_tickets' => 'boolean',
|
||||
'minimum_use_date' => 'datetime',
|
||||
'maximum_use_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Product, $this>
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'producto_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProductVariantDefinition, $this>
|
||||
*/
|
||||
public function definitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Attachment, $this>
|
||||
*/
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'variantes_attachments',
|
||||
'variante_id',
|
||||
'attachment_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<GroupItem, $this>
|
||||
*/
|
||||
public function groupItems(): MorphMany
|
||||
{
|
||||
return $this->morphMany(GroupItem::class, 'groupable');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'producto_variante_id',
|
||||
'products_attribute_id',
|
||||
'value',
|
||||
])]
|
||||
class ProductVariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'productos_variantes_values';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductVariant, $this>
|
||||
*/
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ProductAttribute, $this>
|
||||
*/
|
||||
public function productAttribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductAttribute::class, 'products_attribute_id');
|
||||
}
|
||||
}
|
||||
92
app/Domains/Catalog/Models/Variant.php
Normal file
92
app/Domains/Catalog/Models/Variant.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'catalog_item_id',
|
||||
'inventory_id',
|
||||
])]
|
||||
class Variant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'variantes';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'catalog_item_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CatalogItem, $this> */
|
||||
public function catalogItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<VariantDefinition, $this> */
|
||||
public function definitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(VariantDefinition::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
$relation = $this->belongsToMany(
|
||||
Attachment::class,
|
||||
'catalog_items_attachments',
|
||||
'variant_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->orderByPivot('orden');
|
||||
|
||||
if ($this->catalog_item_id !== null) {
|
||||
$relation->withPivotValue('catalog_item_id', $this->catalog_item_id);
|
||||
}
|
||||
|
||||
return $relation;
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return $this->catalogItem->getPrice();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->catalogItem->nombre;
|
||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
||||
$definitions = $this->definitions
|
||||
->map(function (VariantDefinition $definition): ?string {
|
||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
||||
|
||||
return $attributeName
|
||||
? "{$attributeName}: {$definition->value}"
|
||||
: $definition->value;
|
||||
})
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
||||
}
|
||||
}
|
||||
32
app/Domains/Catalog/Models/VariantDefinition.php
Normal file
32
app/Domains/Catalog/Models/VariantDefinition.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'variant_id',
|
||||
'item_attribute_id',
|
||||
'value',
|
||||
])]
|
||||
class VariantDefinition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'variant_values';
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function variant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Variant::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<ItemAttribute, $this> */
|
||||
public function itemAttribute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ItemAttribute::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user