refactor(catalog): Complete catalog refactor to simplify its data model and its querying.
source commits: refactor/catalog
This commit is contained in:
@@ -62,7 +62,7 @@ MAIL_FROM_NAME="${APP_NAME}"
|
|||||||
AWS_ENDPOINT=
|
AWS_ENDPOINT=
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=
|
AWS_DEFAULT_REGION=garage
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||||
AWS_HTTP_VERIFY=
|
AWS_HTTP_VERIFY=
|
||||||
|
|||||||
@@ -66,6 +66,42 @@ class AttachmentService
|
|||||||
$attachment->delete();
|
$attachment->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function copy(Attachment $source, string $path): Attachment
|
||||||
|
{
|
||||||
|
$normalizedPath = $this->normalizeDirectory($path);
|
||||||
|
|
||||||
|
if ($normalizedPath === '') {
|
||||||
|
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = (string) Str::uuid();
|
||||||
|
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
|
||||||
|
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
|
||||||
|
|
||||||
|
if (! $copied) {
|
||||||
|
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var Attachment $attachment */
|
||||||
|
$attachment = Attachment::query()->create([
|
||||||
|
'key' => $key,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'filename' => $source->filename,
|
||||||
|
'type' => $source->type,
|
||||||
|
'mime_type' => $source->mime_type,
|
||||||
|
'extension' => $source->extension,
|
||||||
|
'size' => $source->size,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
Storage::disk('s3')->delete($storedPath);
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function normalizeDirectory(string $path): string
|
protected function normalizeDirectory(string $path): string
|
||||||
{
|
{
|
||||||
return trim($path, '/');
|
return trim($path, '/');
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Bundle\Models;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\GroupItem;
|
|
||||||
use App\Domains\Shared\Contracts\Buyable;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
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\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'tenant_codigo',
|
|
||||||
'nombre',
|
|
||||||
'descripcion',
|
|
||||||
'precio',
|
|
||||||
])]
|
|
||||||
class Bundle extends Model implements Buyable
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'bundles';
|
|
||||||
|
|
||||||
protected $appends = ['stock_tecnico'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'precio' => 'decimal:2',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<BundleItem, $this>
|
|
||||||
*/
|
|
||||||
public function items(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(BundleItem::class, 'bundle_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return MorphMany<GroupItem, $this>
|
|
||||||
*/
|
|
||||||
public function groupItems(): MorphMany
|
|
||||||
{
|
|
||||||
return $this->morphMany(GroupItem::class, 'groupable');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPrice(): float
|
|
||||||
{
|
|
||||||
return (float) $this->precio;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return $this->nombre ?? 'Bundle';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function availableQuantity(): ?int
|
|
||||||
{
|
|
||||||
if ($this->items->isEmpty()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$availableQuantities = [];
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$variantQuantity = $item->variant?->availableQuantity();
|
|
||||||
|
|
||||||
if ($variantQuantity !== null) {
|
|
||||||
$availableQuantities[] = intdiv($variantQuantity, $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $availableQuantities === [] ? null : min($availableQuantities);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reserveStock(int $amount): void
|
|
||||||
{
|
|
||||||
if ($amount < 0) {
|
|
||||||
throw new \InvalidArgumentException('El monto a reservar debe ser positivo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$item->variant->reserveStock($amount * $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function decrementReservedStock(int $amount): void
|
|
||||||
{
|
|
||||||
if ($amount < 0) {
|
|
||||||
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$item->variant->decrementReservedStock($amount * $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function buy(int $amount): void
|
|
||||||
{
|
|
||||||
if ($amount < 0) {
|
|
||||||
throw new \InvalidArgumentException('El monto a comprar debe ser positivo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->items as $item) {
|
|
||||||
$item->variant->buy($amount * $item->cantidad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function validateStock(): void
|
|
||||||
{
|
|
||||||
$availableQuantity = $this->availableQuantity();
|
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity <= 0) {
|
|
||||||
throw new \InvalidArgumentException('El bundle no tiene stock tecnico disponible.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function stockTecnico(): Attribute
|
|
||||||
{
|
|
||||||
return Attribute::get(fn (): ?int => $this->availableQuantity());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Bundle\Models;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
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_id',
|
|
||||||
'producto_variante_id',
|
|
||||||
'cantidad',
|
|
||||||
])]
|
|
||||||
class BundleItem extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'bundle_items';
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'bundle_id' => 'integer',
|
|
||||||
'producto_variante_id' => 'integer',
|
|
||||||
'cantidad' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Bundle, $this>
|
|
||||||
*/
|
|
||||||
public function bundle(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Bundle::class, 'bundle_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<ProductVariant, $this>
|
|
||||||
*/
|
|
||||||
public function variant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,8 +28,10 @@ class CartController extends Controller
|
|||||||
$result = $this->cartService->addItem(
|
$result = $this->cartService->addItem(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request,
|
$request,
|
||||||
$request->mappedBuyableType(),
|
(int) $request->validated('catalog_item_id'),
|
||||||
(int) $request->validated('buyable_id'),
|
$request->validated('variant_id') !== null
|
||||||
|
? (int) $request->validated('variant_id')
|
||||||
|
: null,
|
||||||
(int) $request->validated('cantidad'),
|
(int) $request->validated('cantidad'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Shared\Contracts\Buyable;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -65,15 +66,16 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
? $this->getRelation('items')
|
? $this->getRelation('items')
|
||||||
: $this->items()->with('buyable')->get();
|
: $this->items()->with(['catalogItem', 'variant'])->get();
|
||||||
|
|
||||||
return (float) $items->reduce(
|
return (float) $items->reduce(
|
||||||
fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
|
fn (float $carry, CartItem $item): float => $carry
|
||||||
|
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
|
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
|
||||||
{
|
{
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -81,10 +83,11 @@ class Cart extends Model
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
|
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
|
||||||
$canonicalType = $buyable::class;
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
$availableQuantity = $buyable->availableQuantity();
|
$inventoryService = app(CatalogInventoryService::class);
|
||||||
|
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -94,15 +97,15 @@ class Cart extends Model
|
|||||||
|
|
||||||
/** @var CartItem|null $item */
|
/** @var CartItem|null $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('buyable_type', $canonicalType)
|
->where('catalog_item_id', $catalogItemId)
|
||||||
->where('buyable_id', $buyable->getKey())
|
->where('variant_id', $variantId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
$item = $this->items()->create([
|
$item = $this->items()->create([
|
||||||
'buyable_type' => $canonicalType,
|
'catalog_item_id' => $catalogItemId,
|
||||||
'buyable_id' => $buyable->getKey(),
|
'variant_id' => $variantId,
|
||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
@@ -110,7 +113,7 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$buyable->reserveStock($quantity);
|
$inventoryService->reserve($selectedItem, $quantity);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -131,9 +134,14 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
|
$selectedItem = $this->resolveScopedItem(
|
||||||
|
$item->catalog_item_id,
|
||||||
|
$item->variant_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
$inventoryService = app(CatalogInventoryService::class);
|
||||||
$delta = $quantity - $item->cantidad;
|
$delta = $quantity - $item->cantidad;
|
||||||
$availableQuantity = $buyable->availableQuantity();
|
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||||
|
|
||||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
@@ -146,11 +154,11 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$buyable->reserveStock($delta);
|
$inventoryService->reserve($selectedItem, $delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$buyable->decrementReservedStock(abs($delta));
|
$inventoryService->release($selectedItem, abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
@@ -166,45 +174,96 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
|
$selectedItem = $this->resolveScopedItem(
|
||||||
$buyable->decrementReservedStock($item->cantidad);
|
$item->catalog_item_id,
|
||||||
|
$item->variant_id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
app(CatalogInventoryService::class)->release(
|
||||||
|
$selectedItem,
|
||||||
|
$item->cantidad,
|
||||||
|
);
|
||||||
$item->delete();
|
$item->delete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable
|
protected function resolveScopedItem(
|
||||||
{
|
int $catalogItemId,
|
||||||
$buyableClass = $this->resolveBuyableClass($buyableType);
|
?int $variantId,
|
||||||
|
bool $lockForUpdate = false,
|
||||||
|
): CatalogItem|Variant {
|
||||||
|
$catalogItemQuery = CatalogItem::query()
|
||||||
|
->whereKey($catalogItemId)
|
||||||
|
->where('tenant_code', $this->tenant_codigo);
|
||||||
|
|
||||||
if ($buyableClass === ProductVariant::class) {
|
if ($lockForUpdate) {
|
||||||
$query = ProductVariant::query()
|
$catalogItemQuery->lockForUpdate();
|
||||||
->whereKey($buyableId)
|
|
||||||
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
|
|
||||||
} else {
|
|
||||||
$query = Bundle::query()
|
|
||||||
->whereKey($buyableId)
|
|
||||||
->where('tenant_codigo', $this->tenant_codigo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$catalogItem = $catalogItemQuery->first();
|
||||||
|
|
||||||
|
if ($catalogItem === null) {
|
||||||
|
throw new NotFoundHttpException('Catalog item not found for tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($catalogItem->isBundle()) {
|
||||||
|
if ($variantId !== null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variant_id' => 'Un bundle no admite una variante.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $catalogItem->bundleComponents()->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'catalog_item_id' => 'El bundle no tiene componentes.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $catalogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variantId === null) {
|
||||||
|
if ($catalogItem->inventory_id === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
|
||||||
|
$catalogItem->setRelation('inventory', $inventory);
|
||||||
|
|
||||||
|
return $catalogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variantQuery = Variant::query()
|
||||||
|
->whereKey($variantId)
|
||||||
|
->where('catalog_item_id', $catalogItem->id);
|
||||||
|
|
||||||
|
if ($lockForUpdate) {
|
||||||
|
$variantQuery->lockForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = $variantQuery->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
|
||||||
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
$variant->setRelation('inventory', $inventory);
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
|
||||||
|
{
|
||||||
|
$query = Inventory::query()->whereKey($inventoryId);
|
||||||
|
|
||||||
if ($lockForUpdate) {
|
if ($lockForUpdate) {
|
||||||
$query->lockForUpdate();
|
$query->lockForUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
$buyable = $query->first();
|
return $query->firstOrFail();
|
||||||
|
|
||||||
if ($buyable === null) {
|
|
||||||
throw new NotFoundHttpException('Buyable not found for tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $buyable;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveBuyableClass(string $buyableType): string
|
|
||||||
{
|
|
||||||
return match ($buyableType) {
|
|
||||||
'variant', ProductVariant::class => ProductVariant::class,
|
|
||||||
'bundle', Bundle::class => Bundle::class,
|
|
||||||
default => throw new \InvalidArgumentException('Invalid buyable type'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -10,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
'buyable_id',
|
'catalog_item_id',
|
||||||
'buyable_type',
|
'variant_id',
|
||||||
'cantidad',
|
'cantidad',
|
||||||
])]
|
])]
|
||||||
class CartItem extends Model
|
class CartItem extends Model
|
||||||
@@ -24,8 +25,8 @@ class CartItem extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
'buyable_id' => 'integer',
|
'catalog_item_id' => 'integer',
|
||||||
'buyable_type' => 'string',
|
'variant_id' => 'integer',
|
||||||
'cantidad' => 'integer',
|
'cantidad' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -38,11 +39,20 @@ class CartItem extends Model
|
|||||||
return $this->belongsTo(Cart::class, 'cart_id');
|
return $this->belongsTo(Cart::class, 'cart_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return BelongsTo<CatalogItem, $this> */
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
public function catalogItem(): BelongsTo
|
||||||
*/
|
|
||||||
public function buyable()
|
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->belongsTo(CatalogItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Variant, $this> */
|
||||||
|
public function variant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Variant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedItem(): CatalogItem|Variant|null
|
||||||
|
{
|
||||||
|
return $this->variant ?? $this->catalogItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Cart\Requests;
|
namespace App\Domains\Cart\Requests;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -19,19 +17,25 @@ class AddCartItemRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$tenantCode = $this->route('tenant')?->codigo;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'buyable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
|
'catalog_item_id' => [
|
||||||
'buyable_id' => ['required', 'integer'],
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('catalog_items', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'variant_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('variantes', 'id')->where(
|
||||||
|
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
|
||||||
|
),
|
||||||
|
],
|
||||||
'cantidad' => ['required', 'integer', 'min:1'],
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mappedBuyableType(): string
|
|
||||||
{
|
|
||||||
return match ($this->input('buyable_type')) {
|
|
||||||
'variant' => ProductVariant::class,
|
|
||||||
'bundle' => Bundle::class,
|
|
||||||
default => throw new \InvalidArgumentException('Invalid buyable type'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cantidad' => ['required', 'integer', 'min:1'],
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
'buyable_type' => ['prohibited'],
|
'catalog_item_id' => ['prohibited'],
|
||||||
'buyable_id' => ['prohibited'],
|
'variant_id' => ['prohibited'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Cart\Resources;
|
namespace App\Domains\Cart\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin \App\Domains\Cart\Models\CartItem
|
* @mixin CartItem
|
||||||
*/
|
*/
|
||||||
class CartItemResource extends JsonResource
|
class CartItemResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -15,42 +16,30 @@ class CartItemResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
|
$selectedItem = $this->selectedItem();
|
||||||
$buyable = $this->buyable;
|
|
||||||
|
|
||||||
$productName = $buyable?->getName();
|
|
||||||
$precio = $buyable?->getPrice();
|
|
||||||
|
|
||||||
$imageUrl = null;
|
$imageUrl = null;
|
||||||
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
|
|
||||||
$firstAttachment = $buyable->attachments->first();
|
if ($selectedItem?->relationLoaded('attachments')) {
|
||||||
if ($firstAttachment) {
|
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
|
}
|
||||||
}
|
|
||||||
|
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||||
|
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'cantidad' => $this->cantidad,
|
'cantidad' => $this->cantidad,
|
||||||
'precio_unitario' => $this->formatMoney($precio),
|
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
|
||||||
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
|
'catalog_item_id' => $this->catalog_item_id,
|
||||||
'buyable_id' => $this->buyable_id,
|
'variant_id' => $this->variant_id,
|
||||||
'product' => $buyable === null ? null : [
|
'product' => $selectedItem === null ? null : [
|
||||||
'nombre' => $productName,
|
'nombre' => $selectedItem->getName(),
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mapBuyableTypeToAlias(?string $type): string
|
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
|
|
||||||
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
|
|
||||||
default => 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatMoney(float|int|string|null $amount): string
|
protected function formatMoney(float|int|string|null $amount): string
|
||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class CartResource extends JsonResource
|
|||||||
: collect();
|
: collect();
|
||||||
|
|
||||||
$subtotal = $items->reduce(
|
$subtotal = $items->reduce(
|
||||||
fn (float $carry, $item): float => $carry + ((float) ($item->buyable?->getPrice() ?? 0) * $item->cantidad),
|
fn (float $carry, $item): float => $carry
|
||||||
|
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
namespace App\Domains\Cart\Services;
|
namespace App\Domains\Cart\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -36,12 +33,17 @@ class CartService
|
|||||||
/**
|
/**
|
||||||
* @return array{cart: Cart, guest_token: ?string}
|
* @return array{cart: Cart, guest_token: ?string}
|
||||||
*/
|
*/
|
||||||
public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
|
public function addItem(
|
||||||
{
|
Tenant $tenant,
|
||||||
|
Request $request,
|
||||||
|
int $catalogItemId,
|
||||||
|
?int $variantId,
|
||||||
|
int $quantity,
|
||||||
|
): array {
|
||||||
$resolvedIdentity = $this->resolveIdentity($request, true);
|
$resolvedIdentity = $this->resolveIdentity($request, true);
|
||||||
$identity = $resolvedIdentity['identity'];
|
$identity = $resolvedIdentity['identity'];
|
||||||
$cart = $this->findOrCreateCart($tenant, $identity);
|
$cart = $this->findOrCreateCart($tenant, $identity);
|
||||||
$cart->addItem($buyableType, $buyableId, $quantity);
|
$cart->addItem($catalogItemId, $variantId, $quantity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cart' => $this->loadCart($cart),
|
'cart' => $this->loadCart($cart),
|
||||||
@@ -97,16 +99,11 @@ class CartService
|
|||||||
protected function loadCart(Cart $cart): Cart
|
protected function loadCart(Cart $cart): Cart
|
||||||
{
|
{
|
||||||
return $cart->fresh()->load([
|
return $cart->fresh()->load([
|
||||||
'items.buyable' => function (MorphTo $morphTo): void {
|
'items.catalogItem.attachments',
|
||||||
$morphTo->morphWith([
|
'items.catalogItem.inventory',
|
||||||
ProductVariant::class => [
|
'items.variant.attachments',
|
||||||
'product',
|
'items.variant.inventory',
|
||||||
'definitions.productAttribute.attribute',
|
'items.variant.definitions.itemAttribute.attribute',
|
||||||
'attachments',
|
|
||||||
],
|
|
||||||
Bundle::class => ['items.variant'],
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
|
||||||
use App\Domains\Catalog\Requests\StoreAttributeRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
|
|
||||||
use App\Domains\Catalog\Resources\AttributeResource;
|
|
||||||
use App\Domains\Catalog\Services\ProductService;
|
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class AttributeController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
$query = Attribute::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->with('options')
|
|
||||||
->latest();
|
|
||||||
|
|
||||||
return AttributeResource::collection($query->paginateFromRequest())->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
$attribute = ProductService::createAttribute($tenant, $request->validated());
|
|
||||||
|
|
||||||
return AttributeResource::make($attribute)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
|
|
||||||
{
|
|
||||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
|
||||||
|
|
||||||
return AttributeResource::make($attribute->load('options'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
|
|
||||||
{
|
|
||||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
|
||||||
$attribute = ProductService::updateAttribute($attribute, $request->validated());
|
|
||||||
|
|
||||||
return AttributeResource::make($attribute);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant, Attribute $attribute): Response
|
|
||||||
{
|
|
||||||
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
|
|
||||||
ProductService::deleteAttribute($attribute);
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
|
|
||||||
{
|
|
||||||
if ($attribute->tenant_codigo !== $tenant->codigo) {
|
|
||||||
throw new NotFoundHttpException('Attribute not found for tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $attribute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Brand;
|
|
||||||
use App\Domains\Catalog\Requests\StoreBrandRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateBrandRequest;
|
|
||||||
use App\Domains\Catalog\Resources\BrandResource;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class BrandController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
return BrandResource::collection(
|
|
||||||
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
|
|
||||||
)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
$brand = Brand::query()->create([
|
|
||||||
...$request->validated(),
|
|
||||||
'tenant_codigo' => $tenant->codigo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return BrandResource::make($brand)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Tenant $tenant, Brand $marca): BrandResource
|
|
||||||
{
|
|
||||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
|
||||||
|
|
||||||
return BrandResource::make($marca);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
|
|
||||||
{
|
|
||||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
|
||||||
$marca->update($request->validated());
|
|
||||||
|
|
||||||
return BrandResource::make($marca);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant, Brand $marca): Response
|
|
||||||
{
|
|
||||||
$marca = $this->resolveScopedBrand($tenant, $marca);
|
|
||||||
$marca->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
|
|
||||||
{
|
|
||||||
if ($brand->tenant_codigo !== $tenant->codigo) {
|
|
||||||
throw new NotFoundHttpException('Brand not found for tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $brand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,31 +2,122 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||||
|
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||||
|
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
{
|
{
|
||||||
public function index(string $tenant): JsonResponse
|
private const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
|
public function index(Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->with([
|
->where('tenant_code', $tenant->codigo)
|
||||||
'groupItems' => fn ($query) => $query->orderBy('order'),
|
|
||||||
'groupItems.groupable' => function (MorphTo $morphTo): void {
|
|
||||||
$morphTo->morphWith([
|
|
||||||
ProductVariant::class => ['product', 'attachments', 'product.attachments'],
|
|
||||||
Bundle::class => ['items.variant.product', 'items.variant.attachments', 'items.variant.product.attachments'],
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->orderBy('group_order')
|
->orderBy('group_order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
|
return response()->json($featuredGroups->map(
|
||||||
|
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||||
|
$featuredGroup,
|
||||||
|
$this->featuredItemsResponse($featuredGroup, 1),
|
||||||
|
))->resolve()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featuredGroupItems(
|
||||||
|
FeaturedGroupPageRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
): JsonResponse {
|
||||||
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
$page = (int) $request->validated('page', 1);
|
||||||
|
|
||||||
|
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(
|
||||||
|
CatalogItemDetailRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
CatalogItem $catalogItem,
|
||||||
|
CatalogService $catalogService,
|
||||||
|
): CatalogItemDetailResource {
|
||||||
|
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
$variantId = $request->validated('variant_id');
|
||||||
|
|
||||||
|
return CatalogItemDetailResource::make(
|
||||||
|
$catalogService->getDetail(
|
||||||
|
$catalogItem,
|
||||||
|
$variantId === null ? null : (int) $variantId,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
StoreCatalogItemRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
CatalogService $catalogService,
|
||||||
|
): JsonResponse {
|
||||||
|
$catalogItem = $catalogService->create([
|
||||||
|
...$request->validated(),
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return CatalogItemResource::make($catalogItem)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||||
|
{
|
||||||
|
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
||||||
|
|
||||||
|
$paginator->getCollection()->each(
|
||||||
|
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
||||||
|
);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($paginator)
|
||||||
|
->response()
|
||||||
|
->getData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginateFeaturedItems(
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$paginator = $featuredGroup->featuredItems()
|
||||||
|
->with([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.attachments',
|
||||||
|
'catalogItem.variants.inventory',
|
||||||
|
'catalogItem.variants.attachments',
|
||||||
|
'catalogItem.variants.definitions.itemAttribute.attribute',
|
||||||
|
'catalogItem.bundleComponents.catalogItem',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
])
|
||||||
|
->paginate(
|
||||||
|
perPage: self::ITEMS_PER_PAGE,
|
||||||
|
pageName: 'page',
|
||||||
|
page: $page,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||||
|
'tenant' => $featuredGroup->tenant_code,
|
||||||
|
'featuredGroup' => $featuredGroup->id,
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Category;
|
|
||||||
use App\Domains\Catalog\Requests\StoreCategoryRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
|
|
||||||
use App\Domains\Catalog\Resources\CategoryResource;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class CategoryController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
return CategoryResource::collection(
|
|
||||||
Category::query()
|
|
||||||
->where(function ($query) use ($tenant): void {
|
|
||||||
$query
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->orWhereNull('tenant_code');
|
|
||||||
})
|
|
||||||
->with(['parent', 'subCategories', 'tenant'])
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get()
|
|
||||||
)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
$category = Category::query()->create([
|
|
||||||
...$request->validated(),
|
|
||||||
'tenant_code' => $tenant->codigo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
|
|
||||||
->response()
|
|
||||||
->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Tenant $tenant, Category $categoria): CategoryResource
|
|
||||||
{
|
|
||||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
|
||||||
|
|
||||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
|
|
||||||
{
|
|
||||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
|
||||||
$this->ensureCategoryIsMutable($categoria);
|
|
||||||
$categoria->update($request->validated());
|
|
||||||
|
|
||||||
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant, Category $categoria): Response
|
|
||||||
{
|
|
||||||
$categoria = $this->resolveScopedCategory($tenant, $categoria);
|
|
||||||
$this->ensureCategoryIsMutable($categoria);
|
|
||||||
$categoria->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
|
|
||||||
{
|
|
||||||
if ($category->tenant_code !== null && $category->tenant_code !== $tenant->codigo) {
|
|
||||||
throw new NotFoundHttpException('Category not found for tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $category;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function ensureCategoryIsMutable(Category $category): void
|
|
||||||
{
|
|
||||||
if ($category->isGlobal()) {
|
|
||||||
throw new AccessDeniedHttpException('Global categories are read-only.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
|
|
||||||
use App\Domains\Catalog\Resources\FeaturedGroupResource;
|
|
||||||
use App\Domains\Catalog\Services\FeaturedGroupService;
|
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class FeaturedGroupController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly FeaturedGroupService $featuredGroupService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(string $tenant): AnonymousResourceCollection
|
|
||||||
{
|
|
||||||
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
|
|
||||||
return FeaturedGroupResource::collection($groups);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
|
|
||||||
{
|
|
||||||
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
|
|
||||||
return new FeaturedGroupResource($group);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
|
||||||
return new FeaturedGroupResource($featuredGroup->load('groupItems'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
|
||||||
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
|
|
||||||
return new FeaturedGroupResource($group);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
|
||||||
$this->featuredGroupService->deleteGroup($featuredGroup);
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
use App\Domains\Catalog\Models\GroupItem;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Catalog\Requests\StoreGroupItemRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateGroupItemRequest;
|
|
||||||
use App\Domains\Catalog\Resources\GroupItemResource;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
|
|
||||||
class GroupItemController extends Controller
|
|
||||||
{
|
|
||||||
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
|
||||||
|
|
||||||
return GroupItemResource::collection($featuredGroup->groupItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup): GroupItemResource
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
|
|
||||||
|
|
||||||
$groupableType = $request->mappedGroupableType();
|
|
||||||
$groupable = $groupableType::query()->findOrFail($request->integer('groupable_id'));
|
|
||||||
|
|
||||||
abort_if(
|
|
||||||
($groupable instanceof ProductVariant && $groupable->product()->where('tenant_codigo', $tenant)->doesntExist())
|
|
||||||
|| ($groupable instanceof Bundle && $groupable->tenant_codigo !== $tenant),
|
|
||||||
404
|
|
||||||
);
|
|
||||||
|
|
||||||
$groupItem = $featuredGroup->groupItems()->create([
|
|
||||||
'groupable_type' => $groupableType,
|
|
||||||
'groupable_id' => $groupable->getKey(),
|
|
||||||
'order' => $request->validated('order'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new GroupItemResource($groupItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateGroupItemRequest $request, string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): GroupItemResource
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
|
|
||||||
$groupItem->update($request->validated());
|
|
||||||
|
|
||||||
return new GroupItemResource($groupItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(string $tenant, FeaturedGroup $featuredGroup, GroupItem $groupItem): JsonResponse
|
|
||||||
{
|
|
||||||
abort_if($featuredGroup->tenant_codigo !== $tenant || $groupItem->featured_group_id !== $featuredGroup->id, 404);
|
|
||||||
$groupItem->delete();
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Catalog\Requests\ProductDetailRequest;
|
|
||||||
use App\Domains\Catalog\Requests\StoreProductRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateProductRequest;
|
|
||||||
use App\Domains\Catalog\Resources\ProductResource;
|
|
||||||
use App\Domains\Catalog\Services\ProductService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class ProductController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Tenant $tenant, ProductService $productService): JsonResponse
|
|
||||||
{
|
|
||||||
return ProductResource::collection(
|
|
||||||
$productService->getProductos($tenant)
|
|
||||||
)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
|
|
||||||
{
|
|
||||||
$product = $productService->create($tenant, $request->validated());
|
|
||||||
|
|
||||||
return ProductResource::make($product)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$variantId = $request->query('variant_id');
|
|
||||||
$variantId = $variantId !== null ? (int) $variantId : null;
|
|
||||||
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
|
|
||||||
|
|
||||||
return ProductResource::make($producto);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$producto = $productService->update($producto, $request->validated());
|
|
||||||
|
|
||||||
return ProductResource::make($producto);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$productService->delete($producto);
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
|
||||||
{
|
|
||||||
if ($product->tenant_codigo !== $tenant->codigo) {
|
|
||||||
throw new NotFoundHttpException('Product not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $product;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
|
|
||||||
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
|
|
||||||
use App\Domains\Catalog\Resources\ProductVariantResource;
|
|
||||||
use App\Domains\Catalog\Services\ProductService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class ProductVariantController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Tenant $tenant, Product $producto): JsonResponse
|
|
||||||
{
|
|
||||||
|
|
||||||
$query = ProductVariant::query()
|
|
||||||
->where('producto_id', $producto->id)
|
|
||||||
->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
|
|
||||||
->latest();
|
|
||||||
|
|
||||||
return ProductVariantResource::collection($query->paginateFromRequest())->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreProductVariantRequest $request, Tenant $tenant, Product $producto, ProductService $productService): JsonResponse
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
|
|
||||||
$variant = $productService->createVariant($producto, $request->validated());
|
|
||||||
|
|
||||||
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Tenant $tenant, Product $producto, ProductVariant $productVariant): ProductVariantResource
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
|
|
||||||
|
|
||||||
return ProductVariantResource::make($productVariant->load([
|
|
||||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
'definitions.productAttribute.attribute.options',
|
|
||||||
'product.attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
|
|
||||||
|
|
||||||
$productVariant = $productService->updateVariant($productVariant, $request->validated());
|
|
||||||
|
|
||||||
return ProductVariantResource::make($productVariant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): Response
|
|
||||||
{
|
|
||||||
$producto = $this->resolveScopedProduct($tenant, $producto);
|
|
||||||
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
|
|
||||||
$productService->deleteVariant($productVariant);
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
|
|
||||||
{
|
|
||||||
if ($product->tenant_codigo !== $tenant->codigo) {
|
|
||||||
throw new NotFoundHttpException('Product not found for tenant.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $product;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveScopedVariant(Product $product, ProductVariant $variant): ProductVariant
|
|
||||||
{
|
|
||||||
if ($variant->producto_id !== $product->id) {
|
|
||||||
throw new NotFoundHttpException('Product variant not found for product.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
app/Domains/Catalog/Enums/CatalogItemType.php
Normal file
15
app/Domains/Catalog/Enums/CatalogItemType.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum CatalogItemType: string
|
||||||
|
{
|
||||||
|
case Standard = 'standard';
|
||||||
|
case Bundle = 'bundle';
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,12 @@ enum InventoryPolicy: string
|
|||||||
{
|
{
|
||||||
case Tracked = 'tracked';
|
case Tracked = 'tracked';
|
||||||
case Unlimited = 'unlimited';
|
case Unlimited = 'unlimited';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Domains/Catalog/Enums/ProductLayout.php
Normal file
18
app/Domains/Catalog/Enums/ProductLayout.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum ProductLayout: string
|
||||||
|
{
|
||||||
|
case Row = 'row';
|
||||||
|
case ColumnWithImage = 'column_with_image';
|
||||||
|
case ColumnWithCart = 'column_with_cart';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
@@ -52,27 +51,4 @@ class Attribute extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
@@ -26,4 +27,10 @@ class Brand extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
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';
|
protected $table = 'categorias';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -55,4 +58,10 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(self::class, 'categoria_id');
|
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;
|
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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_code',
|
||||||
|
'product_layout',
|
||||||
|
'group_name',
|
||||||
|
'group_order',
|
||||||
|
])]
|
||||||
class FeaturedGroup extends Model
|
class FeaturedGroup extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
use HasFactory;
|
||||||
'tenant_codigo',
|
|
||||||
'group_name',
|
|
||||||
'product_layout',
|
|
||||||
'group_order',
|
|
||||||
];
|
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -4,17 +4,18 @@ namespace App\Domains\Catalog\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class UpdateGroupItemRequest extends FormRequest
|
class CatalogItemDetailRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'order' => ['sometimes', 'integer'],
|
'variant_id' => ['sometimes', 'integer', 'min:1'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,18 @@ namespace App\Domains\Catalog\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class ProductDetailRequest extends FormRequest
|
class FeaturedGroupPageRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return array<string, list<string>> */
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'variant_id' => ['sometimes', 'integer'],
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
|
|
||||||
class StoreAttributeRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'codigo' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('attribute', 'codigo')->where(
|
|
||||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
|
||||||
'type' => ['required', Rule::enum(FieldType::class)],
|
|
||||||
'options' => [
|
|
||||||
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
|
|
||||||
FieldType::Select->value,
|
|
||||||
FieldType::Multiselect->value,
|
|
||||||
], true)),
|
|
||||||
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
|
|
||||||
FieldType::Select->value,
|
|
||||||
FieldType::Multiselect->value,
|
|
||||||
], true)),
|
|
||||||
'array',
|
|
||||||
],
|
|
||||||
'options.*.value' => ['required', 'string', 'max:255'],
|
|
||||||
'options.*.label' => ['required', 'string', 'max:255'],
|
|
||||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
|
||||||
'options.*.metadata' => ['nullable', 'array'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withValidator(Validator $validator): void
|
|
||||||
{
|
|
||||||
$validator->after(function (Validator $validator): void {
|
|
||||||
$type = $this->input('type');
|
|
||||||
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
|
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('options')) {
|
|
||||||
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreBrandRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
105
app/Domains/Catalog/Requests/StoreCatalogItemRequest.php
Normal file
105
app/Domains/Catalog/Requests/StoreCatalogItemRequest.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreCatalogItemRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenantCode = $this->route('tenant')?->codigo;
|
||||||
|
$type = $this->input('type', CatalogItemType::Standard->value);
|
||||||
|
$isBundle = $type === CatalogItemType::Bundle->value;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_code' => ['prohibited'],
|
||||||
|
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
|
||||||
|
'category_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('categorias', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'brand_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('brands', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'slug' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('catalog_items', 'slug')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
|
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||||
|
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
|
||||||
|
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
|
||||||
|
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
|
||||||
|
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
|
||||||
|
'inventory_id' => ['prohibited'],
|
||||||
|
'reserved_stock' => ['prohibited'],
|
||||||
|
'sold_units' => ['prohibited'],
|
||||||
|
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||||
|
'attribute_codes.*' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('attribute', 'codigo')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'images' => ['sometimes', 'array'],
|
||||||
|
'images.*' => ['required', new ImageOrBase64Rule],
|
||||||
|
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||||
|
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'variants.*.inventory_id' => ['prohibited'],
|
||||||
|
'variants.*.reserved_stock' => ['prohibited'],
|
||||||
|
'variants.*.sold_units' => ['prohibited'],
|
||||||
|
'variants.*.values' => ['sometimes', 'array'],
|
||||||
|
'variants.*.values.*' => ['nullable', 'string'],
|
||||||
|
'variants.*.images' => ['sometimes', 'array'],
|
||||||
|
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
|
||||||
|
'components' => [
|
||||||
|
Rule::requiredIf($isBundle),
|
||||||
|
Rule::prohibitedIf(! $isBundle),
|
||||||
|
'array',
|
||||||
|
'min:1',
|
||||||
|
],
|
||||||
|
'components.*.catalog_item_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('catalog_items', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'components.*.variant_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('variantes', 'id'),
|
||||||
|
],
|
||||||
|
'components.*.quantity' => ['required', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreCategoryRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreFeaturedGroupRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'group_name' => ['required', 'string', 'max:255'],
|
|
||||||
'product_layout' => ['required', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
|
|
||||||
'group_order' => ['nullable', 'integer'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreGroupItemRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'groupable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
|
|
||||||
'groupable_id' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists(match ($this->input('groupable_type')) {
|
|
||||||
'bundle' => 'bundles',
|
|
||||||
default => 'productos_variantes',
|
|
||||||
}, 'id'),
|
|
||||||
],
|
|
||||||
'order' => ['nullable', 'integer'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mappedGroupableType(): string
|
|
||||||
{
|
|
||||||
return match ($this->input('groupable_type')) {
|
|
||||||
'variant' => ProductVariant::class,
|
|
||||||
'bundle' => Bundle::class,
|
|
||||||
default => throw new \InvalidArgumentException('Invalid groupable type'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreProductRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'categoria_id' => ['required', 'integer'],
|
|
||||||
'brand_id' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('brands', 'id')->where(
|
|
||||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
|
||||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
|
|
||||||
'cantidad_vendida' => ['prohibited'],
|
|
||||||
'attribute_ids' => ['sometimes', 'array'],
|
|
||||||
'attribute_ids.*' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('attribute', 'id')->where(
|
|
||||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'images' => ['sometimes', 'nullable', 'array'],
|
|
||||||
'images.*' => ['required', new ImageOrBase64Rule],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreProductVariantRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)],
|
|
||||||
'cantidad_vendida' => ['prohibited'],
|
|
||||||
'definitions' => ['sometimes', 'array'],
|
|
||||||
'definitions.*.products_attribute_id' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
'distinct',
|
|
||||||
Rule::exists('products_attributes', 'id')->where(
|
|
||||||
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'definitions.*.value' => ['nullable', 'string'],
|
|
||||||
'images' => ['sometimes', 'nullable', 'array'],
|
|
||||||
'images.*' => ['required', new ImageOrBase64Rule],
|
|
||||||
'has_tickets' => ['boolean'],
|
|
||||||
'minimum_use_date' => ['nullable', 'date'],
|
|
||||||
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Validator;
|
|
||||||
|
|
||||||
class UpdateAttributeRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Attribute|null $attribute */
|
|
||||||
$attribute = $this->route('attribute');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'codigo' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('attribute', 'codigo')
|
|
||||||
->ignore($attribute?->id)
|
|
||||||
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
|
|
||||||
],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'is_required' => ['sometimes', 'boolean'],
|
|
||||||
'metadata_schema' => ['nullable', 'array'],
|
|
||||||
'type' => ['required', Rule::enum(FieldType::class)],
|
|
||||||
'options' => ['sometimes', 'array'],
|
|
||||||
'options.*.value' => ['required', 'string', 'max:255'],
|
|
||||||
'options.*.label' => ['required', 'string', 'max:255'],
|
|
||||||
'options.*.sort_order' => ['sometimes', 'integer'],
|
|
||||||
'options.*.metadata' => ['nullable', 'array'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withValidator(Validator $validator): void
|
|
||||||
{
|
|
||||||
$validator->after(function (Validator $validator): void {
|
|
||||||
$type = $this->input('type');
|
|
||||||
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
|
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('metadata_schema')) {
|
|
||||||
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $supportsOptions && $this->filled('options')) {
|
|
||||||
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateBrandRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Category;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateCategoryRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Category|null $category */
|
|
||||||
$category = $this->route('categoria');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'categoria_id' => [
|
|
||||||
'nullable',
|
|
||||||
'integer',
|
|
||||||
'exists:categorias,id',
|
|
||||||
Rule::notIn([$category?->id]),
|
|
||||||
],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateFeaturedGroupRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'group_name' => ['sometimes', 'string', 'max:255'],
|
|
||||||
'product_layout' => ['sometimes', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
|
|
||||||
'group_order' => ['nullable', 'integer'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateProductRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Product|null $product */
|
|
||||||
$product = $this->route('producto');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'categoria_id' => ['required', 'integer'],
|
|
||||||
'brand_id' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('brands', 'id')->where(
|
|
||||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'slug' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('productos', 'slug')->ignore($product?->id),
|
|
||||||
],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'descripcion' => ['nullable', 'string'],
|
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
|
||||||
'attribute_ids' => ['sometimes', 'array'],
|
|
||||||
'attribute_ids.*' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::exists('attribute', 'id')->where(
|
|
||||||
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'images' => ['sometimes', 'nullable', 'array'],
|
|
||||||
'images.*' => ['required', new ImageOrBase64Rule()],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateProductVariantRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'stock' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'inventory_policy' => ['prohibited'],
|
|
||||||
'cantidad_vendida' => ['prohibited'],
|
|
||||||
'definitions' => ['sometimes', 'array'],
|
|
||||||
'definitions.*.products_attribute_id' => [
|
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
'distinct',
|
|
||||||
Rule::exists('products_attributes', 'id')->where(
|
|
||||||
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
|
|
||||||
),
|
|
||||||
],
|
|
||||||
'definitions.*.value' => ['nullable', 'string'],
|
|
||||||
'images' => ['sometimes', 'nullable', 'array'],
|
|
||||||
'images.*' => ['required', new ImageOrBase64Rule],
|
|
||||||
'has_tickets' => ['boolean'],
|
|
||||||
'minimum_use_date' => ['nullable', 'date'],
|
|
||||||
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Catalog\Models\AttributeOption
|
|
||||||
*/
|
|
||||||
class AttributeOptionResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'value' => $this->value,
|
|
||||||
'label' => $this->label,
|
|
||||||
'sort_order' => $this->sort_order,
|
|
||||||
'metadata' => $this->metadata,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Catalog\Models\Attribute
|
|
||||||
*/
|
|
||||||
class AttributeResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'codigo' => $this->codigo,
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'is_required' => $this->is_required,
|
|
||||||
'metadata_schema' => $this->metadata_schema,
|
|
||||||
'type' => $this->type?->value ?? $this->type,
|
|
||||||
'options' => AttributeOptionResource::collection($this->whenLoaded('options')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Catalog\Models\Brand
|
|
||||||
*/
|
|
||||||
class BrandResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'descripcion' => $this->descripcion,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,67 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\GroupItem;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedGroup */
|
||||||
class CatalogFeaturedGroupResource extends JsonResource
|
class CatalogFeaturedGroupResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $itemsPage */
|
||||||
|
public function __construct($resource, private readonly array $itemsPage)
|
||||||
|
{
|
||||||
|
parent::__construct($resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'title' => $this->group_name,
|
'title' => $this->group_name,
|
||||||
'layout' => $this->product_layout,
|
'layout' => $this->product_layout->value,
|
||||||
'group_order' => $this->group_order,
|
'group_order' => $this->group_order,
|
||||||
'items' => $this->whenLoaded('groupItems', function () use ($request) {
|
'items' => $this->itemsPage,
|
||||||
return $this->groupItems->map(function (GroupItem $groupItem) use ($request) {
|
|
||||||
$groupable = $groupItem->groupable;
|
|
||||||
|
|
||||||
if ($groupable instanceof Bundle) {
|
|
||||||
return [
|
|
||||||
'id' => $groupable->id,
|
|
||||||
'groupable_type' => 'bundle',
|
|
||||||
'groupable_id' => $groupable->id,
|
|
||||||
'nombre' => $groupable->nombre,
|
|
||||||
'descripcion' => $groupable->descripcion,
|
|
||||||
'precio' => $groupable->precio,
|
|
||||||
'cantidad_maxima' => $groupable->stock_tecnico,
|
|
||||||
'items' => $groupable->items->map(fn ($item) => [
|
|
||||||
'cantidad' => $item->cantidad,
|
|
||||||
'variant' => (new ProductVariantResource($item->variant))->toArray($request),
|
|
||||||
])->values(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $groupable instanceof ProductVariant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variantResource = (new ProductVariantResource($groupable))->toArray($request);
|
|
||||||
$variantResource['groupable_type'] = 'variant';
|
|
||||||
$variantResource['groupable_id'] = $groupable->id;
|
|
||||||
|
|
||||||
if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') {
|
|
||||||
// Devuelve el Product Variant Resource completo como pidio el usuario
|
|
||||||
// "Que todo devuelva el product variant resource"
|
|
||||||
return $variantResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para column_with_image o vertical_with_image
|
|
||||||
if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') {
|
|
||||||
if (isset($variantResource['images']) && count($variantResource['images']) > 0) {
|
|
||||||
$variantResource['images'] = [$variantResource['images'][0]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variantResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variantResource;
|
|
||||||
})->filter()->values();
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedItem */
|
||||||
|
class CatalogFeaturedItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$catalogItem = $this->catalogItem;
|
||||||
|
|
||||||
|
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
||||||
|
return $this->columnWithImageData($catalogItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $catalogItem->id,
|
||||||
|
'type' => $catalogItem->type->value,
|
||||||
|
'nombre' => $catalogItem->nombre,
|
||||||
|
'descripcion' => $catalogItem->descripcion,
|
||||||
|
'precio' => $catalogItem->precio,
|
||||||
|
'stock_tecnico' => $catalogItem->availableStock(),
|
||||||
|
'variants' => $catalogItem->variants
|
||||||
|
->map(fn (Variant $variant): array => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $variant->inventory->availableStock(),
|
||||||
|
'values' => $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition) => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn ($value, $key): bool => $key !== null),
|
||||||
|
])
|
||||||
|
->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function columnWithImageData(CatalogItem $catalogItem): array
|
||||||
|
{
|
||||||
|
$attachment = $catalogItem->attachments->first()
|
||||||
|
?? $catalogItem->variants
|
||||||
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $catalogItem->id,
|
||||||
|
'type' => $catalogItem->type->value,
|
||||||
|
'nombre' => $catalogItem->nombre,
|
||||||
|
'precio' => $catalogItem->precio,
|
||||||
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Domains/Catalog/Resources/CatalogItemDetailResource.php
Normal file
136
app/Domains/Catalog/Resources/CatalogItemDetailResource.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class CatalogItemDetailResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
/** @var Variant|null $selectedVariant */
|
||||||
|
$selectedVariant = $this->resource->getRelation('selectedVariant');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'brand_id' => $this->brand_id,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'precio' => $this->precio,
|
||||||
|
'category' => $this->category?->nombre,
|
||||||
|
'brand' => $this->brand?->nombre,
|
||||||
|
'inventory_policy' => $this->inventory_policy?->value,
|
||||||
|
'has_tickets' => $this->has_tickets,
|
||||||
|
'minimum_use_date' => $this->minimum_use_date,
|
||||||
|
'maximum_use_date' => $this->maximum_use_date,
|
||||||
|
'attributes' => $this->itemAttributes
|
||||||
|
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
|
||||||
|
->values(),
|
||||||
|
'stock_tecnico' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->availableStock(),
|
||||||
|
),
|
||||||
|
'images' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->imageUrls($this->attachments),
|
||||||
|
),
|
||||||
|
'variants' => $this->variants
|
||||||
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
|
->values(),
|
||||||
|
'selected_variant' => $this->when(
|
||||||
|
$selectedVariant !== null,
|
||||||
|
fn (): array => [
|
||||||
|
...$this->variantData($selectedVariant),
|
||||||
|
'images' => $this->imageUrls($selectedVariant->attachments),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'components' => $this->when(
|
||||||
|
$this->isBundle(),
|
||||||
|
fn () => $this->bundleComponents
|
||||||
|
->map(function ($component): array {
|
||||||
|
$selectedItem = $component->variant ?? $component->catalogItem;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'catalog_item_id' => $component->component_catalog_item_id,
|
||||||
|
'variant_id' => $component->component_variant_id,
|
||||||
|
'quantity' => $component->quantity,
|
||||||
|
'nombre' => $component->catalogItem->nombre,
|
||||||
|
'item_nombre' => $selectedItem->getName(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function attributeData(ItemAttribute $itemAttribute): array
|
||||||
|
{
|
||||||
|
$attribute = $itemAttribute->attribute;
|
||||||
|
$availableValues = $this->variants
|
||||||
|
->flatMap->definitions
|
||||||
|
->where('item_attribute_id', $itemAttribute->id)
|
||||||
|
->pluck('value')
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $attribute->id,
|
||||||
|
'codigo' => $attribute->codigo,
|
||||||
|
'nombre' => $attribute->nombre,
|
||||||
|
'is_required' => $attribute->is_required,
|
||||||
|
'metadata_schema' => $attribute->metadata_schema,
|
||||||
|
'type' => $attribute->type->value,
|
||||||
|
'options' => $attribute->options
|
||||||
|
->whereIn('value', $availableValues)
|
||||||
|
->map(fn ($option): array => [
|
||||||
|
'id' => $option->id,
|
||||||
|
'value' => $option->value,
|
||||||
|
'label' => $option->label,
|
||||||
|
'sort_order' => $option->sort_order,
|
||||||
|
'metadata' => $option->metadata,
|
||||||
|
])
|
||||||
|
->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function variantData(Variant $variant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock_tecnico' => $this->variantStock($variant),
|
||||||
|
'values' => $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition) => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn ($value, $key): bool => $key !== null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, string> */
|
||||||
|
private function imageUrls(Collection $attachments): Collection
|
||||||
|
{
|
||||||
|
return $attachments
|
||||||
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function variantStock(Variant $variant): ?int
|
||||||
|
{
|
||||||
|
return $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $variant->inventory->availableStock();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Domains/Catalog/Resources/CatalogItemResource.php
Normal file
48
app/Domains/Catalog/Resources/CatalogItemResource.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class CatalogItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'brand_id' => $this->brand_id,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'precio' => $this->precio,
|
||||||
|
'inventory_policy' => $this->inventory_policy?->value,
|
||||||
|
'has_tickets' => $this->has_tickets,
|
||||||
|
'minimum_use_date' => $this->minimum_use_date,
|
||||||
|
'maximum_use_date' => $this->maximum_use_date,
|
||||||
|
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
|
||||||
|
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
|
||||||
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
|
->values()),
|
||||||
|
'variants' => $this->whenLoaded('variants', fn () => $this->variants
|
||||||
|
->map(fn ($variant) => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'real_stock' => $variant->inventory?->real_stock,
|
||||||
|
'values' => $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition) => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn ($value, $key) => $key !== null),
|
||||||
|
'images' => $variant->attachments
|
||||||
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
|
->values(),
|
||||||
|
])
|
||||||
|
->values()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Catalog\Models\Category
|
|
||||||
*/
|
|
||||||
class CategoryResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'is_global' => $this->resource->isGlobal(),
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'parent' => $this->whenLoaded('parent', fn () => $this->serializeRelatedCategory($this->parent)),
|
|
||||||
'sub_categories' => $this->whenLoaded('subCategories', fn () =>
|
|
||||||
$this->subCategories->map(fn ($category) => $this->serializeRelatedCategory($category))->values()
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
protected function serializeRelatedCategory(?\App\Domains\Catalog\Models\Category $category): ?array
|
|
||||||
{
|
|
||||||
if ($category === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $category->id,
|
|
||||||
'is_global' => $category->isGlobal(),
|
|
||||||
'nombre' => $category->nombre,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class FeaturedGroupResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'tenant_codigo' => $this->tenant_codigo,
|
|
||||||
'group_name' => $this->group_name,
|
|
||||||
'product_layout' => $this->product_layout,
|
|
||||||
'group_order' => $this->group_order,
|
|
||||||
'group_items' => GroupItemResource::collection($this->whenLoaded('groupItems')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class GroupItemResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'featured_group_id' => $this->featured_group_id,
|
|
||||||
'groupable_type' => match ($this->groupable_type) {
|
|
||||||
ProductVariant::class => 'variant',
|
|
||||||
Bundle::class => 'bundle',
|
|
||||||
default => 'unknown',
|
|
||||||
},
|
|
||||||
'groupable_id' => $this->groupable_id,
|
|
||||||
'order' => $this->order,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin Product
|
|
||||||
*/
|
|
||||||
class ProductResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'category_id' => $this->categoria_id,
|
|
||||||
'brand_id' => $this->brand_id,
|
|
||||||
'slug' => $this->slug,
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'descripcion' => $this->descripcion,
|
|
||||||
'precio' => $this->precio,
|
|
||||||
'category' => $this->whenLoaded('category', fn () => $this->category?->nombre),
|
|
||||||
'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre),
|
|
||||||
|
|
||||||
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
|
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
|
||||||
->values()
|
|
||||||
),
|
|
||||||
'attributes' => AttributeResource::collection($this->whenLoaded('attributes')),
|
|
||||||
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
|
|
||||||
->map(fn ($variant) => [
|
|
||||||
'variant_id' => $variant->id,
|
|
||||||
'inventory_policy' => $variant->inventory_policy->value,
|
|
||||||
'cantidad_maxima' => $variant->stock_tecnico,
|
|
||||||
'cantidad_vendida' => $variant->cantidad_vendida,
|
|
||||||
'attributes' => $variant->definitions
|
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key) => $key !== null)
|
|
||||||
->toArray(),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
),
|
|
||||||
'variant' => $this->when(
|
|
||||||
$this->getSelectedVariant() !== null,
|
|
||||||
fn () => ProductVariantResource::make($this->getSelectedVariant())
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
|
|
||||||
*/
|
|
||||||
class ProductVariantDefinitionResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'producto_variante_id' => $this->producto_variante_id,
|
|
||||||
'products_attribute_id' => $this->products_attribute_id,
|
|
||||||
'attribute_id' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute_id),
|
|
||||||
'value' => $this->value,
|
|
||||||
'attribute' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute?->nombre),
|
|
||||||
'metadata' => $this->resolveMetadata(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
protected function resolveMetadata(): ?array
|
|
||||||
{
|
|
||||||
if (! $this->relationLoaded('productAttribute')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attribute = $this->productAttribute?->attribute;
|
|
||||||
|
|
||||||
if (! $attribute?->relationLoaded('options')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$option = $attribute->options->firstWhere('value', $this->value);
|
|
||||||
|
|
||||||
if ($option === null || $option->metadata === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $option->metadata;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin ProductVariant
|
|
||||||
*/
|
|
||||||
class ProductVariantResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'inventory_policy' => $this->inventory_policy->value,
|
|
||||||
'cantidad_maxima' => $this->stock_tecnico,
|
|
||||||
'cantidad_vendida' => $this->cantidad_vendida,
|
|
||||||
'has_tickets' => $this->has_tickets,
|
|
||||||
'minimum_use_date' => $this->minimum_use_date,
|
|
||||||
'maximum_use_date' => $this->maximum_use_date,
|
|
||||||
'product' => ProductResource::make($this->whenLoaded('product')),
|
|
||||||
'definitions' => $this->whenLoaded(
|
|
||||||
'definitions',
|
|
||||||
fn () => $this->definitions
|
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->productAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key) => $key !== null)
|
|
||||||
->toArray()
|
|
||||||
),
|
|
||||||
'images' => $this->whenLoaded('attachments', function () {
|
|
||||||
if ($this->attachments->isNotEmpty()) {
|
|
||||||
return $this->attachments
|
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->relationLoaded('fallbackAttachments')) {
|
|
||||||
return $this->fallbackAttachments
|
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->relationLoaded('product') && $this->product?->relationLoaded('attachments')) {
|
|
||||||
return $this->product->attachments
|
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
218
app/Domains/Catalog/Services/CatalogInventoryService.php
Normal file
218
app/Domains/Catalog/Services/CatalogInventoryService.php
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\BundleComponent;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CatalogInventoryService
|
||||||
|
{
|
||||||
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
|
{
|
||||||
|
if ($selection instanceof CatalogItem
|
||||||
|
&& $selection->type === CatalogItemType::Standard
|
||||||
|
&& $selection->relationLoaded('inventory')
|
||||||
|
&& $selection->inventory !== null) {
|
||||||
|
return $selection->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $selection->inventory->availableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selection instanceof CatalogItem
|
||||||
|
&& $selection->type === CatalogItemType::Standard
|
||||||
|
&& $selection->inventory_id === null) {
|
||||||
|
if ($selection->inventory_policy === InventoryPolicy::Unlimited) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection->loadMissing('variants.inventory');
|
||||||
|
|
||||||
|
return $selection->variants->sum(
|
||||||
|
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements = $this->inventoryRequirements($selection);
|
||||||
|
$trackedRequirements = array_filter(
|
||||||
|
$requirements,
|
||||||
|
fn (array $requirement): bool => $requirement['tracks_inventory'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($trackedRequirements === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventories = Inventory::query()
|
||||||
|
->whereKey(array_keys($trackedRequirements))
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
$available = [];
|
||||||
|
|
||||||
|
foreach ($trackedRequirements as $inventoryId => $requirement) {
|
||||||
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||||
|
$available[] = intdiv(
|
||||||
|
$inventory->availableStock(),
|
||||||
|
$requirement['quantity'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reserve(CatalogItem|Variant $selection, int $quantity): void
|
||||||
|
{
|
||||||
|
$this->mutate($selection, $quantity, 'reserve');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function release(CatalogItem|Variant $selection, int $quantity): void
|
||||||
|
{
|
||||||
|
$this->mutate($selection, $quantity, 'release');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(CatalogItem|Variant $selection, int $quantity): void
|
||||||
|
{
|
||||||
|
$this->mutate($selection, $quantity, 'commit');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mutate(
|
||||||
|
CatalogItem|Variant $selection,
|
||||||
|
int $quantity,
|
||||||
|
string $operation,
|
||||||
|
): void {
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($selection, $quantity, $operation): void {
|
||||||
|
$requirements = $this->inventoryRequirements($selection);
|
||||||
|
ksort($requirements);
|
||||||
|
$inventories = Inventory::query()
|
||||||
|
->whereKey(array_keys($requirements))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($requirements as $inventoryId => $requirement) {
|
||||||
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
|
||||||
|
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||||
|
|
||||||
|
if ($operation === 'reserve'
|
||||||
|
&& $requirement['tracks_inventory']
|
||||||
|
&& $inventory->availableStock() < $requiredQuantity) {
|
||||||
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($operation, ['release', 'commit'], true)
|
||||||
|
&& $inventory->reserved_stock < $requiredQuantity) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad reservada no alcanza para la operacion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operation === 'commit'
|
||||||
|
&& $requirement['tracks_inventory']
|
||||||
|
&& $inventory->real_stock < $requiredQuantity) {
|
||||||
|
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requirements as $inventoryId => $requirement) {
|
||||||
|
/** @var Inventory $inventory */
|
||||||
|
$inventory = $inventories->get($inventoryId);
|
||||||
|
$requiredQuantity = $requirement['quantity'] * $quantity;
|
||||||
|
|
||||||
|
match ($operation) {
|
||||||
|
'reserve' => $inventory->reserve($requiredQuantity, $requirement['tracks_inventory']),
|
||||||
|
'release' => $inventory->release($requiredQuantity),
|
||||||
|
'commit' => $inventory->buy($requiredQuantity, $requirement['tracks_inventory']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
private function inventoryRequirements(CatalogItem|Variant $selection): array
|
||||||
|
{
|
||||||
|
if ($selection instanceof Variant) {
|
||||||
|
$selection->loadMissing('catalogItem');
|
||||||
|
|
||||||
|
return $this->singleRequirement(
|
||||||
|
$selection->inventory_id,
|
||||||
|
$selection->catalogItem->inventory_policy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selection->type !== CatalogItemType::Bundle) {
|
||||||
|
return $this->singleRequirement(
|
||||||
|
$selection->inventory_id,
|
||||||
|
$selection->inventory_policy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection->loadMissing([
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
$requirements = [];
|
||||||
|
|
||||||
|
foreach ($selection->bundleComponents as $component) {
|
||||||
|
$this->addComponentRequirement($requirements, $component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requirements === []) {
|
||||||
|
throw new \InvalidArgumentException('El bundle no tiene componentes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{quantity: int, tracks_inventory: bool}> */
|
||||||
|
private function singleRequirement(
|
||||||
|
?int $inventoryId,
|
||||||
|
?InventoryPolicy $policy,
|
||||||
|
): array {
|
||||||
|
if ($inventoryId === null || $policy === null) {
|
||||||
|
throw new \InvalidArgumentException('El item requiere una variante con inventario.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$inventoryId => [
|
||||||
|
'quantity' => 1,
|
||||||
|
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{quantity: int, tracks_inventory: bool}> $requirements
|
||||||
|
*/
|
||||||
|
private function addComponentRequirement(array &$requirements, BundleComponent $component): void
|
||||||
|
{
|
||||||
|
$selectedItem = $component->variant ?? $component->catalogItem;
|
||||||
|
$inventoryId = $selectedItem->inventory_id;
|
||||||
|
$policy = $component->catalogItem->inventory_policy;
|
||||||
|
|
||||||
|
if ($inventoryId === null || $policy === null) {
|
||||||
|
throw new \InvalidArgumentException('Un componente del bundle no tiene inventario.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($requirements[$inventoryId])) {
|
||||||
|
$requirements[$inventoryId]['quantity'] += $component->quantity;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements[$inventoryId] = [
|
||||||
|
'quantity' => $component->quantity,
|
||||||
|
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
469
app/Domains/Catalog/Services/CatalogService.php
Normal file
469
app/Domains/Catalog/Services/CatalogService.php
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class CatalogService
|
||||||
|
{
|
||||||
|
public function __construct(protected AttachmentService $attachmentService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function create(array $data): CatalogItem
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($data): CatalogItem {
|
||||||
|
$type = CatalogItemType::from(
|
||||||
|
$data['type'] ?? CatalogItemType::Standard->value,
|
||||||
|
);
|
||||||
|
$variants = $data['variants'] ?? [];
|
||||||
|
$images = $data['images'] ?? [];
|
||||||
|
$attributeCodes = $data['attribute_codes'] ?? [];
|
||||||
|
$components = $data['components'] ?? [];
|
||||||
|
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||||
|
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||||
|
|
||||||
|
$hasVariants = $attributeCodes !== [];
|
||||||
|
|
||||||
|
if ($type === CatalogItemType::Bundle) {
|
||||||
|
$this->validateBundleData($data, $components);
|
||||||
|
} else {
|
||||||
|
if (array_key_exists('components', $data)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'components' => ['Un item standard no puede tener componentes.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateInventoryStrategy(
|
||||||
|
$data,
|
||||||
|
$variants,
|
||||||
|
$hasVariants,
|
||||||
|
$hasDirectStock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unset(
|
||||||
|
$data['variants'],
|
||||||
|
$data['images'],
|
||||||
|
$data['attribute_codes'],
|
||||||
|
$data['components'],
|
||||||
|
$data['real_stock'],
|
||||||
|
$data['reserved_stock'],
|
||||||
|
$data['sold_units'],
|
||||||
|
$data['inventory_id'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$data['type'] = $type;
|
||||||
|
|
||||||
|
if ($type === CatalogItemType::Bundle) {
|
||||||
|
$data['inventory_id'] = null;
|
||||||
|
$data['inventory_policy'] = null;
|
||||||
|
$data['has_tickets'] = false;
|
||||||
|
$data['minimum_use_date'] = null;
|
||||||
|
$data['maximum_use_date'] = null;
|
||||||
|
} elseif ($hasVariants) {
|
||||||
|
$data['inventory_id'] = null;
|
||||||
|
} else {
|
||||||
|
$data['inventory_id'] = $this->createInventory($realStock)->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem = CatalogItem::query()->create($data);
|
||||||
|
$itemAttributes = $type === CatalogItemType::Standard
|
||||||
|
? $this->createItemAttributes($catalogItem, $attributeCodes)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if ($type === CatalogItemType::Bundle) {
|
||||||
|
$this->createBundleComponents($catalogItem, $components);
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdVariants = [];
|
||||||
|
foreach ($variants as $index => $variantData) {
|
||||||
|
$createdVariants[] = [
|
||||||
|
'variant' => $this->createVariant(
|
||||||
|
$catalogItem,
|
||||||
|
$variantData,
|
||||||
|
$itemAttributes,
|
||||||
|
$index,
|
||||||
|
),
|
||||||
|
'images' => $variantData['images'] ?? [],
|
||||||
|
'index' => $index,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->attachImages($catalogItem, $images, 'images');
|
||||||
|
|
||||||
|
foreach ($createdVariants as $createdVariant) {
|
||||||
|
$this->attachImages(
|
||||||
|
$createdVariant['variant'],
|
||||||
|
$createdVariant['images'],
|
||||||
|
"variants.{$createdVariant['index']}.images",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $catalogItem->load([
|
||||||
|
'attachments',
|
||||||
|
'inventory',
|
||||||
|
'category',
|
||||||
|
'brand',
|
||||||
|
'itemAttributes.attribute',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
|
||||||
|
{
|
||||||
|
$catalogItem->load([
|
||||||
|
'attachments',
|
||||||
|
'inventory',
|
||||||
|
'category',
|
||||||
|
'brand',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants' => fn ($query) => $query->orderBy('id'),
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem.inventory',
|
||||||
|
'bundleComponents.variant.inventory',
|
||||||
|
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$selectedVariant = $variantId === null
|
||||||
|
? $catalogItem->variants->first()
|
||||||
|
: $catalogItem->variants->firstWhere('id', $variantId);
|
||||||
|
|
||||||
|
if ($variantId !== null && $selectedVariant === null) {
|
||||||
|
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem->setRelation('selectedVariant', $selectedVariant);
|
||||||
|
|
||||||
|
return $catalogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(CatalogItem $catalogItem): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($catalogItem): void {
|
||||||
|
$catalogItem->load([
|
||||||
|
'attachments',
|
||||||
|
'variants.attachments',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attachments = $catalogItem->attachments
|
||||||
|
->merge($catalogItem->variants->flatMap->attachments)
|
||||||
|
->unique('id');
|
||||||
|
$inventoryIds = collect([$catalogItem->inventory_id])
|
||||||
|
->merge($catalogItem->variants->pluck('inventory_id'))
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
$catalogItem->attachments()->detach();
|
||||||
|
foreach ($catalogItem->variants as $variant) {
|
||||||
|
$variant->attachments()->detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem->delete();
|
||||||
|
Inventory::query()->whereKey($inventoryIds)->delete();
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
|
||||||
|
$this->attachmentService->delete($attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createInventory(int $realStock): Inventory
|
||||||
|
{
|
||||||
|
return Inventory::query()->create([
|
||||||
|
'real_stock' => $realStock,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $components
|
||||||
|
*/
|
||||||
|
private function createBundleComponents(CatalogItem $bundle, array $components): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach (array_values($components) as $index => $componentData) {
|
||||||
|
$catalogItemId = (int) $componentData['catalog_item_id'];
|
||||||
|
$variantId = isset($componentData['variant_id'])
|
||||||
|
? (int) $componentData['variant_id']
|
||||||
|
: null;
|
||||||
|
$key = $catalogItemId.':'.($variantId ?? 'direct');
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}" => ['El componente esta duplicado.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
|
||||||
|
$componentItem = CatalogItem::query()
|
||||||
|
->whereKey($catalogItemId)
|
||||||
|
->where('tenant_code', $bundle->tenant_code)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($componentItem === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}.catalog_item_id" => [
|
||||||
|
'El item no pertenece al tenant del bundle.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}.catalog_item_id" => [
|
||||||
|
'El componente debe ser un item standard distinto del bundle.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasVariants = $componentItem->variants()->exists();
|
||||||
|
if ($hasVariants && $variantId === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}.variant_id" => [
|
||||||
|
'Debe seleccionar una variante para este componente.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasVariants && $variantId !== null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}.variant_id" => [
|
||||||
|
'El componente con inventario directo no admite una variante.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"components.{$index}.variant_id" => [
|
||||||
|
'La variante no pertenece al componente indicado.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bundle->bundleComponents()->create([
|
||||||
|
'component_catalog_item_id' => $componentItem->id,
|
||||||
|
'component_variant_id' => $variantId,
|
||||||
|
'quantity' => (int) $componentData['quantity'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<int, array<string, mixed>> $components
|
||||||
|
*/
|
||||||
|
private function validateBundleData(array $data, array $components): void
|
||||||
|
{
|
||||||
|
if ($components === []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'components' => ['Un bundle debe tener al menos un componente.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'real_stock',
|
||||||
|
'inventory_policy',
|
||||||
|
'attribute_codes',
|
||||||
|
'variants',
|
||||||
|
'has_tickets',
|
||||||
|
'minimum_use_date',
|
||||||
|
'maximum_use_date',
|
||||||
|
] as $field) {
|
||||||
|
if (array_key_exists($field, $data)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$field => ["{$field} no se admite para un bundle."],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $images
|
||||||
|
*/
|
||||||
|
private function attachImages(
|
||||||
|
CatalogItem|Variant $owner,
|
||||||
|
array $images,
|
||||||
|
string $validationKey,
|
||||||
|
): void {
|
||||||
|
foreach (array_values($images) as $order => $image) {
|
||||||
|
$attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}");
|
||||||
|
|
||||||
|
$owner->attachments()->attach($attachment->id, ['orden' => $order]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAttachment(mixed $image, string $validationKey): Attachment
|
||||||
|
{
|
||||||
|
if (is_string($image) && Str::isUuid($image)) {
|
||||||
|
$attachment = Attachment::query()->where('key', $image)->first();
|
||||||
|
|
||||||
|
if ($attachment === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => ['El attachment indicado no existe.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->attachmentService->store($image, 'catalog-items');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $attributeCodes
|
||||||
|
* @return array<string, ItemAttribute>
|
||||||
|
*/
|
||||||
|
private function createItemAttributes(
|
||||||
|
CatalogItem $catalogItem,
|
||||||
|
array $attributeCodes,
|
||||||
|
): array {
|
||||||
|
$itemAttributes = [];
|
||||||
|
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||||
|
$attributes = Attribute::query()
|
||||||
|
->where('tenant_codigo', $catalogItem->tenant_code)
|
||||||
|
->whereIn('codigo', $attributeCodes)
|
||||||
|
->get()
|
||||||
|
->keyBy('codigo');
|
||||||
|
|
||||||
|
foreach ($attributeCodes as $attributeCode) {
|
||||||
|
$attribute = $attributes->get($attributeCode);
|
||||||
|
|
||||||
|
if ($attribute === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'attribute_codes' => [
|
||||||
|
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemAttribute = $catalogItem->itemAttributes()->create([
|
||||||
|
'attribute_id' => $attribute->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemAttributes[$attributeCode] = $itemAttribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $itemAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, ItemAttribute> $itemAttributes
|
||||||
|
*/
|
||||||
|
private function createVariant(
|
||||||
|
CatalogItem $catalogItem,
|
||||||
|
array $data,
|
||||||
|
array $itemAttributes,
|
||||||
|
int $index,
|
||||||
|
): Variant {
|
||||||
|
unset($data['images']);
|
||||||
|
|
||||||
|
if (
|
||||||
|
array_key_exists('inventory_id', $data)
|
||||||
|
|| array_key_exists('reserved_stock', $data)
|
||||||
|
|| array_key_exists('sold_units', $data)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.inventory" => [
|
||||||
|
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||||
|
$variant = $catalogItem->variants()->create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||||
|
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||||
|
|
||||||
|
if ($itemAttribute === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.values.{$attributeCode}" => [
|
||||||
|
'El atributo no pertenece al ítem de catálogo.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->definitions()->create([
|
||||||
|
'item_attribute_id' => $itemAttribute->id,
|
||||||
|
'value' => $value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
*/
|
||||||
|
private function validateInventoryStrategy(
|
||||||
|
array $data,
|
||||||
|
array $variants,
|
||||||
|
bool $hasVariants,
|
||||||
|
bool $hasDirectStock,
|
||||||
|
): void {
|
||||||
|
if ($hasVariants && $variants === []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variants' => [
|
||||||
|
'Un ítem con attribute_codes debe tener variantes.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasVariants && $variants !== []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variants' => [
|
||||||
|
'Un ítem sin attribute_codes no puede tener variantes.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasVariants && $hasDirectStock) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'real_stock' => [
|
||||||
|
'Un ítem con variantes no puede tener inventario directo.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
array_key_exists('inventory_id', $data)
|
||||||
|
|| array_key_exists('reserved_stock', $data)
|
||||||
|
|| array_key_exists('sold_units', $data)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'inventory' => [
|
||||||
|
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
|
|
||||||
class FeaturedGroupService
|
|
||||||
{
|
|
||||||
public function createGroup(string $tenantCodigo, array $data): FeaturedGroup
|
|
||||||
{
|
|
||||||
$data['tenant_codigo'] = $tenantCodigo;
|
|
||||||
return FeaturedGroup::create($data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateGroup(FeaturedGroup $group, array $data): FeaturedGroup
|
|
||||||
{
|
|
||||||
$group->update($data);
|
|
||||||
return $group;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteGroup(FeaturedGroup $group): bool|null
|
|
||||||
{
|
|
||||||
return $group->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Attachable\Services\AttachmentService;
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class ProductService
|
|
||||||
{
|
|
||||||
public function __construct(protected AttachmentService $attachmentService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a product.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public function create(Tenant $tenant, array $data): Product
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($tenant, $data) {
|
|
||||||
$attributeIds = $data['attribute_ids'] ?? [];
|
|
||||||
$images = $data['images'] ?? [];
|
|
||||||
$stock = $data['stock'] ?? 0;
|
|
||||||
$inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value;
|
|
||||||
unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']);
|
|
||||||
|
|
||||||
/** @var Product $product */
|
|
||||||
$product = Product::query()->create([
|
|
||||||
...$data,
|
|
||||||
'tenant_codigo' => $tenant->codigo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$product->attributes()->sync($attributeIds);
|
|
||||||
|
|
||||||
if (! empty($images)) {
|
|
||||||
$this->syncProductImages($product, $images);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create default variant with stock
|
|
||||||
$this->createVariant($product, [
|
|
||||||
'stock' => $stock,
|
|
||||||
'inventory_policy' => $inventoryPolicy,
|
|
||||||
'is_placeholder' => true,
|
|
||||||
'definitions' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a product.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public function update(Product $product, array $data): Product
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($product, $data) {
|
|
||||||
$hasAttributeIds = array_key_exists('attribute_ids', $data);
|
|
||||||
$attributeIds = $data['attribute_ids'] ?? [];
|
|
||||||
$hasImages = array_key_exists('images', $data);
|
|
||||||
$images = $data['images'] ?? [];
|
|
||||||
unset($data['attribute_ids'], $data['images']);
|
|
||||||
|
|
||||||
// Ensure tenant_codigo cannot be updated/changed
|
|
||||||
unset($data['tenant_codigo']);
|
|
||||||
|
|
||||||
$product->update($data);
|
|
||||||
|
|
||||||
if ($hasAttributeIds) {
|
|
||||||
$product->attributes()->sync($attributeIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasImages) {
|
|
||||||
$this->syncProductImages($product, $images);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product.
|
|
||||||
*/
|
|
||||||
public function delete(Product $product): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($product) {
|
|
||||||
foreach ($product->variants as $variant) {
|
|
||||||
$this->deleteVariantAttachments($variant);
|
|
||||||
$product->deleteVariant($variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete product-level attachments from S3 and database
|
|
||||||
$existing = $product->attachments()->get();
|
|
||||||
$product->attachments()->detach();
|
|
||||||
foreach ($existing as $attachment) {
|
|
||||||
$this->attachmentService->delete($attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
$product->attributes()->detach();
|
|
||||||
$product->delete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a product variant.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public function createVariant(Product $product, array $data): ProductVariant
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($product, $data) {
|
|
||||||
$images = $data['images'] ?? [];
|
|
||||||
unset($data['images']);
|
|
||||||
|
|
||||||
// Determine if the variant being created is a placeholder one
|
|
||||||
$hasDefinitions = ! empty($data['definitions']);
|
|
||||||
$isPlaceholder = $data['is_placeholder'] ?? (! $hasDefinitions);
|
|
||||||
$data['is_placeholder'] = $isPlaceholder;
|
|
||||||
|
|
||||||
// Remove any existing placeholder variants
|
|
||||||
$defaultVariants = $product->variants()->where('is_placeholder', true)->get();
|
|
||||||
foreach ($defaultVariants as $defaultVariant) {
|
|
||||||
$this->deleteVariantAttachments($defaultVariant);
|
|
||||||
$product->deleteVariant($defaultVariant);
|
|
||||||
}
|
|
||||||
|
|
||||||
$variant = $product->createVariant($data);
|
|
||||||
|
|
||||||
if (! empty($images)) {
|
|
||||||
$this->syncVariantImages($variant, $images);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a product variant.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($variant, $data) {
|
|
||||||
$hasImages = array_key_exists('images', $data);
|
|
||||||
$images = $data['images'] ?? [];
|
|
||||||
unset($data['images']);
|
|
||||||
|
|
||||||
/** @var Product $product */
|
|
||||||
$product = $variant->product;
|
|
||||||
$updatedVariant = $product->updateVariant($variant, $data);
|
|
||||||
|
|
||||||
if ($hasImages) {
|
|
||||||
$this->syncVariantImages($updatedVariant, $images);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updatedVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a product variant.
|
|
||||||
*/
|
|
||||||
public function deleteVariant(ProductVariant $variant): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($variant) {
|
|
||||||
/** @var Product $product */
|
|
||||||
$product = $variant->product;
|
|
||||||
$this->deleteVariantAttachments($variant);
|
|
||||||
$product->deleteVariant($variant);
|
|
||||||
|
|
||||||
// Re-create a default variant with stock 0 if it has no variants left
|
|
||||||
if ($product->variants()->count() === 0) {
|
|
||||||
$product->createVariant([
|
|
||||||
'stock' => 0,
|
|
||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
|
||||||
'is_placeholder' => true,
|
|
||||||
'definitions' => [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an attribute.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function createAttribute(Tenant $tenant, array $data): Attribute
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($tenant, $data) {
|
|
||||||
return Product::createAttribute($tenant, $data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an attribute.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function updateAttribute(Attribute $attribute, array $data): Attribute
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($attribute, $data) {
|
|
||||||
return Product::updateAttribute($attribute, $data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a list of image files/base64 strings and sync them to a variant.
|
|
||||||
*
|
|
||||||
* When called on update, the existing attachments are detached first so the
|
|
||||||
* final set always matches exactly what was sent in the request.
|
|
||||||
*
|
|
||||||
* @param array<int, UploadedFile|string> $images
|
|
||||||
*/
|
|
||||||
protected function syncVariantImages(ProductVariant $variant, array $images): void
|
|
||||||
{
|
|
||||||
$this->deleteVariantAttachments($variant);
|
|
||||||
|
|
||||||
$attachmentIds = [];
|
|
||||||
|
|
||||||
foreach ($images as $image) {
|
|
||||||
$attachment = $this->attachmentService->store($image, 'variants');
|
|
||||||
$attachmentIds[] = $attachment->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variant->attachments()->sync($attachmentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a list of image files/base64 strings and sync them to a product.
|
|
||||||
*
|
|
||||||
* Same logic as syncVariantImages but for products without variants.
|
|
||||||
*
|
|
||||||
* @param array<int, UploadedFile|string> $images
|
|
||||||
*/
|
|
||||||
protected function syncProductImages(Product $product, array $images): void
|
|
||||||
{
|
|
||||||
// Detach pivot record and delete attachment from S3 and database
|
|
||||||
$existing = $product->attachments()->get();
|
|
||||||
$product->attachments()->detach();
|
|
||||||
foreach ($existing as $attachment) {
|
|
||||||
$this->attachmentService->delete($attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachmentIds = [];
|
|
||||||
|
|
||||||
foreach ($images as $image) {
|
|
||||||
$attachment = $this->attachmentService->store($image, 'products');
|
|
||||||
$attachmentIds[] = $attachment->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$product->attachments()->sync($attachmentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function deleteVariantAttachments(ProductVariant $variant): void
|
|
||||||
{
|
|
||||||
$existing = $variant->attachments()->get();
|
|
||||||
$variant->attachments()->detach();
|
|
||||||
|
|
||||||
foreach ($existing as $attachment) {
|
|
||||||
$this->attachmentService->delete($attachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an attribute.
|
|
||||||
*/
|
|
||||||
public static function deleteAttribute(Attribute $attribute): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($attribute) {
|
|
||||||
Product::deleteAttribute($attribute);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get products for a tenant with resolved first image (with fallback to first variant's first image).
|
|
||||||
*/
|
|
||||||
public function getProductos(Tenant $tenant): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$products = Product::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->with([
|
|
||||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
'brand',
|
|
||||||
'category',
|
|
||||||
'variants.attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
])
|
|
||||||
->latest()
|
|
||||||
->paginateFromRequest();
|
|
||||||
|
|
||||||
foreach ($products as $product) {
|
|
||||||
$resolvedAttachment = null;
|
|
||||||
if ($product->attachments->isNotEmpty()) {
|
|
||||||
$resolvedAttachment = $product->attachments->first();
|
|
||||||
} else {
|
|
||||||
$firstVariant = $product->variants->sortBy('id')->first();
|
|
||||||
if ($firstVariant && $firstVariant->attachments->isNotEmpty()) {
|
|
||||||
$resolvedAttachment = $firstVariant->attachments->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$product->setRelation('attachments', $resolvedAttachment ? collect([$resolvedAttachment]) : collect());
|
|
||||||
$product->unsetRelation('variants');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $products;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product
|
|
||||||
{
|
|
||||||
$product->load([
|
|
||||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
'attributes.options',
|
|
||||||
'brand',
|
|
||||||
'category',
|
|
||||||
'variants' => fn ($query) => $query->orderBy('id'),
|
|
||||||
'variants.definitions.productAttribute.attribute.options',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->filterProductDetailAttributeOptions($product);
|
|
||||||
|
|
||||||
$selectedVariant = $variantId !== null
|
|
||||||
? $product->variants->firstWhere('id', $variantId)
|
|
||||||
: $product->variants->first(fn (ProductVariant $variant) => $variant->isAvailableForSale());
|
|
||||||
|
|
||||||
if ($variantId !== null && $selectedVariant === null) {
|
|
||||||
throw new NotFoundHttpException('Product variant not found for product.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'variant_id' => 'La variante seleccionada no tiene stock.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectedVariant ??= $product->variants->first();
|
|
||||||
|
|
||||||
if ($selectedVariant !== null) {
|
|
||||||
$selectedVariant->load([
|
|
||||||
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
|
|
||||||
]);
|
|
||||||
$selectedVariant->setRelation('fallbackAttachments', $product->attachments);
|
|
||||||
$product->setSelectedVariant($selectedVariant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $product;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function filterProductDetailAttributeOptions(Product $product): void
|
|
||||||
{
|
|
||||||
$availableValuesByAttributeId = [];
|
|
||||||
|
|
||||||
foreach ($product->variants as $variant) {
|
|
||||||
foreach ($variant->definitions as $definition) {
|
|
||||||
$attributeId = $definition->productAttribute?->attribute_id;
|
|
||||||
|
|
||||||
if ($attributeId === null || $definition->value === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$availableValuesByAttributeId[$attributeId][$definition->value] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($product->attributes as $attribute) {
|
|
||||||
if (! $attribute->relationLoaded('options')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$availableValues = $availableValuesByAttributeId[$attribute->id] ?? [];
|
|
||||||
|
|
||||||
$attribute->setRelation(
|
|
||||||
'options',
|
|
||||||
$attribute->options
|
|
||||||
->filter(fn ($option): bool => array_key_exists($option->value, $availableValues))
|
|
||||||
->values()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Catalog\Controllers\AttributeController;
|
|
||||||
use App\Domains\Catalog\Controllers\BrandController;
|
|
||||||
use App\Domains\Catalog\Controllers\CatalogController;
|
use App\Domains\Catalog\Controllers\CatalogController;
|
||||||
use App\Domains\Catalog\Controllers\CategoryController;
|
|
||||||
use App\Domains\Catalog\Controllers\FeaturedGroupController;
|
|
||||||
use App\Domains\Catalog\Controllers\GroupItemController;
|
|
||||||
use App\Domains\Catalog\Controllers\ProductController;
|
|
||||||
use App\Domains\Catalog\Controllers\ProductVariantController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||||
Route::get('catalog', [CatalogController::class, 'index']);
|
Route::get('catalog', [CatalogController::class, 'index']);
|
||||||
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
|
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||||
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
|
->name('catalog.featured-groups.items.index');
|
||||||
Route::apiResource('productos', ProductController::class);
|
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||||
Route::apiResource('attributes', AttributeController::class);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
Route::apiResource('productos.variants', ProductVariantController::class)
|
|
||||||
->parameters([
|
|
||||||
'productos' => 'producto',
|
|
||||||
'variants' => 'productVariant',
|
|
||||||
]);
|
|
||||||
Route::apiResource('featured-groups', FeaturedGroupController::class);
|
|
||||||
Route::apiResource('featured-groups.items', GroupItemController::class)
|
|
||||||
->only(['index', 'store', 'update', 'destroy'])
|
|
||||||
->parameters([
|
|
||||||
'featured-groups' => 'featuredGroup',
|
|
||||||
'items' => 'groupItem',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,13 +37,15 @@ class TenantIntegrationController extends Controller
|
|||||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
|
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||||
$tenantCode,
|
$tenantCode,
|
||||||
$integration,
|
$integration,
|
||||||
$request->input('integration_data', [])
|
$request->input('integration_data', [])
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($tenantIntegration);
|
return response()->json([
|
||||||
|
'message' => 'integration configured correctly',
|
||||||
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Controllers;
|
namespace App\Domains\Purchase\Controllers;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||||
@@ -12,7 +10,6 @@ use App\Domains\Purchase\Resources\PurchaseResource;
|
|||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -53,10 +50,10 @@ class PurchaseController extends Controller
|
|||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
$compra->loadMissing(['items', 'cart.items']);
|
$compra->loadMissing(['items', 'cart.items']);
|
||||||
$this->loadBuyables($compra->items);
|
$compra->items->load('imageAttachment');
|
||||||
|
|
||||||
if ($compra->cart !== null) {
|
if ($compra->cart !== null) {
|
||||||
$this->loadBuyables($compra->cart->items);
|
$this->loadCartCatalogEntries($compra->cart->items);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PurchaseResource::make($compra);
|
return PurchaseResource::make($compra);
|
||||||
@@ -161,19 +158,13 @@ class PurchaseController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
protected function loadBuyables(Collection $items): void
|
protected function loadCartCatalogEntries(Collection $items): void
|
||||||
{
|
{
|
||||||
$items->load([
|
$items->load([
|
||||||
'buyable' => function (MorphTo $morphTo): void {
|
'catalogItem.attachments',
|
||||||
$morphTo->morphWith([
|
'variant.attachments',
|
||||||
ProductVariant::class => [
|
'variant.catalogItem.attachments',
|
||||||
'product',
|
'variant.definitions.itemAttribute.attribute',
|
||||||
'definitions.productAttribute.attribute',
|
|
||||||
'attachments',
|
|
||||||
],
|
|
||||||
Bundle::class => ['items.variant'],
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class Purchase extends Model
|
|||||||
|
|
||||||
$cart = $this->relationLoaded('cart')
|
$cart = $this->relationLoaded('cart')
|
||||||
? $this->getRelation('cart')
|
? $this->getRelation('cart')
|
||||||
: $this->cart()->with('items.buyable')->first();
|
: $this->cart()->with(['items.catalogItem', 'items.variant'])->first();
|
||||||
|
|
||||||
if (! $cart) {
|
if (! $cart) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Models;
|
namespace App\Domains\Purchase\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -10,8 +10,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
'buyable_id',
|
'source_catalog_item_id',
|
||||||
'buyable_type',
|
'source_variant_id',
|
||||||
|
'image_attachment_id',
|
||||||
|
'nombre',
|
||||||
|
'descripcion',
|
||||||
|
'slug',
|
||||||
|
'item_nombre',
|
||||||
|
'variant_attributes',
|
||||||
'cantidad',
|
'cantidad',
|
||||||
'precio_unitario',
|
'precio_unitario',
|
||||||
'discount_total',
|
'discount_total',
|
||||||
@@ -28,8 +34,10 @@ class PurchaseItem extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'compra_id' => 'integer',
|
'compra_id' => 'integer',
|
||||||
'buyable_id' => 'integer',
|
'source_catalog_item_id' => 'integer',
|
||||||
'buyable_type' => 'string',
|
'source_variant_id' => 'integer',
|
||||||
|
'image_attachment_id' => 'integer',
|
||||||
|
'variant_attributes' => 'array',
|
||||||
'cantidad' => 'integer',
|
'cantidad' => 'integer',
|
||||||
'precio_unitario' => 'decimal:2',
|
'precio_unitario' => 'decimal:2',
|
||||||
'discount_total' => 'decimal:2',
|
'discount_total' => 'decimal:2',
|
||||||
@@ -46,11 +54,9 @@ class PurchaseItem extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
public function imageAttachment(): BelongsTo
|
||||||
*/
|
|
||||||
public function buyable()
|
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->belongsTo(Attachment::class, 'image_attachment_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,136 +2,114 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Resources;
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Shared\Contracts\Buyable;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/** @mixin PurchaseItem|CartItem */
|
||||||
* @mixin PurchaseItem|CartItem
|
|
||||||
*/
|
|
||||||
class PurchaseItemResource extends JsonResource
|
class PurchaseItemResource extends JsonResource
|
||||||
{
|
{
|
||||||
/**
|
/** @return array<string, mixed> */
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
/** @var Buyable|null $buyable */
|
if ($this->resource instanceof PurchaseItem) {
|
||||||
$buyable = $this->buyable;
|
$imageUrl = $this->imageAttachment?->getTemporaryUrl(1440);
|
||||||
$quantity = (int) ($this->cantidad ?? 0);
|
$attributes = $this->variant_attributes ?? [];
|
||||||
$unitPrice = $this->resolveUnitPrice();
|
|
||||||
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
|
|
||||||
$variant = $buyable instanceof ProductVariant ? $buyable : null;
|
|
||||||
|
|
||||||
$imageUrl = null;
|
return [
|
||||||
$attributes = [];
|
'id' => $this->id,
|
||||||
if ($this->buyable_type === ProductVariant::class && $buyable) {
|
'quantity' => (int) $this->cantidad,
|
||||||
$imageUrl = $this->resolveImageUrl($buyable);
|
'unit_price' => $this->formatMoney($this->precio_unitario),
|
||||||
$attributes = $this->resolveAttributes($buyable);
|
'line_total' => $this->formatMoney($this->total),
|
||||||
|
'source_catalog_item_id' => $this->source_catalog_item_id,
|
||||||
|
'source_variant_id' => $this->source_variant_id,
|
||||||
|
'item_details' => [
|
||||||
|
'nombre' => $this->item_nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'imagen' => $imageUrl,
|
||||||
|
'attributes' => $attributes,
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$selectedItem = $this->selectedItem();
|
||||||
|
$catalogItem = $this->catalogItem;
|
||||||
|
$variant = $this->variant;
|
||||||
|
$quantity = (int) ($this->cantidad ?? 0);
|
||||||
|
$unitPrice = $this->resolveUnitPrice($selectedItem);
|
||||||
|
$lineTotal = $unitPrice * $quantity;
|
||||||
|
$imageUrl = $this->resolveImageUrl($selectedItem, $catalogItem);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $this->formatMoney($unitPrice),
|
'unit_price' => $this->formatMoney($unitPrice),
|
||||||
'line_total' => $this->formatMoney($lineTotal),
|
'line_total' => $this->formatMoney($lineTotal),
|
||||||
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
|
'catalog_item_id' => $this->catalog_item_id,
|
||||||
'buyable_id' => $this->buyable_id,
|
'variant_id' => $this->variant_id,
|
||||||
'product' => $variant === null ? null : [
|
'product' => $catalogItem === null ? null : [
|
||||||
'id' => $variant->product?->id,
|
'id' => $catalogItem->id,
|
||||||
'nombre' => $variant->product?->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'slug' => $variant->product?->slug,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
|
'slug' => $catalogItem->slug,
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
],
|
],
|
||||||
'variant' => $variant === null ? null : [
|
'variant' => $variant === null ? null : [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'attributes' => $attributes,
|
'attributes' => $this->resolveAttributes($variant),
|
||||||
],
|
],
|
||||||
'item_details' => $buyable === null ? null : [
|
'item_details' => $selectedItem === null ? null : [
|
||||||
'nombre' => $buyable->getName(),
|
'nombre' => $selectedItem->getName(),
|
||||||
|
'descripcion' => $catalogItem?->descripcion,
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
'attributes' => $attributes,
|
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mapBuyableTypeToAlias(?string $type): string
|
private function resolveUnitPrice(CatalogItem|Variant|null $selectedItem): float
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return (float) ($selectedItem?->getPrice() ?? 0);
|
||||||
ProductVariant::class => 'variant',
|
|
||||||
Bundle::class => 'bundle',
|
|
||||||
default => 'unknown',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveUnitPrice(): float
|
private function resolveImageUrl(
|
||||||
{
|
CatalogItem|Variant|null $selectedItem,
|
||||||
if ($this->resource instanceof PurchaseItem) {
|
?CatalogItem $catalogItem,
|
||||||
return (float) ($this->precio_unitario ?? 0);
|
): ?string {
|
||||||
|
$attachment = $selectedItem?->relationLoaded('attachments')
|
||||||
|
? $selectedItem->attachments->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($attachment === null && $catalogItem?->relationLoaded('attachments')) {
|
||||||
|
$attachment = $catalogItem->attachments->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->resource instanceof CartItem) {
|
return $attachment?->getTemporaryUrl(1440);
|
||||||
return (float) ($this->buyable?->getPrice() ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0.0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveLineTotal(float $unitPrice, int $quantity): float
|
/** @return array<int, array{name: string, value: mixed}> */
|
||||||
|
private function resolveAttributes(Variant $variant): array
|
||||||
{
|
{
|
||||||
if ($this->resource instanceof PurchaseItem) {
|
if (! $variant->relationLoaded('definitions')) {
|
||||||
return (float) ($this->total ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $unitPrice * $quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveImageUrl($buyable): ?string
|
|
||||||
{
|
|
||||||
if (! $buyable->relationLoaded('attachments')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachment = $buyable->attachments->first();
|
|
||||||
|
|
||||||
if ($attachment === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $attachment->getTemporaryUrl(1440);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{name: string, value: mixed}>
|
|
||||||
*/
|
|
||||||
protected function resolveAttributes($buyable): array
|
|
||||||
{
|
|
||||||
if (! $buyable->relationLoaded('definitions')) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $buyable->definitions
|
return $variant->definitions
|
||||||
->map(function ($definition): array {
|
->map(fn ($definition): array => [
|
||||||
return [
|
'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''),
|
||||||
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
|
'value' => $definition->value,
|
||||||
'value' => $definition->value,
|
])
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
|
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function formatMoney(float|int|string|null $amount): ?string
|
private function formatMoney(float|int|string|null $amount): string
|
||||||
{
|
{
|
||||||
if ($amount === null) {
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return number_format((float) $amount, 2, '.', '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class PurchaseResource extends JsonResource
|
|||||||
return (float) $item->precio_unitario * $item->cantidad;
|
return (float) $item->precio_unitario * $item->cantidad;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) ($item->buyable?->getPrice() ?? 0) * $item->cantidad;
|
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
||||||
|
|||||||
@@ -2,19 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class CheckoutService
|
class CheckoutService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AttachmentService $attachmentService,
|
||||||
|
private readonly CatalogInventoryService $catalogInventoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
||||||
{
|
{
|
||||||
$cartId = (int) $purchaseData['cart_id'];
|
$cartId = (int) $purchaseData['cart_id'];
|
||||||
@@ -30,7 +39,7 @@ class CheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cartItems->load('buyable');
|
$this->loadCartItems($cartItems);
|
||||||
$cart->setRelation('items', $cartItems);
|
$cart->setRelation('items', $cartItems);
|
||||||
$totalAmount = $cart->getTotalAmount();
|
$totalAmount = $cart->getTotalAmount();
|
||||||
|
|
||||||
@@ -64,7 +73,7 @@ class CheckoutService
|
|||||||
$purchase->save();
|
$purchase->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $purchase->load(['items.buyable']);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +92,7 @@ class CheckoutService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) {
|
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED], true)) {
|
||||||
return $purchase->load(['items.buyable']);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
@@ -91,67 +100,71 @@ class CheckoutService
|
|||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $purchase->load(['items.buyable']);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirmPurchase(Purchase $purchase): void
|
public function confirmPurchase(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($purchase): void {
|
$snapshotPaths = [];
|
||||||
/** @var Purchase $purchase */
|
|
||||||
$purchase = Purchase::query()
|
|
||||||
->lockForUpdate()
|
|
||||||
->findOrFail($purchase->getKey());
|
|
||||||
|
|
||||||
if ($purchase->items()->exists()) {
|
try {
|
||||||
return;
|
DB::transaction(function () use ($purchase, &$snapshotPaths): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($purchase->items()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = $purchase->cart()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cart_id' => 'The purchase cart is no longer available.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartItems = $cart->items()->lockForUpdate()->get();
|
||||||
|
|
||||||
|
if ($cartItems->isEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'cart_id' => 'The purchase cart does not contain items.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadCartItems($cartItems);
|
||||||
|
$this->verifyTenantItems($purchase->tenant, $cartItems);
|
||||||
|
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($purchase, $cartItems, $snapshotPaths);
|
||||||
|
|
||||||
|
$purchase->items()->createMany($purchaseItemsPayload);
|
||||||
|
$this->completeCartConversion($cart, $cartItems);
|
||||||
|
});
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
foreach ($snapshotPaths as $snapshotPath) {
|
||||||
|
Storage::disk('s3')->delete($snapshotPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Cart|null $cart */
|
throw $throwable;
|
||||||
$cart = $purchase->cart()->lockForUpdate()->first();
|
}
|
||||||
|
|
||||||
if ($cart === null) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'cart_id' => 'The purchase cart is no longer available.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
|
||||||
|
|
||||||
if ($cartItems->isEmpty()) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'cart_id' => 'The purchase cart does not contain items.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItems->load('buyable');
|
|
||||||
$this->verifyTenantBuyables($purchase->tenant, $cartItems);
|
|
||||||
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems);
|
|
||||||
|
|
||||||
$purchase->items()->createMany($purchaseItemsPayload);
|
|
||||||
$this->completeCartConversion($cart, $cartItems);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function verifyTenantBuyables(Tenant $tenant, Collection $cartItems): void
|
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
|
||||||
{
|
{
|
||||||
foreach ($cartItems as $item) {
|
foreach ($cartItems as $item) {
|
||||||
$buyable = $item->buyable;
|
$selectedItem = $item->selectedItem();
|
||||||
if ($buyable === null) {
|
if ($selectedItem === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'One or more buyables could not be loaded.',
|
'cart_id' => 'One or more catalog items could not be loaded.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($item->buyable_type === ProductVariant::class && $buyable->product->tenant_codigo !== $tenant->codigo) {
|
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'One or more product variants do not belong to the tenant.',
|
'cart_id' => 'One or more catalog items do not belong to the tenant.',
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->buyable_type === Bundle::class && $buyable->tenant_codigo !== $tenant->codigo) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'cart_id' => 'One or more bundles do not belong to the tenant.',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +172,7 @@ class CheckoutService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, CartItem> $cartItems
|
* @param Collection<int, CartItem> $cartItems
|
||||||
* @return Collection<int, ProductVariant>
|
* @return Collection<int, CartItem>
|
||||||
*/
|
*/
|
||||||
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||||
{
|
{
|
||||||
@@ -185,17 +198,33 @@ class CheckoutService
|
|||||||
* @param Collection<int, CartItem> $cartItems
|
* @param Collection<int, CartItem> $cartItems
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
protected function buildPurchaseItemsPayload(Collection $cartItems): array
|
protected function buildPurchaseItemsPayload(
|
||||||
{
|
Purchase $purchase,
|
||||||
|
Collection $cartItems,
|
||||||
|
array &$snapshotPaths = [],
|
||||||
|
): array {
|
||||||
return $cartItems
|
return $cartItems
|
||||||
->map(function (CartItem $item): array {
|
->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array {
|
||||||
$buyable = $item->buyable;
|
$selectedItem = $item->selectedItem();
|
||||||
$quantity = (int) $item['cantidad'];
|
$quantity = (int) $item['cantidad'];
|
||||||
$unitPrice = $buyable?->getPrice() ?? 0;
|
$unitPrice = $selectedItem?->getPrice() ?? 0;
|
||||||
|
$imageAttachment = $this->snapshotFirstImage($purchase, $item);
|
||||||
|
|
||||||
|
if ($imageAttachment !== null) {
|
||||||
|
$snapshotPaths[] = $imageAttachment->path;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'buyable_type' => $item->buyable_type,
|
'source_catalog_item_id' => $item->catalog_item_id,
|
||||||
'buyable_id' => $item->buyable_id,
|
'source_variant_id' => $item->variant_id,
|
||||||
|
'image_attachment_id' => $imageAttachment?->id,
|
||||||
|
'nombre' => $item->catalogItem->nombre,
|
||||||
|
'descripcion' => $item->catalogItem->descripcion,
|
||||||
|
'slug' => $item->catalogItem->slug,
|
||||||
|
'item_nombre' => $selectedItem->getName(),
|
||||||
|
'variant_attributes' => $item->variant === null
|
||||||
|
? []
|
||||||
|
: $this->snapshotAttributes($item->variant),
|
||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
'precio_unitario' => $unitPrice,
|
'precio_unitario' => $unitPrice,
|
||||||
'discount_total' => null,
|
'discount_total' => null,
|
||||||
@@ -212,11 +241,14 @@ class CheckoutService
|
|||||||
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
|
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
|
||||||
{
|
{
|
||||||
foreach ($cartItems as $item) {
|
foreach ($cartItems as $item) {
|
||||||
$buyable = $item->buyable;
|
$selectedItem = $item->selectedItem();
|
||||||
$quantity = (int) $item->cantidad;
|
$quantity = (int) $item->cantidad;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$buyable->buy($quantity);
|
$this->catalogInventoryService->commit(
|
||||||
|
$selectedItem,
|
||||||
|
$quantity,
|
||||||
|
);
|
||||||
} catch (\InvalidArgumentException $exception) {
|
} catch (\InvalidArgumentException $exception) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The selected cart has inconsistent stock state.',
|
'cart_id' => 'The selected cart has inconsistent stock state.',
|
||||||
@@ -230,4 +262,52 @@ class CheckoutService
|
|||||||
$cart->save();
|
$cart->save();
|
||||||
$cart->delete();
|
$cart->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
|
private function loadCartItems(Collection $cartItems): void
|
||||||
|
{
|
||||||
|
$cartItems->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.attachments',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.attachments',
|
||||||
|
'variant.catalogItem',
|
||||||
|
'variant.definitions.itemAttribute.attribute',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return $purchase->load([
|
||||||
|
'items.imageAttachment',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshotFirstImage(Purchase $purchase, CartItem $item): ?Attachment
|
||||||
|
{
|
||||||
|
$source = $item->variant?->attachments->first()
|
||||||
|
?? $item->catalogItem?->attachments->first();
|
||||||
|
|
||||||
|
if ($source === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->attachmentService->copy(
|
||||||
|
$source,
|
||||||
|
"purchase/{$purchase->id}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{name: string, value: mixed}> */
|
||||||
|
private function snapshotAttributes(Variant $variant): array
|
||||||
|
{
|
||||||
|
return $variant->definitions
|
||||||
|
->map(fn ($definition): array => [
|
||||||
|
'name' => (string) ($definition->itemAttribute?->attribute?->nombre ?? ''),
|
||||||
|
'value' => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Shared\Contracts;
|
|
||||||
|
|
||||||
interface Buyable
|
|
||||||
{
|
|
||||||
public function getPrice(): float;
|
|
||||||
|
|
||||||
public function getName(): string;
|
|
||||||
|
|
||||||
public function availableQuantity(): ?int;
|
|
||||||
|
|
||||||
public function reserveStock(int $amount): void;
|
|
||||||
|
|
||||||
public function decrementReservedStock(int $amount): void;
|
|
||||||
|
|
||||||
public function buy(int $amount): void;
|
|
||||||
|
|
||||||
public function validateStock(): void;
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -71,15 +73,15 @@ class Tenant extends Model
|
|||||||
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
|
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function productos(): HasMany
|
public function catalogItems(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Product::class, 'tenant_codigo', 'codigo');
|
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function menues(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
public function menues(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(
|
return $this->belongsToMany(
|
||||||
\App\Domains\Menu\Models\Menu::class,
|
Menu::class,
|
||||||
'tenant_menues',
|
'tenant_menues',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'menu_code',
|
'menu_code',
|
||||||
@@ -88,4 +90,3 @@ class Tenant extends Model
|
|||||||
)->withTimestamps();
|
)->withTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,8 +20,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
\Illuminate\Database\Eloquent\Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
/** @var \Illuminate\Database\Eloquent\Builder $this */
|
/** @var Builder $this */
|
||||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||||
$page = $page ?? (int) request()->query('page', 1);
|
$page = $page ?? (int) request()->query('page', 1);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -8,6 +7,8 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
|
private const LEGACY_PRODUCT_VARIANT = 'App\\Domains\\Catalog\\Models\\ProductVariant';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the migrations.
|
* Run the migrations.
|
||||||
*/
|
*/
|
||||||
@@ -18,7 +19,7 @@ return new class extends Migration
|
|||||||
});
|
});
|
||||||
|
|
||||||
DB::table('group_items')->update([
|
DB::table('group_items')->update([
|
||||||
'groupable_type' => ProductVariant::class,
|
'groupable_type' => self::LEGACY_PRODUCT_VARIANT,
|
||||||
'groupable_id' => DB::raw('product_variant_id'),
|
'groupable_id' => DB::raw('product_variant_id'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
if (DB::table('group_items')->where('groupable_type', '!=', ProductVariant::class)->exists()) {
|
if (DB::table('group_items')->where('groupable_type', '!=', self::LEGACY_PRODUCT_VARIANT)->exists()) {
|
||||||
throw new RuntimeException('Cannot roll back groupable group items while bundle items exist.');
|
throw new RuntimeException('Cannot roll back groupable group items while bundle items exist.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('inventories', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('sold_units')->default(0);
|
||||||
|
$table->unsignedInteger('reserved_stock')->default(0);
|
||||||
|
$table->unsignedInteger('real_stock')->default(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('catalog_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained('categorias')->nullOnDelete();
|
||||||
|
$table->foreignId('brand_id')->nullable()->constrained('brands')->nullOnDelete();
|
||||||
|
$table->foreignId('inventory_id')->nullable()->unique()->constrained('inventories')->restrictOnDelete();
|
||||||
|
$table->string('slug');
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->text('descripcion')->nullable();
|
||||||
|
$table->decimal('precio', 10, 2);
|
||||||
|
$table->enum('inventory_policy', InventoryPolicy::values())
|
||||||
|
->default(InventoryPolicy::Tracked->value);
|
||||||
|
$table->boolean('has_tickets')->default(false);
|
||||||
|
$table->dateTime('maximum_use_date')->nullable();
|
||||||
|
$table->dateTime('minimum_use_date')->nullable();
|
||||||
|
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
|
||||||
|
$table->unique(['tenant_code', 'slug']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('catalog_items');
|
||||||
|
Schema::dropIfExists('inventories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$inventoryIds = [];
|
||||||
|
|
||||||
|
DB::table('productos_variantes')->orderBy('id')->each(
|
||||||
|
function (object $variant) use (&$inventoryIds): void {
|
||||||
|
$inventoryIds[$variant->id] = DB::table('inventories')->insertGetId([
|
||||||
|
'sold_units' => $variant->cantidad_vendida,
|
||||||
|
'reserved_stock' => $variant->stock_reservado,
|
||||||
|
'real_stock' => $variant->stock_real,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::table('productos')->orderBy('id')->each(
|
||||||
|
function (object $product): void {
|
||||||
|
$variants = DB::table('productos_variantes')
|
||||||
|
->where('producto_id', $product->id)
|
||||||
|
->orderBy('id');
|
||||||
|
$firstVariant = (clone $variants)->first();
|
||||||
|
$inventoryId = null;
|
||||||
|
|
||||||
|
if ($firstVariant === null) {
|
||||||
|
$inventoryId = DB::table('inventories')->insertGetId([
|
||||||
|
'sold_units' => 0,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'real_stock' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('catalog_items')->insert([
|
||||||
|
'id' => $product->id,
|
||||||
|
'tenant_code' => $product->tenant_codigo,
|
||||||
|
'category_id' => $product->categoria_id,
|
||||||
|
'brand_id' => $product->brand_id,
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
'slug' => $product->slug,
|
||||||
|
'nombre' => $product->nombre,
|
||||||
|
'descripcion' => $product->descripcion,
|
||||||
|
'precio' => $product->precio,
|
||||||
|
'inventory_policy' => $firstVariant->inventory_policy
|
||||||
|
?? InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => $firstVariant->has_tickets ?? false,
|
||||||
|
'minimum_use_date' => $firstVariant->minimum_use_date ?? null,
|
||||||
|
'maximum_use_date' => $firstVariant->maximum_use_date ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Schema::table('productos_variantes', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['producto_id']);
|
||||||
|
$table->foreignId('inventory_id')->nullable()->after('producto_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $variantId => $inventoryId) {
|
||||||
|
DB::table('productos_variantes')->where('id', $variantId)->update([
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('productos_variantes', function (Blueprint $table): void {
|
||||||
|
$table->renameColumn('producto_id', 'catalog_item_id');
|
||||||
|
$table->dropColumn([
|
||||||
|
'inventory_policy',
|
||||||
|
'stock_real',
|
||||||
|
'stock_reservado',
|
||||||
|
'cantidad_vendida',
|
||||||
|
'is_placeholder',
|
||||||
|
'has_tickets',
|
||||||
|
'minimum_use_date',
|
||||||
|
'maximum_use_date',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::rename('productos_variantes', 'variantes');
|
||||||
|
|
||||||
|
Schema::table('variantes', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('inventory_id')->nullable(false)->change();
|
||||||
|
$table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete();
|
||||||
|
$table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete();
|
||||||
|
$table->unique('inventory_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('products_attributes', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['product_id']);
|
||||||
|
$table->renameColumn('product_id', 'catalog_item_id');
|
||||||
|
});
|
||||||
|
Schema::rename('products_attributes', 'item_attributes');
|
||||||
|
Schema::table('item_attributes', function (Blueprint $table): void {
|
||||||
|
$table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('productos_variantes_values', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign('productos_variantes_definiciones_producto_variante_id_foreign');
|
||||||
|
$table->dropForeign(['products_attribute_id']);
|
||||||
|
$table->dropUnique('prod_var_values_variant_product_attr_unique');
|
||||||
|
$table->renameColumn('producto_variante_id', 'variant_id');
|
||||||
|
$table->renameColumn('products_attribute_id', 'item_attribute_id');
|
||||||
|
});
|
||||||
|
Schema::rename('productos_variantes_values', 'variant_values');
|
||||||
|
Schema::table('variant_values', function (Blueprint $table): void {
|
||||||
|
$table->foreign('variant_id')->references('id')->on('variantes')->cascadeOnDelete();
|
||||||
|
$table->foreign('item_attribute_id')->references('id')->on('item_attributes')->cascadeOnDelete();
|
||||||
|
$table->unique(['variant_id', 'item_attribute_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('productos_attachments', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['producto_id']);
|
||||||
|
$table->dropUnique('productos_attachments_producto_id_attachment_id_unique');
|
||||||
|
$table->renameColumn('producto_id', 'catalog_item_id');
|
||||||
|
});
|
||||||
|
Schema::rename('productos_attachments', 'catalog_items_attachments');
|
||||||
|
Schema::table('catalog_items_attachments', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('variant_id')
|
||||||
|
->nullable()
|
||||||
|
->after('id')
|
||||||
|
->constrained('variantes')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('orden')->default(0)->after('attachment_id');
|
||||||
|
$table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$catalogAttachmentOrder = [];
|
||||||
|
DB::table('catalog_items_attachments')
|
||||||
|
->orderBy('catalog_item_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->each(function (object $catalogAttachment) use (&$catalogAttachmentOrder): void {
|
||||||
|
$catalogItemId = $catalogAttachment->catalog_item_id;
|
||||||
|
$order = $catalogAttachmentOrder[$catalogItemId] ?? 0;
|
||||||
|
|
||||||
|
DB::table('catalog_items_attachments')
|
||||||
|
->where('id', $catalogAttachment->id)
|
||||||
|
->update(['orden' => $order]);
|
||||||
|
|
||||||
|
$catalogAttachmentOrder[$catalogItemId] = $order + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
$variantAttachmentOrder = [];
|
||||||
|
DB::table('variantes_attachments')->orderBy('id')->each(
|
||||||
|
function (object $variantAttachment) use (&$variantAttachmentOrder): void {
|
||||||
|
$variant = DB::table('variantes')
|
||||||
|
->select('catalog_item_id')
|
||||||
|
->where('id', $variantAttachment->variante_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('catalog_items_attachments')->insert([
|
||||||
|
'variant_id' => $variantAttachment->variante_id,
|
||||||
|
'catalog_item_id' => $variant->catalog_item_id,
|
||||||
|
'attachment_id' => $variantAttachment->attachment_id,
|
||||||
|
'orden' => $variantAttachmentOrder[$variantAttachment->variante_id] ?? 0,
|
||||||
|
'created_at' => $variantAttachment->created_at,
|
||||||
|
'updated_at' => $variantAttachment->updated_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variantAttachmentOrder[$variantAttachment->variante_id] =
|
||||||
|
($variantAttachmentOrder[$variantAttachment->variante_id] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Schema::drop('variantes_attachments');
|
||||||
|
|
||||||
|
Schema::table('catalog_items_attachments', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['created_at', 'updated_at']);
|
||||||
|
$table->unique(
|
||||||
|
['catalog_item_id', 'variant_id', 'attachment_id'],
|
||||||
|
'catalog_item_variant_attachment_unique'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::drop('productos');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('This destructive catalog migration cannot be reversed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const LEGACY_PRODUCT_VARIANT = 'App\\Domains\\Catalog\\Models\\ProductVariant';
|
||||||
|
|
||||||
|
private const LEGACY_BUNDLE = 'App\\Domains\\Bundle\\Models\\Bundle';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('carrito_items', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('catalog_item_id')->nullable()->after('cart_id');
|
||||||
|
$table->foreignId('variant_id')->nullable()->after('catalog_item_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('carrito_items')->orderBy('id')->each(function (object $cartItem): void {
|
||||||
|
if ($cartItem->buyable_type !== self::LEGACY_PRODUCT_VARIANT) {
|
||||||
|
if ($cartItem->buyable_type === self::LEGACY_BUNDLE) {
|
||||||
|
DB::table('bundle_items')
|
||||||
|
->where('bundle_id', $cartItem->buyable_id)
|
||||||
|
->orderBy('id')
|
||||||
|
->each(function (object $bundleItem) use ($cartItem): void {
|
||||||
|
$variant = DB::table('variantes')
|
||||||
|
->select('inventory_id')
|
||||||
|
->where('id', $bundleItem->producto_variante_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = DB::table('inventories')
|
||||||
|
->select('reserved_stock')
|
||||||
|
->where('id', $variant->inventory_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($inventory === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservedAmount = $cartItem->cantidad * $bundleItem->cantidad;
|
||||||
|
DB::table('inventories')->where('id', $variant->inventory_id)->update([
|
||||||
|
'reserved_stock' => max(0, $inventory->reserved_stock - $reservedAmount),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('carrito_items')->where('id', $cartItem->id)->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = DB::table('variantes')
|
||||||
|
->select(['id', 'catalog_item_id'])
|
||||||
|
->where('id', $cartItem->buyable_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
DB::table('carrito_items')->where('id', $cartItem->id)->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('carrito_items')->where('id', $cartItem->id)->update([
|
||||||
|
'catalog_item_id' => $variant->catalog_item_id,
|
||||||
|
'variant_id' => $variant->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carrito_items', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['cart_id', 'buyable_type', 'buyable_id']);
|
||||||
|
$table->dropColumn(['buyable_type', 'buyable_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carrito_items', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('catalog_item_id')->nullable(false)->change();
|
||||||
|
$table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete();
|
||||||
|
$table->foreign('variant_id')->references('id')->on('variantes')->cascadeOnDelete();
|
||||||
|
$table->unique(
|
||||||
|
['cart_id', 'catalog_item_id', 'variant_id'],
|
||||||
|
'cart_catalog_item_variant_unique'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('This destructive cart migration cannot be reversed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const LEGACY_PRODUCT_VARIANT = 'App\\Domains\\Catalog\\Models\\ProductVariant';
|
||||||
|
|
||||||
|
private const PREVIOUS_CATALOG_ITEM = 'App\\Domains\\CatalogNew\\Models\\CatalogItem';
|
||||||
|
|
||||||
|
private const PREVIOUS_VARIANT = 'App\\Domains\\CatalogNew\\Models\\Variant';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('source_catalog_item_id')->nullable()->after('compra_id');
|
||||||
|
$table->unsignedBigInteger('source_variant_id')->nullable()->after('source_catalog_item_id');
|
||||||
|
$table->foreignId('image_attachment_id')->nullable()->after('source_variant_id');
|
||||||
|
$table->string('nombre')->nullable()->after('image_attachment_id');
|
||||||
|
$table->text('descripcion')->nullable()->after('nombre');
|
||||||
|
$table->string('slug')->nullable()->after('descripcion');
|
||||||
|
$table->string('item_nombre')->nullable()->after('slug');
|
||||||
|
$table->json('variant_attributes')->nullable()->after('item_nombre');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('compra_items')->orderBy('id')->each(function (object $purchaseItem): void {
|
||||||
|
if (in_array($purchaseItem->buyable_type, [CatalogItem::class, self::PREVIOUS_CATALOG_ITEM], true)) {
|
||||||
|
$catalogItem = DB::table('catalog_items')
|
||||||
|
->where('id', $purchaseItem->buyable_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($catalogItem !== null) {
|
||||||
|
DB::table('compra_items')->where('id', $purchaseItem->id)->update([
|
||||||
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
|
'nombre' => $catalogItem->nombre,
|
||||||
|
'descripcion' => $catalogItem->descripcion,
|
||||||
|
'slug' => $catalogItem->slug,
|
||||||
|
'item_nombre' => $catalogItem->nombre,
|
||||||
|
'variant_attributes' => json_encode([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($purchaseItem->buyable_type, [self::LEGACY_PRODUCT_VARIANT, Variant::class, self::PREVIOUS_VARIANT], true)) {
|
||||||
|
$variant = DB::table('variantes')
|
||||||
|
->select(['id', 'catalog_item_id'])
|
||||||
|
->where('id', $purchaseItem->buyable_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant !== null) {
|
||||||
|
$catalogItem = DB::table('catalog_items')->find($variant->catalog_item_id);
|
||||||
|
|
||||||
|
if ($catalogItem === null) {
|
||||||
|
DB::table('compra_items')->where('id', $purchaseItem->id)->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = DB::table('variant_values as variant_value')
|
||||||
|
->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id')
|
||||||
|
->leftJoin('attribute', 'attribute.id', '=', 'item_attribute.attribute_id')
|
||||||
|
->where('variant_value.variant_id', $variant->id)
|
||||||
|
->orderBy('variant_value.id')
|
||||||
|
->get(['attribute.nombre as name', 'variant_value.value'])
|
||||||
|
->map(fn (object $attribute): array => [
|
||||||
|
'name' => (string) ($attribute->name ?? ''),
|
||||||
|
'value' => $attribute->value,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
$attributeDescription = collect($attributes)
|
||||||
|
->map(fn (array $attribute): string => $attribute['name'] !== ''
|
||||||
|
? "{$attribute['name']}: {$attribute['value']}"
|
||||||
|
: (string) $attribute['value'])
|
||||||
|
->filter()
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
DB::table('compra_items')->where('id', $purchaseItem->id)->update([
|
||||||
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
|
'source_variant_id' => $variant->id,
|
||||||
|
'nombre' => $catalogItem->nombre,
|
||||||
|
'descripcion' => $catalogItem->descripcion,
|
||||||
|
'slug' => $catalogItem->slug,
|
||||||
|
'item_nombre' => $attributeDescription === ''
|
||||||
|
? $catalogItem->nombre
|
||||||
|
: "{$catalogItem->nombre} ({$attributeDescription})",
|
||||||
|
'variant_attributes' => json_encode($attributes),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('compra_items')->where('id', $purchaseItem->id)->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['buyable_type', 'buyable_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('source_catalog_item_id')->nullable(false)->change();
|
||||||
|
$table->string('nombre')->nullable(false)->change();
|
||||||
|
$table->string('item_nombre')->nullable(false)->change();
|
||||||
|
$table->foreign('image_attachment_id')->references('id')->on('attachments')->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('This destructive purchase migration cannot be reversed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('featured_items');
|
||||||
|
Schema::dropIfExists('featured_products');
|
||||||
|
Schema::dropIfExists('group_items');
|
||||||
|
Schema::dropIfExists('featured_variants');
|
||||||
|
Schema::dropIfExists('featured_groups');
|
||||||
|
|
||||||
|
Schema::create('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->enum('product_layout', ProductLayout::values());
|
||||||
|
$table->string('group_name');
|
||||||
|
$table->unsignedInteger('group_order')->default(0);
|
||||||
|
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('featured_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('featured_group_id')
|
||||||
|
->constrained('featured_groups')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('catalog_item_id')
|
||||||
|
->constrained('catalog_items')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('order')->default(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('This destructive featured catalog migration cannot be reversed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||||
|
$table->enum('type', CatalogItemType::values())
|
||||||
|
->default(CatalogItemType::Standard->value)
|
||||||
|
->after('inventory_id');
|
||||||
|
$table->enum('inventory_policy', InventoryPolicy::values())
|
||||||
|
->nullable()
|
||||||
|
->default(InventoryPolicy::Tracked->value)
|
||||||
|
->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('bundle_components', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('bundle_catalog_item_id')
|
||||||
|
->constrained('catalog_items')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('component_catalog_item_id')
|
||||||
|
->constrained('catalog_items')
|
||||||
|
->restrictOnDelete();
|
||||||
|
$table->foreignId('component_variant_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('variantes')
|
||||||
|
->restrictOnDelete();
|
||||||
|
$table->unsignedInteger('quantity');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('bundle_components');
|
||||||
|
|
||||||
|
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('type');
|
||||||
|
$table->enum('inventory_policy', InventoryPolicy::values())
|
||||||
|
->nullable(false)
|
||||||
|
->default(InventoryPolicy::Tracked->value)
|
||||||
|
->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('bundle_items');
|
||||||
|
Schema::dropIfExists('bundles');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Legacy bundle data cannot be restored.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\Product;
|
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
@@ -19,17 +18,15 @@ class AttributeSeeder extends Seeder
|
|||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
// Fiesta Futbol Infantil only uses the Fecha attribute.
|
// Fiesta Futbol Infantil only uses the Fecha attribute.
|
||||||
$existingColor = Attribute::query()
|
if ($tenant->codigo === 'fiesta_futbol_infantil') {
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
Attribute::query()
|
||||||
->where('codigo', 'color')
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->first();
|
->whereIn('codigo', ['color', 'talle', 'talle_numerico'])
|
||||||
|
->delete();
|
||||||
if ($existingColor) {
|
|
||||||
Product::deleteAttribute($existingColor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
||||||
Product::createAttribute($tenant, [
|
$this->seedAttribute($tenant, [
|
||||||
'codigo' => 'color',
|
'codigo' => 'color',
|
||||||
'nombre' => 'Color',
|
'nombre' => 'Color',
|
||||||
'type' => FieldType::Select->value,
|
'type' => FieldType::Select->value,
|
||||||
@@ -67,17 +64,8 @@ class AttributeSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seed Talle (Size - Text options) attribute
|
// Seed Talle (Size - Text options) attribute
|
||||||
$existingTalle = Attribute::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('codigo', 'talle')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingTalle) {
|
|
||||||
Product::deleteAttribute($existingTalle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
||||||
Product::createAttribute($tenant, [
|
$this->seedAttribute($tenant, [
|
||||||
'codigo' => 'talle',
|
'codigo' => 'talle',
|
||||||
'nombre' => 'Talle',
|
'nombre' => 'Talle',
|
||||||
'type' => FieldType::Select->value,
|
'type' => FieldType::Select->value,
|
||||||
@@ -92,17 +80,8 @@ class AttributeSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seed Talle Numérico (Numeric Size options) attribute
|
// Seed Talle Numérico (Numeric Size options) attribute
|
||||||
$existingTalleNumerico = Attribute::query()
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('codigo', 'talle_numerico')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingTalleNumerico) {
|
|
||||||
Product::deleteAttribute($existingTalleNumerico);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
if ($tenant->codigo !== 'fiesta_futbol_infantil') {
|
||||||
Product::createAttribute($tenant, [
|
$this->seedAttribute($tenant, [
|
||||||
'codigo' => 'talle_numerico',
|
'codigo' => 'talle_numerico',
|
||||||
'nombre' => 'Talle Numérico',
|
'nombre' => 'Talle Numérico',
|
||||||
'type' => FieldType::Select->value,
|
'type' => FieldType::Select->value,
|
||||||
@@ -117,16 +96,7 @@ class AttributeSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seed Fecha attribute
|
// Seed Fecha attribute
|
||||||
$existingFecha = Attribute::query()
|
$this->seedAttribute($tenant, [
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('codigo', 'fecha')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existingFecha) {
|
|
||||||
Product::deleteAttribute($existingFecha);
|
|
||||||
}
|
|
||||||
|
|
||||||
Product::createAttribute($tenant, [
|
|
||||||
'codigo' => 'fecha',
|
'codigo' => 'fecha',
|
||||||
'nombre' => 'Fecha',
|
'nombre' => 'Fecha',
|
||||||
'type' => FieldType::Select->value,
|
'type' => FieldType::Select->value,
|
||||||
@@ -141,4 +111,24 @@ class AttributeSeeder extends Seeder
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function seedAttribute(Tenant $tenant, array $data): void
|
||||||
|
{
|
||||||
|
$options = $data['options'] ?? [];
|
||||||
|
unset($data['options']);
|
||||||
|
|
||||||
|
$attribute = Attribute::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'codigo' => $data['codigo'],
|
||||||
|
],
|
||||||
|
$data,
|
||||||
|
);
|
||||||
|
|
||||||
|
$attribute->options()->delete();
|
||||||
|
$attribute->options()->createMany($options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class CategorySeeder extends Seeder
|
|||||||
'Pantalones',
|
'Pantalones',
|
||||||
'Zapatillas',
|
'Zapatillas',
|
||||||
'Buzos',
|
'Buzos',
|
||||||
'Accesorios'
|
'Accesorios',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($categories as $nombre) {
|
foreach ($categories as $nombre) {
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Domains\Bundle\Models\Bundle;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Services\ProductService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class FiestaFutbolInfantilProductSeeder extends Seeder
|
class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ProductService $productService) {}
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
@@ -25,110 +25,160 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
throw new RuntimeException("Tenant 'fiesta_futbol_infantil' no encontrado.");
|
throw new RuntimeException("Tenant 'fiesta_futbol_infantil' no encontrado.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bundles reference product variants, so remove them before reseeding products.
|
$this->deleteExistingCatalog($tenant);
|
||||||
Bundle::query()->where('tenant_codigo', $tenant->codigo)->delete();
|
|
||||||
|
|
||||||
// Delete existing products for this tenant
|
$ticketCategory = Category::query()->firstOrCreate([
|
||||||
$existingProducts = Product::query()
|
'nombre' => 'Entradas',
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
'tenant_code' => null,
|
||||||
->get();
|
]);
|
||||||
|
$foodCategory = Category::query()->firstOrCreate([
|
||||||
foreach ($existingProducts as $product) {
|
'nombre' => 'Gastronomía',
|
||||||
$this->productService->delete($product);
|
'tenant_code' => null,
|
||||||
}
|
]);
|
||||||
|
|
||||||
// We need a category, let's just use 'Accesorios' or create an 'Entradas' category
|
|
||||||
$category = Category::firstOrCreate(['nombre' => 'Entradas', 'tenant_code' => null]);
|
|
||||||
|
|
||||||
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
|
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
|
||||||
$entradaVariants = [];
|
|
||||||
|
|
||||||
// 1. Entrada General
|
$generalAdmission = $this->catalogService->create([
|
||||||
$entrada = $this->productService->create($tenant, [
|
'tenant_code' => $tenant->codigo,
|
||||||
'categoria_id' => $category->id,
|
'category_id' => $ticketCategory->id,
|
||||||
'slug' => 'entrada-general',
|
'slug' => 'entrada-general',
|
||||||
'nombre' => 'Entrada General',
|
'nombre' => 'Entrada General',
|
||||||
'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
|
'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
|
||||||
'precio' => 10000,
|
'precio' => 10000,
|
||||||
'stock' => 0,
|
|
||||||
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
||||||
'attribute_ids' => [Attribute::where('codigo', 'fecha')->where('tenant_codigo', $tenant->codigo)->first()?->id],
|
'has_tickets' => true,
|
||||||
|
'minimum_use_date' => $dates[0].' 00:00:00',
|
||||||
|
'maximum_use_date' => $dates[array_key_last($dates)].' 23:59:59',
|
||||||
|
'attribute_codes' => ['fecha'],
|
||||||
|
'variants' => array_map(
|
||||||
|
fn (string $date): array => [
|
||||||
|
'real_stock' => 0,
|
||||||
|
'values' => ['fecha' => $date],
|
||||||
|
],
|
||||||
|
$dates,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($dates as $date) {
|
$items = [
|
||||||
$entradaVariants[] = $this->productService->createVariant($entrada, [
|
['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $foodCategory->id],
|
||||||
'stock' => 0,
|
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
|
||||||
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id],
|
||||||
'has_tickets' => true,
|
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id],
|
||||||
'minimum_use_date' => $date.' 00:00:00',
|
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id],
|
||||||
'maximum_use_date' => $date.' 23:59:59',
|
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id],
|
||||||
'definitions' => [
|
|
||||||
[
|
|
||||||
'products_attribute_id' => DB::table('products_attributes')
|
|
||||||
->where('product_id', $entrada->id)
|
|
||||||
->first()?->id,
|
|
||||||
'value' => $date,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other products without variants
|
|
||||||
$gastronomiaCategory = Category::firstOrCreate(['nombre' => 'Gastronomía', 'tenant_code' => null]);
|
|
||||||
|
|
||||||
$simpleProducts = [
|
|
||||||
['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'cat' => $gastronomiaCategory->id],
|
|
||||||
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'cat' => $gastronomiaCategory->id],
|
|
||||||
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'cat' => $gastronomiaCategory->id],
|
|
||||||
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'cat' => $gastronomiaCategory->id],
|
|
||||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'cat' => $category->id],
|
|
||||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'cat' => $category->id],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$createdProducts = [];
|
$createdItems = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
foreach ($simpleProducts as $p) {
|
$createdItems[$item['slug']] = $this->catalogService->create([
|
||||||
$createdProducts[$p['slug']] = $this->productService->create($tenant, [
|
'tenant_code' => $tenant->codigo,
|
||||||
'categoria_id' => $p['cat'],
|
'descripcion' => $item['descripcion'] ?? $item['nombre'],
|
||||||
'slug' => $p['slug'],
|
|
||||||
'nombre' => $p['nombre'],
|
|
||||||
'descripcion' => $p['nombre'],
|
|
||||||
'precio' => $p['precio'],
|
|
||||||
'stock' => 0,
|
|
||||||
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
||||||
|
'real_stock' => 0,
|
||||||
|
...$item,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$allDaysBundle = Bundle::query()->create([
|
$this->catalogService->create([
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'type' => CatalogItemType::Bundle->value,
|
||||||
|
'slug' => 'entrada-general-todos-los-dias',
|
||||||
'nombre' => 'Entrada General - Todos los días',
|
'nombre' => 'Entrada General - Todos los días',
|
||||||
'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.',
|
'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.',
|
||||||
'precio' => 40000,
|
'precio' => 40000,
|
||||||
|
'category_id' => $ticketCategory->id,
|
||||||
|
'components' => $generalAdmission->variants
|
||||||
|
->map(fn ($variant): array => [
|
||||||
|
'catalog_item_id' => $generalAdmission->id,
|
||||||
|
'variant_id' => $variant->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($entradaVariants as $variant) {
|
$this->catalogService->create([
|
||||||
$allDaysBundle->items()->create([
|
'tenant_code' => $tenant->codigo,
|
||||||
'producto_variante_id' => $variant->id,
|
'type' => CatalogItemType::Bundle->value,
|
||||||
'cantidad' => 1,
|
'slug' => 'combo-2-panchos-2-hamburguesas',
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$foodBundle = Bundle::query()->create([
|
|
||||||
'tenant_codigo' => $tenant->codigo,
|
|
||||||
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',
|
'nombre' => 'Combo 2 Panchos + 2 Hamburguesas',
|
||||||
'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.',
|
'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.',
|
||||||
'precio' => 24000,
|
'precio' => 24000,
|
||||||
|
'category_id' => $foodCategory->id,
|
||||||
|
'components' => [
|
||||||
|
[
|
||||||
|
'catalog_item_id' => $createdItems['pancho']->id,
|
||||||
|
'quantity' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'catalog_item_id' => $createdItems['hamburguesa-papa-frita']->id,
|
||||||
|
'quantity' => 2,
|
||||||
|
],
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$foodBundle->items()->createMany([
|
$this->seedFeaturedGroups($tenant);
|
||||||
[
|
}
|
||||||
'producto_variante_id' => $createdProducts['pancho']->variants()->sole()->id,
|
|
||||||
'cantidad' => 2,
|
private function deleteExistingCatalog(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('type', CatalogItemType::Bundle->value)
|
||||||
|
->each(fn (CatalogItem $item) => $this->catalogService->delete($item));
|
||||||
|
|
||||||
|
CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('type', CatalogItemType::Standard->value)
|
||||||
|
->each(fn (CatalogItem $item) => $this->catalogService->delete($item));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedFeaturedGroups(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
||||||
|
|
||||||
|
$groups = [
|
||||||
|
'Entradas' => [
|
||||||
|
'entrada-general',
|
||||||
|
'entrada-general-todos-los-dias',
|
||||||
],
|
],
|
||||||
[
|
'Estacionamiento' => [
|
||||||
'producto_variante_id' => $createdProducts['hamburguesa-papa-frita']->variants()->sole()->id,
|
'estacionamiento-auto',
|
||||||
'cantidad' => 2,
|
'estacionamiento-moto',
|
||||||
],
|
],
|
||||||
]);
|
'Comidas' => [
|
||||||
|
'hamburguesa-papa-frita',
|
||||||
|
'pancho',
|
||||||
|
'combo-2-panchos-2-hamburguesas',
|
||||||
|
],
|
||||||
|
'Bebidas' => [
|
||||||
|
'coca-cola-500ml',
|
||||||
|
'agua-mineral-1l',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$catalogItems = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereIn('slug', collect($groups)->flatten()->all())
|
||||||
|
->get()
|
||||||
|
->keyBy('slug');
|
||||||
|
|
||||||
|
$groupOrder = 0;
|
||||||
|
foreach ($groups as $groupName => $slugs) {
|
||||||
|
$featuredGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'product_layout' => ProductLayout::Row,
|
||||||
|
'group_name' => $groupName,
|
||||||
|
'group_order' => $groupOrder++,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featuredGroup->featuredItems()->createMany(
|
||||||
|
collect($slugs)->values()->map(
|
||||||
|
fn (string $slug, int $order): array => [
|
||||||
|
'catalog_item_id' => $catalogItems->get($slug)->id,
|
||||||
|
'order' => $order,
|
||||||
|
]
|
||||||
|
)->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\Brand;
|
use App\Domains\Catalog\Models\Brand;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Services\ProductService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -65,7 +66,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
'istockphoto-1675347112-2048x2048.jpg',
|
'istockphoto-1675347112-2048x2048.jpg',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private readonly ProductService $productService) {}
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
@@ -140,14 +141,14 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
->unique()
|
->unique()
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
$existingProducts = Product::query()
|
$existingProducts = CatalogItem::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->whereIn('slug', $productSlugsToDelete)
|
->whereIn('slug', $productSlugsToDelete)
|
||||||
->with(['attachments', 'variants.attachments'])
|
->with('variants:id,catalog_item_id,inventory_id')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($existingProducts as $existingProduct) {
|
foreach ($existingProducts as $existingProduct) {
|
||||||
$this->productService->delete($existingProduct);
|
$this->catalogService->delete($existingProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($productsToSeed as $catalogProduct) {
|
foreach ($productsToSeed as $catalogProduct) {
|
||||||
@@ -158,6 +159,34 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
$catalogProduct['variant_groups'],
|
$catalogProduct['variant_groups'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->seedFeaturedProducts($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedFeaturedProducts(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete();
|
||||||
|
|
||||||
|
$group = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
|
'group_name' => 'Productos',
|
||||||
|
'group_order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->orderBy('id')
|
||||||
|
->get('id');
|
||||||
|
|
||||||
|
$group->featuredItems()->createMany(
|
||||||
|
$items->values()->map(
|
||||||
|
fn (CatalogItem $item, int $order): array => [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'order' => $order,
|
||||||
|
]
|
||||||
|
)->all()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,33 +218,21 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributeIds = Attribute::query()
|
$attributeCodes = Attribute::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->whereIn('codigo', $metadata['attribute_codes'])
|
->whereIn('codigo', $metadata['attribute_codes'])
|
||||||
->pluck('id', 'codigo');
|
->pluck('codigo');
|
||||||
|
|
||||||
if ($attributeIds->count() !== count($metadata['attribute_codes'])) {
|
if ($attributeCodes->count() !== count($metadata['attribute_codes'])) {
|
||||||
throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'.");
|
throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$product = $this->productService->create($tenant, [
|
|
||||||
'categoria_id' => $category->id,
|
|
||||||
'brand_id' => $brand?->id,
|
|
||||||
'slug' => $metadata['product_slug'],
|
|
||||||
'nombre' => $metadata['product_name'],
|
|
||||||
'descripcion' => $metadata['description'],
|
|
||||||
'precio' => $pricing['price'],
|
|
||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
|
||||||
'attribute_ids' => array_values($attributeIds->all()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$productAttributeIds = DB::table('products_attributes')
|
|
||||||
->where('product_id', $product->id)
|
|
||||||
->pluck('id', 'attribute_id');
|
|
||||||
|
|
||||||
$sizes = $metadata['type'] === 'zapatillas'
|
$sizes = $metadata['type'] === 'zapatillas'
|
||||||
? self::SHOE_SIZES
|
? self::SHOE_SIZES
|
||||||
: self::CLOTHING_SIZES;
|
: self::CLOTHING_SIZES;
|
||||||
|
$variants = [];
|
||||||
|
$itemImages = [];
|
||||||
|
$directStock = 0;
|
||||||
|
|
||||||
foreach ($variantGroups as $variantGroup) {
|
foreach ($variantGroups as $variantGroup) {
|
||||||
$images = array_map(
|
$images = array_map(
|
||||||
@@ -223,47 +240,29 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
$variantGroup['files']
|
$variantGroup['files']
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($attributeIds->isEmpty()) {
|
if ($attributeCodes->isEmpty()) {
|
||||||
$this->productService->createVariant($product, [
|
$directStock = $variantGroup['stock'];
|
||||||
'stock' => $variantGroup['stock'],
|
$itemImages = [...$itemImages, ...$images];
|
||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
|
||||||
'definitions' => [],
|
|
||||||
'images' => $images,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($sizes as $size) {
|
foreach ($sizes as $size) {
|
||||||
$definitions = [];
|
$values = [];
|
||||||
|
|
||||||
if (isset($attributeIds['color']) && $variantGroup['metadata']['color'] !== null) {
|
if ($attributeCodes->contains('color') && $variantGroup['metadata']['color'] !== null) {
|
||||||
$colorProductAttributeId = $productAttributeIds[$attributeIds['color']] ?? null;
|
$values['color'] = $variantGroup['metadata']['color'];
|
||||||
|
|
||||||
if ($colorProductAttributeId === null) {
|
|
||||||
throw new RuntimeException("Missing product attribute for color on product '{$metadata['product_slug']}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$definitions[] = [
|
|
||||||
'products_attribute_id' => $colorProductAttributeId,
|
|
||||||
'value' => $variantGroup['metadata']['color'],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
|
$sizeAttributeCode = $metadata['type'] === 'zapatillas'
|
||||||
? 'talle_numerico'
|
? 'talle_numerico'
|
||||||
: 'talle';
|
: 'talle';
|
||||||
|
|
||||||
$sizeProductAttributeId = $productAttributeIds[$attributeIds[$sizeAttributeCode]] ?? null;
|
if (! $attributeCodes->contains($sizeAttributeCode)) {
|
||||||
|
|
||||||
if ($sizeProductAttributeId === null) {
|
|
||||||
throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'.");
|
throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$definitions[] = [
|
$values[$sizeAttributeCode] = $size;
|
||||||
'products_attribute_id' => $sizeProductAttributeId,
|
|
||||||
'value' => $size,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Override stock for specific variants as requested by the user
|
// Override stock for specific variants as requested by the user
|
||||||
$stock = $variantGroup['stock'];
|
$stock = $variantGroup['stock'];
|
||||||
@@ -280,14 +279,30 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
$stock = 0;
|
$stock = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->productService->createVariant($product, [
|
$variants[] = [
|
||||||
'stock' => $stock,
|
'real_stock' => $stock,
|
||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
'values' => $values,
|
||||||
'definitions' => $definitions,
|
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->catalogService->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'brand_id' => $brand?->id,
|
||||||
|
'slug' => $metadata['product_slug'],
|
||||||
|
'nombre' => $metadata['product_name'],
|
||||||
|
'descripcion' => $metadata['description'],
|
||||||
|
'precio' => $pricing['price'],
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
...($attributeCodes->isEmpty()
|
||||||
|
? ['real_stock' => $directStock, 'images' => $itemImages]
|
||||||
|
: [
|
||||||
|
'attribute_codes' => array_values($metadata['attribute_codes']),
|
||||||
|
'variants' => $variants,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
|
|
||||||
require __DIR__.'/../app/Domains/Auth/routes/api.php';
|
require __DIR__.'/../app/Domains/Auth/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||||
@@ -11,4 +8,3 @@ require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
|||||||
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
require __DIR__.'/../app/Domains/Integration/routes/api.php';
|
||||||
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
require __DIR__.'/../app/Domains/Menu/routes/api.php';
|
||||||
|
|
||||||
|
|||||||
@@ -4,107 +4,70 @@ namespace Tests\Feature\Cart;
|
|||||||
|
|
||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Auth\Models\User;
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Product;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Models\ProductAttribute;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariant;
|
|
||||||
use App\Domains\Catalog\Models\ProductVariantDefinition;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class CartControllerTest extends TestCase
|
class CartControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
|
public function test_cart_items_have_explicit_catalog_and_optional_variant_keys(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('acme', 'Acme', 'acme.com');
|
$this->assertTrue(Schema::hasColumns('carrito_items', [
|
||||||
|
'catalog_item_id',
|
||||||
$this->getJson('/api/tenants/acme/cart')
|
'variant_id',
|
||||||
->assertOk()
|
]));
|
||||||
->assertJson([
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
|
||||||
'data' => [
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
|
||||||
'id' => null,
|
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'status' => 'active',
|
|
||||||
'items' => [],
|
|
||||||
'subtotal' => '0.00',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void
|
public function test_it_adds_a_catalog_item_without_a_variant(): void
|
||||||
{
|
{
|
||||||
$variant = $this->createVariantForTenant('acme', 10, '49.90');
|
$tenant = $this->createTenant('acme');
|
||||||
$attribute = Attribute::query()->create([
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'codigo' => 'color',
|
|
||||||
'nombre' => 'Color',
|
|
||||||
'type' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$productAttribute = ProductAttribute::query()->create([
|
|
||||||
'product_id' => $variant->producto_id,
|
|
||||||
'attribute_id' => $attribute->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ProductVariantDefinition::query()->create([
|
|
||||||
'producto_variante_id' => $variant->id,
|
|
||||||
'products_attribute_id' => $productAttribute->id,
|
|
||||||
'value' => 'Red',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
'buyable_type' => 'variant',
|
'catalog_item_id' => $item->id,
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertCookie('guest_token')
|
->assertCookie('guest_token')
|
||||||
->assertJsonPath('data.tenant_codigo', 'acme')
|
->assertJsonPath('data.items.0.catalog_item_id', $item->id)
|
||||||
|
->assertJsonPath('data.items.0.variant_id', null)
|
||||||
->assertJsonPath('data.items.0.cantidad', 2)
|
->assertJsonPath('data.items.0.cantidad', 2)
|
||||||
->assertJsonPath('data.items.0.precio_unitario', '49.90')
|
->assertJsonPath('data.items.0.precio_unitario', '49.90')
|
||||||
->assertJsonPath('data.items.0.buyable_type', 'variant')
|
->assertJsonPath('data.items.0.product.nombre', 'Item acme')
|
||||||
->assertJsonPath('data.items.0.buyable_id', $variant->id)
|
|
||||||
->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)')
|
|
||||||
->assertJsonPath('data.items.0.product.imagen', null)
|
|
||||||
->assertJsonPath('data.subtotal', '99.80');
|
->assertJsonPath('data.subtotal', '99.80');
|
||||||
|
|
||||||
$this->assertDatabaseHas('carritos', [
|
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'guest_token' => $response->getCookie('guest_token', false)?->getValue(),
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('carrito_items', [
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
'buyable_type' => ProductVariant::class,
|
'catalog_item_id' => $item->id,
|
||||||
'buyable_id' => $variant->id,
|
'variant_id' => null,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
'id' => $item->inventory_id,
|
||||||
'id' => $variant->id,
|
'reserved_stock' => 2,
|
||||||
'stock_real' => 10,
|
|
||||||
'stock_reservado' => 2,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void
|
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
|
||||||
{
|
{
|
||||||
$variant = $this->createVariantForTenant('acme', 12, '25.00');
|
$tenant = $this->createTenant('acme');
|
||||||
|
[$item, $variant] = $this->createVariantItem($tenant, 12, '25.00');
|
||||||
|
|
||||||
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
'buyable_type' => 'variant',
|
'catalog_item_id' => $item->id,
|
||||||
'buyable_id' => $variant->id,
|
'variant_id' => $variant->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
])->assertOk();
|
||||||
|
|
||||||
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
|
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
|
||||||
|
|
||||||
@@ -116,279 +79,38 @@ class CartControllerTest extends TestCase
|
|||||||
[],
|
[],
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||||
json_encode([
|
json_encode([
|
||||||
'buyable_type' => 'variant',
|
'catalog_item_id' => $item->id,
|
||||||
'buyable_id' => $variant->id,
|
'variant_id' => $variant->id,
|
||||||
'cantidad' => 3,
|
'cantidad' => 3,
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.catalog_item_id', $item->id)
|
||||||
|
->assertJsonPath('data.items.0.variant_id', $variant->id)
|
||||||
->assertJsonPath('data.items.0.cantidad', 5)
|
->assertJsonPath('data.items.0.cantidad', 5)
|
||||||
->assertJsonPath('data.subtotal', '125.00');
|
->assertJsonPath('data.subtotal', '125.00');
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 1);
|
|
||||||
$this->assertDatabaseCount('carrito_items', 1);
|
$this->assertDatabaseCount('carrito_items', 1);
|
||||||
$this->assertDatabaseHas('carrito_items', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'buyable_type' => ProductVariant::class,
|
'id' => $variant->inventory_id,
|
||||||
'buyable_id' => $variant->id,
|
'reserved_stock' => 5,
|
||||||
'cantidad' => 5,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'stock_real' => 12,
|
|
||||||
'stock_reservado' => 5,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_updates_item_quantity_and_adjusts_stock(): void
|
public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void
|
||||||
{
|
{
|
||||||
$variant = $this->createVariantForTenant('acme', 10, '15.00');
|
$tenant = $this->createTenant('acme');
|
||||||
|
[$item, $variant] = $this->createVariantItem($tenant, 10, '15.00');
|
||||||
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
'buyable_type' => 'variant',
|
'catalog_item_id' => $item->id,
|
||||||
'buyable_id' => $variant->id,
|
'variant_id' => $variant->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
|
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
|
||||||
$cartItemId = $createResponse->json('data.items.0.id');
|
$cartItemId = $createResponse->json('data.items.0.id');
|
||||||
|
|
||||||
$response = $this->call(
|
|
||||||
'PATCH',
|
|
||||||
"/api/tenants/acme/cart/items/{$cartItemId}",
|
|
||||||
[],
|
|
||||||
['guest_token' => $guestToken],
|
|
||||||
[],
|
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
|
||||||
json_encode([
|
|
||||||
'cantidad' => 5,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonPath('data.items.0.cantidad', 5)
|
|
||||||
->assertJsonPath('data.subtotal', '75.00');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('carrito_items', [
|
|
||||||
'buyable_type' => ProductVariant::class,
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 5,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'stock_real' => 10,
|
|
||||||
'stock_reservado' => 5,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_removes_an_item_and_restores_stock(): void
|
|
||||||
{
|
|
||||||
$variant = $this->createVariantForTenant('acme', 10, '15.00');
|
|
||||||
|
|
||||||
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 4,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
|
|
||||||
$cartItemId = $createResponse->json('data.items.0.id');
|
|
||||||
|
|
||||||
$response = $this->call(
|
|
||||||
'DELETE',
|
|
||||||
"/api/tenants/acme/cart/items/{$cartItemId}",
|
|
||||||
[],
|
|
||||||
['guest_token' => $guestToken],
|
|
||||||
[],
|
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json']
|
|
||||||
);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonPath('data.items', [])
|
|
||||||
->assertJsonPath('data.subtotal', '0.00');
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('carrito_items', 0);
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'stock_real' => 10,
|
|
||||||
'stock_reservado' => 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00');
|
|
||||||
$acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie');
|
|
||||||
$globexVariant = $this->createVariantForTenant('globex', 6, '30.00');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $acmeVariantA->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $acmeVariantB->id,
|
|
||||||
'cantidad' => 2,
|
|
||||||
])
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonPath('data.subtotal', '50.00');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->postJson('/api/tenants/globex/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $globexVariant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 2);
|
|
||||||
$this->assertDatabaseHas('carritos', [
|
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'user_id' => $user->id,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('carritos', [
|
|
||||||
'tenant_codigo' => 'globex',
|
|
||||||
'user_id' => $user->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_variants_from_another_tenant(): void
|
|
||||||
{
|
|
||||||
$this->createVariantForTenant('acme', 10, '10.00');
|
|
||||||
$otherVariant = $this->createVariantForTenant('globex', 10, '20.00');
|
|
||||||
|
|
||||||
$this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $otherVariant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])->assertNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void
|
|
||||||
{
|
|
||||||
$variant = $this->createVariantForTenant('acme', 10, '10.00');
|
|
||||||
|
|
||||||
$this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
|
|
||||||
'cantidad' => 2,
|
|
||||||
])->assertNotFound();
|
|
||||||
|
|
||||||
$this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
|
|
||||||
->assertNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_validates_quantity_and_stock_constraints(): void
|
|
||||||
{
|
|
||||||
$variant = $this->createVariantForTenant('acme', 2, '10.00');
|
|
||||||
|
|
||||||
$this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 0,
|
|
||||||
])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$guestToken = $response->getCookie('guest_token', false)?->getValue();
|
|
||||||
$cartItemId = $response->json('data.items.0.id');
|
|
||||||
|
|
||||||
$response1 = $this->call(
|
|
||||||
'POST',
|
|
||||||
'/api/tenants/acme/cart/items',
|
|
||||||
[],
|
|
||||||
['guest_token' => $guestToken],
|
|
||||||
[],
|
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
|
||||||
json_encode([
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
$response1
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['cantidad']);
|
|
||||||
|
|
||||||
$response2 = $this->call(
|
|
||||||
'PATCH',
|
|
||||||
"/api/tenants/acme/cart/items/{$cartItemId}",
|
|
||||||
[],
|
|
||||||
['guest_token' => $guestToken],
|
|
||||||
[],
|
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
|
||||||
json_encode([
|
|
||||||
'cantidad' => 3,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
$response2
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_only_accepts_buyable_identity_when_adding_an_item(): void
|
|
||||||
{
|
|
||||||
$variant = $this->createVariantForTenant('acme', 10, '10.00');
|
|
||||||
|
|
||||||
$this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'product_variant_id' => $variant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
|
|
||||||
|
|
||||||
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
])->assertOk();
|
|
||||||
|
|
||||||
$cartItemId = $createResponse->json('data.items.0.id');
|
|
||||||
|
|
||||||
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
|
|
||||||
'cantidad' => 2,
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
])->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void
|
|
||||||
{
|
|
||||||
$variant = $this->createVariantForTenant(
|
|
||||||
'acme',
|
|
||||||
0,
|
|
||||||
'10.00',
|
|
||||||
'unlimited',
|
|
||||||
InventoryPolicy::Unlimited,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
|
||||||
'buyable_type' => 'variant',
|
|
||||||
'buyable_id' => $variant->id,
|
|
||||||
'cantidad' => 100,
|
|
||||||
])->assertOk();
|
|
||||||
|
|
||||||
$guestToken = $response->getCookie('guest_token', false)?->getValue();
|
|
||||||
$cartItemId = $response->json('data.items.0.id');
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
|
||||||
'id' => $variant->id,
|
|
||||||
'stock_real' => 0,
|
|
||||||
'stock_reservado' => 100,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->call(
|
$this->call(
|
||||||
'PATCH',
|
'PATCH',
|
||||||
"/api/tenants/acme/cart/items/{$cartItemId}",
|
"/api/tenants/acme/cart/items/{$cartItemId}",
|
||||||
@@ -396,13 +118,14 @@ class CartControllerTest extends TestCase
|
|||||||
['guest_token' => $guestToken],
|
['guest_token' => $guestToken],
|
||||||
[],
|
[],
|
||||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||||
json_encode(['cantidad' => 150]),
|
json_encode(['cantidad' => 5]),
|
||||||
)->assertOk();
|
)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items.0.cantidad', 5);
|
||||||
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->id,
|
'id' => $variant->inventory_id,
|
||||||
'stock_real' => 0,
|
'reserved_stock' => 5,
|
||||||
'stock_reservado' => 150,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->call(
|
$this->call(
|
||||||
@@ -412,84 +135,143 @@ class CartControllerTest extends TestCase
|
|||||||
['guest_token' => $guestToken],
|
['guest_token' => $guestToken],
|
||||||
[],
|
[],
|
||||||
['HTTP_Accept' => 'application/json'],
|
['HTTP_Accept' => 'application/json'],
|
||||||
)->assertOk();
|
)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.items', []);
|
||||||
|
|
||||||
$this->assertDatabaseHas('productos_variantes', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->id,
|
'id' => $variant->inventory_id,
|
||||||
'stock_real' => 0,
|
'reserved_stock' => 0,
|
||||||
'stock_reservado' => 0,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createVariantForTenant(
|
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
|
||||||
string $tenantCode,
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
[$item] = $this->createVariantItem($tenant, 10, '10.00');
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors('variant_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_a_variant_from_another_item_or_tenant(): void
|
||||||
|
{
|
||||||
|
$acme = $this->createTenant('acme');
|
||||||
|
$globex = $this->createTenant('globex');
|
||||||
|
[$acmeItem] = $this->createVariantItem($acme, 10, '10.00');
|
||||||
|
[, $globexVariant] = $this->createVariantItem($globex, 10, '20.00');
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $acmeItem->id,
|
||||||
|
'variant_id' => $globexVariant->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors('variant_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unlimited_inventory_can_be_reserved_and_released(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$item = $this->createDirectItem(
|
||||||
|
$tenant,
|
||||||
|
0,
|
||||||
|
'10.00',
|
||||||
|
InventoryPolicy::Unlimited,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 100,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$guestToken = $response->getCookie('guest_token', false)?->getValue();
|
||||||
|
$cartItemId = $response->json('data.items.0.id');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $item->inventory_id,
|
||||||
|
'real_stock' => 0,
|
||||||
|
'reserved_stock' => 100,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->call(
|
||||||
|
'DELETE',
|
||||||
|
"/api/tenants/acme/cart/items/{$cartItemId}",
|
||||||
|
[],
|
||||||
|
['guest_token' => $guestToken],
|
||||||
|
[],
|
||||||
|
['HTTP_Accept' => 'application/json'],
|
||||||
|
)
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $item->inventory_id,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDirectItem(
|
||||||
|
Tenant $tenant,
|
||||||
int $stock,
|
int $stock,
|
||||||
string $price,
|
string $price,
|
||||||
string $slugPrefix = 'shirt',
|
InventoryPolicy $policy = InventoryPolicy::Tracked,
|
||||||
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
|
): CatalogItem {
|
||||||
): ProductVariant {
|
$inventory = Inventory::query()->create(['real_stock' => $stock]);
|
||||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
|
|
||||||
if (! $tenant) {
|
|
||||||
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
$category = Category::query()->create([
|
return CatalogItem::query()->create([
|
||||||
'tenant_code' => $tenantCode,
|
'tenant_code' => $tenant->codigo,
|
||||||
'nombre' => "{$slugPrefix} category {$tenantCode}",
|
'inventory_id' => $inventory->id,
|
||||||
]);
|
'slug' => 'item-'.$tenant->codigo.'-'.CatalogItem::query()->count(),
|
||||||
|
'nombre' => 'Item '.$tenant->codigo,
|
||||||
$product = Product::query()->create([
|
|
||||||
'tenant_codigo' => $tenantCode,
|
|
||||||
'categoria_id' => $category->id,
|
|
||||||
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
|
|
||||||
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
|
|
||||||
'descripcion' => 'Test product',
|
|
||||||
'precio' => $price,
|
'precio' => $price,
|
||||||
|
'inventory_policy' => $policy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return ProductVariant::query()->create([
|
|
||||||
'producto_id' => $product->id,
|
|
||||||
'inventory_policy' => $inventoryPolicy->value,
|
|
||||||
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
|
|
||||||
'nombre' => ucfirst($slugPrefix).' Variant',
|
|
||||||
'stock' => $stock,
|
|
||||||
'descripcion' => 'Test variant',
|
|
||||||
'precio' => $price,
|
|
||||||
])->load('product');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
/** @return array{CatalogItem, Variant} */
|
||||||
|
private function createVariantItem(Tenant $tenant, int $stock, string $price): array
|
||||||
{
|
{
|
||||||
$hdrKey = (string) Str::uuid();
|
$item = CatalogItem::query()->create([
|
||||||
$ftrKey = (string) Str::uuid();
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'inventory_id' => null,
|
||||||
|
'slug' => 'variant-item-'.$tenant->codigo.'-'.CatalogItem::query()->count(),
|
||||||
|
'nombre' => 'Variant item '.$tenant->codigo,
|
||||||
|
'precio' => $price,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked,
|
||||||
|
]);
|
||||||
|
$inventory = Inventory::query()->create(['real_stock' => $stock]);
|
||||||
|
$variant = $item->variants()->create(['inventory_id' => $inventory->id]);
|
||||||
|
|
||||||
$headerAttachment = Attachment::create([
|
return [$item, $variant];
|
||||||
'key' => $hdrKey,
|
}
|
||||||
'path' => 'tenants/'.$hdrKey.'.png',
|
|
||||||
'filename' => 'logo_header.png',
|
private function createTenant(string $code): Tenant
|
||||||
|
{
|
||||||
|
$headerLogo = $this->createAttachment("{$code}-header");
|
||||||
|
$footerLogo = $this->createAttachment("{$code}-footer");
|
||||||
|
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.local",
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'success_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $headerLogo->id,
|
||||||
|
'footer_logo_id' => $footerLogo->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAttachment(string $name): Attachment
|
||||||
|
{
|
||||||
|
return Attachment::query()->create([
|
||||||
|
'path' => "test/{$name}.png",
|
||||||
|
'filename' => "{$name}.png",
|
||||||
'type' => AttachmentType::Image,
|
'type' => AttachmentType::Image,
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
]);
|
]);
|
||||||
$footerAttachment = Attachment::create([
|
|
||||||
'key' => $ftrKey,
|
|
||||||
'path' => 'tenants/'.$ftrKey.'.png',
|
|
||||||
'filename' => 'logo_footer.png',
|
|
||||||
'type' => AttachmentType::Image,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Tenant::create([
|
|
||||||
'codigo' => $codigo,
|
|
||||||
'nombre' => $nombre,
|
|
||||||
'dominio' => $dominio,
|
|
||||||
'primary_color' => '#111111',
|
|
||||||
'secondary_color' => '#222222',
|
|
||||||
'danger_color' => '#333333',
|
|
||||||
'success_color' => '#28a745',
|
|
||||||
'header_bg_color' => '#444444',
|
|
||||||
'footer_bg_color' => '#444444',
|
|
||||||
'header_logo_id' => $headerAttachment->id,
|
|
||||||
'footer_logo_id' => $footerAttachment->id,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Catalog;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class AttributeControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_creates_a_select_attribute_with_options(): void
|
|
||||||
{
|
|
||||||
$this->createTenant('acme', 'Acme', 'acme.com');
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/attributes', [
|
|
||||||
'codigo' => 'color',
|
|
||||||
'nombre' => 'Color',
|
|
||||||
'is_required' => true,
|
|
||||||
'type' => 'select',
|
|
||||||
'metadata_schema' => [
|
|
||||||
'swatch' => ['type' => 'hex'],
|
|
||||||
],
|
|
||||||
'options' => [
|
|
||||||
[
|
|
||||||
'value' => 'red',
|
|
||||||
'label' => 'Red',
|
|
||||||
'sort_order' => 1,
|
|
||||||
'metadata' => ['hex' => '#ff0000'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'value' => 'blue',
|
|
||||||
'label' => 'Blue',
|
|
||||||
'sort_order' => 2,
|
|
||||||
'metadata' => ['hex' => '#0000ff'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertCreated()
|
|
||||||
->assertJsonPath('data.codigo', 'color')
|
|
||||||
->assertJsonPath('data.type', 'select')
|
|
||||||
->assertJsonPath('data.options.0.value', 'red')
|
|
||||||
->assertJsonPath('data.options.0.label', 'Red')
|
|
||||||
->assertJsonPath('data.options.1.metadata.hex', '#0000ff');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('attribute', [
|
|
||||||
'tenant_codigo' => 'acme',
|
|
||||||
'codigo' => 'color',
|
|
||||||
'type' => 'select',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('attribute_options', [
|
|
||||||
'value' => 'red',
|
|
||||||
'label' => 'Red',
|
|
||||||
'sort_order' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_options_for_string_attributes(): void
|
|
||||||
{
|
|
||||||
$this->createTenant('acme', 'Acme', 'acme.com');
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/attributes', [
|
|
||||||
'codigo' => 'material',
|
|
||||||
'nombre' => 'Material',
|
|
||||||
'type' => 'string',
|
|
||||||
'options' => [
|
|
||||||
['label' => 'Cotton'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertUnprocessable()
|
|
||||||
->assertJsonValidationErrors(['options']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_throws_exception_when_creating_non_select_attribute_with_options_directly_on_model(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createTenant('acme2', 'Acme 2', 'acme2.com');
|
|
||||||
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
$this->expectExceptionMessage('Options are only allowed for select and multiselect attributes.');
|
|
||||||
|
|
||||||
\App\Domains\Catalog\Models\Product::createAttribute($tenant, [
|
|
||||||
'codigo' => 'material2',
|
|
||||||
'nombre' => 'Material 2',
|
|
||||||
'type' => 'string',
|
|
||||||
'options' => [
|
|
||||||
['label' => 'Cotton', 'value' => 'cotton'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
|
||||||
{
|
|
||||||
$hdrKey = (string) \Illuminate\Support\Str::uuid();
|
|
||||||
$ftrKey = (string) \Illuminate\Support\Str::uuid();
|
|
||||||
|
|
||||||
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
|
||||||
'key' => $hdrKey,
|
|
||||||
'path' => 'tenants/' . $hdrKey . '.png',
|
|
||||||
'filename' => 'logo_header.png',
|
|
||||||
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
]);
|
|
||||||
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
|
||||||
'key' => $ftrKey,
|
|
||||||
'path' => 'tenants/' . $ftrKey . '.png',
|
|
||||||
'filename' => 'logo_footer.png',
|
|
||||||
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
|
||||||
'mime_type' => 'image/png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Tenant::create([
|
|
||||||
'codigo' => $codigo,
|
|
||||||
'nombre' => $nombre,
|
|
||||||
'dominio' => $dominio,
|
|
||||||
'primary_color' => '#111111',
|
|
||||||
'secondary_color' => '#222222',
|
|
||||||
'danger_color' => '#333333',
|
|
||||||
'success_color' => '#28a745',
|
|
||||||
'header_bg_color' => '#444444',
|
|
||||||
'footer_bg_color' => '#444444',
|
|
||||||
'header_logo_id' => $headerAttachment->id,
|
|
||||||
'footer_logo_id' => $footerAttachment->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
368
tests/Feature/Catalog/BundleCatalogItemTest.php
Normal file
368
tests/Feature/Catalog/BundleCatalogItemTest.php
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Catalog;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Cart\Resources\CartItemResource;
|
||||||
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BundleCatalogItemTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private CatalogService $catalogService;
|
||||||
|
|
||||||
|
private CatalogInventoryService $inventoryService;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->catalogService = app(CatalogService::class);
|
||||||
|
$this->inventoryService = app(CatalogInventoryService::class);
|
||||||
|
$this->tenant = $this->createTenant('bundle-tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_creates_a_bundle_and_derives_its_inventory(): void
|
||||||
|
{
|
||||||
|
$shirt = $this->createStandardItem('shirt', 11);
|
||||||
|
$cap = $this->createStandardItem('cap', 3);
|
||||||
|
$bundle = $this->createBundle('training-kit', [
|
||||||
|
['catalog_item_id' => $shirt->id, 'quantity' => 2],
|
||||||
|
['catalog_item_id' => $cap->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(CatalogItemType::Bundle, $bundle->type);
|
||||||
|
$this->assertNull($bundle->inventory_id);
|
||||||
|
$this->assertNull($bundle->inventory_policy);
|
||||||
|
$this->assertCount(2, $bundle->bundleComponents);
|
||||||
|
$this->assertSame(3, $bundle->availableStock());
|
||||||
|
|
||||||
|
$this->inventoryService->reserve($bundle, 2);
|
||||||
|
$this->assertSame(4, $shirt->inventory->fresh()->reserved_stock);
|
||||||
|
$this->assertSame(2, $cap->inventory->fresh()->reserved_stock);
|
||||||
|
|
||||||
|
$this->inventoryService->release($bundle, 1);
|
||||||
|
$this->inventoryService->commit($bundle, 1);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $shirt->inventory_id,
|
||||||
|
'real_stock' => 9,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'sold_units' => 2,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $cap->inventory_id,
|
||||||
|
'real_stock' => 2,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'sold_units' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bundle_inventory_rolls_back_when_one_component_has_insufficient_stock(): void
|
||||||
|
{
|
||||||
|
$available = $this->createStandardItem('available', 10);
|
||||||
|
$scarce = $this->createStandardItem('scarce', 1);
|
||||||
|
$bundle = $this->createBundle('invalid-reservation', [
|
||||||
|
['catalog_item_id' => $available->id, 'quantity' => 2],
|
||||||
|
['catalog_item_id' => $scarce->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->inventoryService->reserve($bundle, 2);
|
||||||
|
$this->fail('The reservation should have failed.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->assertSame(0, $available->inventory->fresh()->reserved_stock);
|
||||||
|
$this->assertSame(0, $scarce->inventory->fresh()->reserved_stock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bundle_supports_fixed_variants_and_unlimited_components(): void
|
||||||
|
{
|
||||||
|
$attribute = Attribute::query()->create([
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'codigo' => 'size',
|
||||||
|
'nombre' => 'Size',
|
||||||
|
'type' => FieldType::String,
|
||||||
|
]);
|
||||||
|
$variantItem = $this->catalogService->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'slug' => 'variant-component',
|
||||||
|
'nombre' => 'Variant component',
|
||||||
|
'precio' => 20,
|
||||||
|
'attribute_codes' => [$attribute->codigo],
|
||||||
|
'variants' => [
|
||||||
|
['real_stock' => 6, 'values' => ['size' => 'M']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$unlimited = $this->createStandardItem(
|
||||||
|
'unlimited-component',
|
||||||
|
0,
|
||||||
|
inventoryPolicy: InventoryPolicy::Unlimited,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->createBundle('variant-without-selection', [
|
||||||
|
['catalog_item_id' => $variantItem->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
$this->fail('A variant component should require variant_id.');
|
||||||
|
} catch (ValidationException $exception) {
|
||||||
|
$this->assertArrayHasKey('components.0.variant_id', $exception->errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = $variantItem->variants->firstOrFail();
|
||||||
|
$bundle = $this->createBundle('mixed-inventory-bundle', [
|
||||||
|
[
|
||||||
|
'catalog_item_id' => $variantItem->id,
|
||||||
|
'variant_id' => $variant->id,
|
||||||
|
'quantity' => 2,
|
||||||
|
],
|
||||||
|
['catalog_item_id' => $unlimited->id, 'quantity' => 5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(3, $bundle->availableStock());
|
||||||
|
$this->inventoryService->reserve($bundle, 2);
|
||||||
|
$this->assertSame(4, $variant->inventory->fresh()->reserved_stock);
|
||||||
|
$this->assertSame(10, $unlimited->inventory->fresh()->reserved_stock);
|
||||||
|
|
||||||
|
$unlimitedOnly = $this->createBundle('unlimited-bundle', [
|
||||||
|
['catalog_item_id' => $unlimited->id, 'quantity' => 2],
|
||||||
|
]);
|
||||||
|
$this->assertNull($unlimitedOnly->availableStock());
|
||||||
|
$this->assertTrue($unlimitedOnly->isAvailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cart_treats_the_bundle_as_a_single_catalog_item(): void
|
||||||
|
{
|
||||||
|
$originalItem = $this->createStandardItem('original', 10);
|
||||||
|
$bundle = $this->createBundle('cart-kit', [
|
||||||
|
['catalog_item_id' => $originalItem->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'guest_token' => 'bundle-guest',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $cart->addItem($bundle->id, null, 1);
|
||||||
|
$this->assertSame($bundle->id, $cartItem->catalog_item_id);
|
||||||
|
$this->assertNull($cartItem->variant_id);
|
||||||
|
$this->assertArrayNotHasKey(
|
||||||
|
'components',
|
||||||
|
CartItemResource::make($cartItem->load('catalogItem.attachments'))->resolve(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$cart->updateItem($cartItem->id, 2);
|
||||||
|
|
||||||
|
$this->assertSame(2, $originalItem->inventory->fresh()->reserved_stock);
|
||||||
|
|
||||||
|
$cart->removeItem($cartItem->id);
|
||||||
|
$this->assertSame(0, $originalItem->inventory->fresh()->reserved_stock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_checkout_snapshots_only_the_bundle_and_commits_component_inventory(): void
|
||||||
|
{
|
||||||
|
$component = $this->createStandardItem('checkout-component', 10);
|
||||||
|
$bundle = $this->createBundle('checkout-kit', [
|
||||||
|
['catalog_item_id' => $component->id, 'quantity' => 2],
|
||||||
|
], '100.00');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$cart->addItem($bundle->id, null, 2);
|
||||||
|
|
||||||
|
$checkoutService = app(CheckoutService::class);
|
||||||
|
$purchase = $checkoutService->startCheckout($this->tenant, $user->id, [
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
'dni' => '12345678',
|
||||||
|
'telefono' => '+54 9 341 555-0000',
|
||||||
|
'nombre_apellido' => 'Bundle Buyer',
|
||||||
|
'email' => 'bundle@example.com',
|
||||||
|
]);
|
||||||
|
$purchase->update(['payment_method' => 'transfer']);
|
||||||
|
$checkoutService->confirmPurchase($checkoutService->completePurchase($purchase));
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('compra_items', 1);
|
||||||
|
$this->assertDatabaseHas('compra_items', [
|
||||||
|
'compra_id' => $purchase->id,
|
||||||
|
'source_catalog_item_id' => $bundle->id,
|
||||||
|
'source_variant_id' => null,
|
||||||
|
'nombre' => $bundle->nombre,
|
||||||
|
'cantidad' => 2,
|
||||||
|
'precio_unitario' => '100.00',
|
||||||
|
'total' => '200.00',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $component->inventory_id,
|
||||||
|
'real_stock' => 6,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'sold_units' => 4,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bundle_validation_rejects_invalid_shapes(): void
|
||||||
|
{
|
||||||
|
$standard = $this->createStandardItem('standard', 10);
|
||||||
|
$otherTenant = $this->createTenant('other-tenant');
|
||||||
|
$foreign = $this->createStandardItem('foreign', 10, $otherTenant);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[],
|
||||||
|
[['catalog_item_id' => $foreign->id, 'quantity' => 1]],
|
||||||
|
] as $components) {
|
||||||
|
try {
|
||||||
|
$this->createBundle('invalid-'.count($components), $components);
|
||||||
|
$this->fail('The invalid bundle should not have been created.');
|
||||||
|
} catch (ValidationException) {
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bundle = $this->createBundle('valid-bundle', [
|
||||||
|
['catalog_item_id' => $standard->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(ValidationException::class);
|
||||||
|
$this->createBundle('nested-bundle', [
|
||||||
|
['catalog_item_id' => $bundle->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_catalog_api_creates_and_returns_bundle_details(): void
|
||||||
|
{
|
||||||
|
$component = $this->createStandardItem('api-component', 8);
|
||||||
|
|
||||||
|
$bundleId = $this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
|
||||||
|
'type' => CatalogItemType::Bundle->value,
|
||||||
|
'slug' => 'api-bundle',
|
||||||
|
'nombre' => 'API Bundle',
|
||||||
|
'precio' => 75,
|
||||||
|
'components' => [
|
||||||
|
['catalog_item_id' => $component->id, 'quantity' => 2],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
|
||||||
|
->json('data.id');
|
||||||
|
|
||||||
|
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
|
||||||
|
->assertJsonPath('data.stock_tecnico', 4)
|
||||||
|
->assertJsonCount(1, 'data.components')
|
||||||
|
->assertJsonPath('data.components.0.catalog_item_id', $component->id)
|
||||||
|
->assertJsonPath('data.components.0.variant_id', null)
|
||||||
|
->assertJsonPath('data.components.0.quantity', 2);
|
||||||
|
|
||||||
|
$this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
|
||||||
|
'type' => CatalogItemType::Bundle->value,
|
||||||
|
'slug' => 'bundle-with-stock',
|
||||||
|
'nombre' => 'Invalid Bundle',
|
||||||
|
'precio' => 75,
|
||||||
|
'real_stock' => 10,
|
||||||
|
'components' => [
|
||||||
|
['catalog_item_id' => $component->id, 'quantity' => 1],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['real_stock']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bundle_components_are_deleted_with_the_bundle(): void
|
||||||
|
{
|
||||||
|
$component = $this->createStandardItem('deletion-component', 5);
|
||||||
|
$bundle = $this->createBundle('deletable-bundle', [
|
||||||
|
['catalog_item_id' => $component->id, 'quantity' => 1],
|
||||||
|
]);
|
||||||
|
$this->catalogService->delete($bundle);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('catalog_items', ['id' => $bundle->id]);
|
||||||
|
$this->assertDatabaseMissing('bundle_components', [
|
||||||
|
'bundle_catalog_item_id' => $bundle->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('catalog_items', ['id' => $component->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStandardItem(
|
||||||
|
string $slug,
|
||||||
|
int $stock,
|
||||||
|
?Tenant $tenant = null,
|
||||||
|
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
|
||||||
|
): CatalogItem {
|
||||||
|
$tenant ??= $this->tenant;
|
||||||
|
|
||||||
|
return $this->catalogService->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'slug' => $slug,
|
||||||
|
'nombre' => ucfirst($slug),
|
||||||
|
'precio' => '25.00',
|
||||||
|
'inventory_policy' => $inventoryPolicy,
|
||||||
|
'real_stock' => $stock,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array<string, mixed>> $components */
|
||||||
|
private function createBundle(
|
||||||
|
string $slug,
|
||||||
|
array $components,
|
||||||
|
string $price = '50.00',
|
||||||
|
): CatalogItem {
|
||||||
|
return $this->catalogService->create([
|
||||||
|
'tenant_code' => $this->tenant->codigo,
|
||||||
|
'type' => CatalogItemType::Bundle->value,
|
||||||
|
'slug' => $slug,
|
||||||
|
'nombre' => ucfirst($slug),
|
||||||
|
'precio' => $price,
|
||||||
|
'components' => $components,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTenant(string $code): Tenant
|
||||||
|
{
|
||||||
|
$header = Attachment::query()->create([
|
||||||
|
'path' => "test/{$code}-header.png",
|
||||||
|
'filename' => 'header.png',
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footer = Attachment::query()->create([
|
||||||
|
'path' => "test/{$code}-footer.png",
|
||||||
|
'filename' => 'footer.png',
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Tenant::query()->create([
|
||||||
|
'codigo' => $code,
|
||||||
|
'nombre' => ucfirst($code),
|
||||||
|
'dominio' => "{$code}.local",
|
||||||
|
'primary_color' => '#000000',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'success_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $header->id,
|
||||||
|
'footer_logo_id' => $footer->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user