refactor(catalog): Complete catalog refactor to simplify its data model and its querying.

source commits: refactor/catalog
This commit is contained in:
2026-07-21 09:23:19 -03:00
parent d3182fbd13
commit 29a2ca19a3
116 changed files with 5141 additions and 5217 deletions

View File

@@ -66,6 +66,42 @@ class AttachmentService
$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
{
return trim($path, '/');

View File

@@ -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());
}
}

View File

@@ -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');
}
}

View File

@@ -28,8 +28,10 @@ class CartController extends Controller
$result = $this->cartService->addItem(
$tenant,
$request,
$request->mappedBuyableType(),
(int) $request->validated('buyable_id'),
(int) $request->validated('catalog_item_id'),
$request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null,
(int) $request->validated('cantidad'),
);

View File

@@ -3,9 +3,10 @@
namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Shared\Contracts\Buyable;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -65,15 +66,16 @@ class Cart extends Model
{
$items = $this->relationLoaded('items')
? $this->getRelation('items')
: $this->items()->with('buyable')->get();
: $this->items()->with(['catalogItem', 'variant'])->get();
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,
);
}
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
@@ -81,10 +83,11 @@ class Cart extends Model
]);
}
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
$canonicalType = $buyable::class;
$availableQuantity = $buyable->availableQuantity();
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
@@ -94,15 +97,15 @@ class Cart extends Model
/** @var CartItem|null $item */
$item = $this->items()
->where('buyable_type', $canonicalType)
->where('buyable_id', $buyable->getKey())
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $variantId)
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'buyable_type' => $canonicalType,
'buyable_id' => $buyable->getKey(),
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
} else {
@@ -110,7 +113,7 @@ class Cart extends Model
$item->save();
}
$buyable->reserveStock($quantity);
$inventoryService->reserve($selectedItem, $quantity);
return $item->fresh();
});
@@ -131,9 +134,14 @@ class Cart extends Model
->lockForUpdate()
->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;
$availableQuantity = $buyable->availableQuantity();
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
@@ -146,11 +154,11 @@ class Cart extends Model
$item->save();
if ($delta > 0) {
$buyable->reserveStock($delta);
$inventoryService->reserve($selectedItem, $delta);
}
if ($delta < 0) {
$buyable->decrementReservedStock(abs($delta));
$inventoryService->release($selectedItem, abs($delta));
}
return $item->fresh();
@@ -166,45 +174,96 @@ class Cart extends Model
->lockForUpdate()
->firstOrFail();
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$buyable->decrementReservedStock($item->cantidad);
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(CatalogInventoryService::class)->release(
$selectedItem,
$item->cantidad,
);
$item->delete();
});
}
protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false): Buyable
{
$buyableClass = $this->resolveBuyableClass($buyableType);
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
if ($buyableClass === ProductVariant::class) {
$query = ProductVariant::query()
->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);
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$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) {
$query->lockForUpdate();
}
$buyable = $query->first();
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'),
};
return $query->firstOrFail();
}
}

View File

@@ -2,7 +2,8 @@
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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'cart_id',
'buyable_id',
'buyable_type',
'catalog_item_id',
'variant_id',
'cantidad',
])]
class CartItem extends Model
@@ -24,8 +25,8 @@ class CartItem extends Model
{
return [
'cart_id' => 'integer',
'buyable_id' => 'integer',
'buyable_type' => 'string',
'catalog_item_id' => 'integer',
'variant_id' => 'integer',
'cantidad' => 'integer',
];
}
@@ -38,11 +39,20 @@ class CartItem extends Model
return $this->belongsTo(Cart::class, 'cart_id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function buyable()
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
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;
}
}

View File

@@ -2,8 +2,6 @@
namespace App\Domains\Cart\Requests;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,19 +17,25 @@ class AddCartItemRequest extends FormRequest
*/
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
return [
'buyable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
'buyable_id' => ['required', 'integer'],
'catalog_item_id' => [
'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'],
];
}
public function mappedBuyableType(): string
{
return match ($this->input('buyable_type')) {
'variant' => ProductVariant::class,
'bundle' => Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
}
}

View File

@@ -18,8 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
'buyable_type' => ['prohibited'],
'buyable_id' => ['prohibited'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['prohibited'],
];
}
}

View File

@@ -2,11 +2,12 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\CartItem
* @mixin CartItem
*/
class CartItemResource extends JsonResource
{
@@ -15,42 +16,30 @@ class CartItemResource extends JsonResource
*/
public function toArray(Request $request): array
{
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
$buyable = $this->buyable;
$productName = $buyable?->getName();
$precio = $buyable?->getPrice();
$selectedItem = $this->selectedItem();
$imageUrl = null;
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
$firstAttachment = $buyable->attachments->first();
if ($firstAttachment) {
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
}
if ($selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($precio),
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'buyable_id' => $this->buyable_id,
'product' => $buyable === null ? null : [
'nombre' => $productName,
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'product' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'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
{
return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@@ -21,7 +21,8 @@ class CartResource extends JsonResource
: collect();
$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,
);

View File

@@ -3,11 +3,8 @@
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
@@ -36,12 +33,17 @@ class CartService
/**
* @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);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($buyableType, $buyableId, $quantity);
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart),
@@ -97,16 +99,11 @@ class CartService
protected function loadCart(Cart $cart): Cart
{
return $cart->fresh()->load([
'items.buyable' => function (MorphTo $morphTo): void {
$morphTo->morphWith([
ProductVariant::class => [
'product',
'definitions.productAttribute.attribute',
'attachments',
],
Bundle::class => ['items.variant'],
]);
},
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute',
]);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -2,31 +2,122 @@
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\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 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\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
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)
->with([
'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'],
]);
},
])
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->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,
]));
}
}

View File

@@ -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.');
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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');
}
}

View File

@@ -6,4 +6,12 @@ enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View 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');
}
}

View File

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable([
'tenant_codigo',
@@ -52,27 +51,4 @@ class Attribute extends Model
{
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<ProductAttribute, $this>
*/
public function productAttributes(): HasMany
{
return $this->hasMany(ProductAttribute::class, 'attribute_id');
}
/**
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
*/
public function variantDefinitions(): HasManyThrough
{
return $this->hasManyThrough(
ProductVariantDefinition::class,
ProductAttribute::class,
'attribute_id',
'products_attribute_id',
'id',
'id'
);
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
@@ -26,4 +27,10 @@ class Brand extends Model
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View 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');
}
}

View 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;
}
}

View File

@@ -20,6 +20,9 @@ class Category extends Model
protected $table = 'categorias';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
@@ -55,4 +58,10 @@ class Category extends Model
{
return $this->hasMany(self::class, 'categoria_id');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@@ -2,20 +2,45 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'product_layout',
'group_name',
'group_order',
])]
class FeaturedGroup extends Model
{
protected $fillable = [
'tenant_codigo',
'group_name',
'product_layout',
'group_order',
];
use HasFactory;
public function groupItems(): HasMany
public $timestamps = false;
protected $table = 'featured_groups';
protected function casts(): array
{
return $this->hasMany(GroupItem::class);
return [
'product_layout' => ProductLayout::class,
'group_order' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class)->orderBy('order');
}
}

View 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);
}
}

View File

@@ -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();
}
}

View 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();
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View 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})";
}
}

View 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);
}
}

View File

@@ -1 +0,0 @@

View File

@@ -4,17 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGroupItemRequest extends FormRequest
class CatalogItemDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'order' => ['sometimes', 'integer'],
'variant_id' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -4,20 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ProductDetailRequest extends FormRequest
class FeaturedGroupPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'variant_id' => ['sometimes', 'integer'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -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.');
}
});
}
}

View File

@@ -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'],
];
}
}

View 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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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'),
};
}
}

View File

@@ -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],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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.');
}
});
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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()],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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')),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -2,67 +2,28 @@
namespace App\Domains\Catalog\Resources;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
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
{
return [
'id' => $this->id,
'title' => $this->group_name,
'layout' => $this->product_layout,
'layout' => $this->product_layout->value,
'group_order' => $this->group_order,
'items' => $this->whenLoaded('groupItems', function () use ($request) {
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();
}),
'items' => $this->itemsPage,
];
}
}

View File

@@ -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),
];
}
}

View 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();
}
}

View 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()),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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')),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -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())
),
];
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}),
];
}
}

View 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,
];
}
}

View 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.',
],
]);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -1,31 +1,12 @@
<?php
use App\Domains\Catalog\Controllers\AttributeController;
use App\Domains\Catalog\Controllers\BrandController;
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;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('catalog', [CatalogController::class, 'index']);
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::apiResource('attributes', AttributeController::class);
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',
]);
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
->name('catalog.featured-groups.items.index');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});

View File

@@ -37,13 +37,15 @@ class TenantIntegrationController extends Controller
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try {
$tenantIntegration = $this->tenantIntegrationService->updateOrCreateIntegration(
$this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
$integration,
$request->input('integration_data', [])
);
return response()->json($tenantIntegration);
return response()->json([
'message' => 'integration configured correctly',
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'Error validando la configuración: ' . $e->getMessage()

View File

@@ -2,8 +2,6 @@
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\Purchase\Models\Purchase;
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\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -53,10 +50,10 @@ class PurchaseController extends Controller
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing(['items', 'cart.items']);
$this->loadBuyables($compra->items);
$compra->items->load('imageAttachment');
if ($compra->cart !== null) {
$this->loadBuyables($compra->cart->items);
$this->loadCartCatalogEntries($compra->cart->items);
}
return PurchaseResource::make($compra);
@@ -161,19 +158,13 @@ class PurchaseController extends Controller
/**
* @return list<string>
*/
protected function loadBuyables(Collection $items): void
protected function loadCartCatalogEntries(Collection $items): void
{
$items->load([
'buyable' => function (MorphTo $morphTo): void {
$morphTo->morphWith([
ProductVariant::class => [
'product',
'definitions.productAttribute.attribute',
'attachments',
],
Bundle::class => ['items.variant'],
]);
},
'catalogItem.attachments',
'variant.attachments',
'variant.catalogItem.attachments',
'variant.definitions.itemAttribute.attribute',
]);
}
}

View File

@@ -119,7 +119,7 @@ class Purchase extends Model
$cart = $this->relationLoaded('cart')
? $this->getRelation('cart')
: $this->cart()->with('items.buyable')->first();
: $this->cart()->with(['items.catalogItem', 'items.variant'])->first();
if (! $cart) {
return 0.0;

View File

@@ -2,7 +2,7 @@
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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,8 +10,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'compra_id',
'buyable_id',
'buyable_type',
'source_catalog_item_id',
'source_variant_id',
'image_attachment_id',
'nombre',
'descripcion',
'slug',
'item_nombre',
'variant_attributes',
'cantidad',
'precio_unitario',
'discount_total',
@@ -28,8 +34,10 @@ class PurchaseItem extends Model
{
return [
'compra_id' => 'integer',
'buyable_id' => 'integer',
'buyable_type' => 'string',
'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer',
'image_attachment_id' => 'integer',
'variant_attributes' => 'array',
'cantidad' => 'integer',
'precio_unitario' => 'decimal:2',
'discount_total' => 'decimal:2',
@@ -46,11 +54,9 @@ class PurchaseItem extends Model
return $this->belongsTo(Purchase::class, 'compra_id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function buyable()
/** @return BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo
{
return $this->morphTo();
return $this->belongsTo(Attachment::class, 'image_attachment_id');
}
}

View File

@@ -2,136 +2,114 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Bundle\Models\Bundle;
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\Shared\Contracts\Buyable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin PurchaseItem|CartItem
*/
/** @mixin PurchaseItem|CartItem */
class PurchaseItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Buyable|null $buyable */
$buyable = $this->buyable;
$quantity = (int) ($this->cantidad ?? 0);
$unitPrice = $this->resolveUnitPrice();
$lineTotal = $this->resolveLineTotal($unitPrice, $quantity);
$variant = $buyable instanceof ProductVariant ? $buyable : null;
if ($this->resource instanceof PurchaseItem) {
$imageUrl = $this->imageAttachment?->getTemporaryUrl(1440);
$attributes = $this->variant_attributes ?? [];
$imageUrl = null;
$attributes = [];
if ($this->buyable_type === ProductVariant::class && $buyable) {
$imageUrl = $this->resolveImageUrl($buyable);
$attributes = $this->resolveAttributes($buyable);
return [
'id' => $this->id,
'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario),
'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 [
'id' => $this->id,
'quantity' => $quantity,
'unit_price' => $this->formatMoney($unitPrice),
'line_total' => $this->formatMoney($lineTotal),
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'buyable_id' => $this->buyable_id,
'product' => $variant === null ? null : [
'id' => $variant->product?->id,
'nombre' => $variant->product?->nombre,
'slug' => $variant->product?->slug,
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'product' => $catalogItem === null ? null : [
'id' => $catalogItem->id,
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'slug' => $catalogItem->slug,
'imagen' => $imageUrl,
],
'variant' => $variant === null ? null : [
'id' => $variant->id,
'attributes' => $attributes,
'attributes' => $this->resolveAttributes($variant),
],
'item_details' => $buyable === null ? null : [
'nombre' => $buyable->getName(),
'item_details' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'descripcion' => $catalogItem?->descripcion,
'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) {
ProductVariant::class => 'variant',
Bundle::class => 'bundle',
default => 'unknown',
};
return (float) ($selectedItem?->getPrice() ?? 0);
}
protected function resolveUnitPrice(): float
{
if ($this->resource instanceof PurchaseItem) {
return (float) ($this->precio_unitario ?? 0);
private function resolveImageUrl(
CatalogItem|Variant|null $selectedItem,
?CatalogItem $catalogItem,
): ?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 (float) ($this->buyable?->getPrice() ?? 0);
}
return 0.0;
return $attachment?->getTemporaryUrl(1440);
}
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) {
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')) {
if (! $variant->relationLoaded('definitions')) {
return [];
}
return $buyable->definitions
->map(function ($definition): array {
return [
'name' => (string) ($definition->productAttribute?->attribute?->nombre ?? ''),
'value' => $definition->value,
];
})
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();
}
protected function formatMoney(float|int|string|null $amount): ?string
private function formatMoney(float|int|string|null $amount): string
{
if ($amount === null) {
return null;
}
return number_format((float) $amount, 2, '.', '');
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -89,7 +89,7 @@ class PurchaseResource extends JsonResource
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

View File

@@ -2,19 +2,28 @@
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\CartItem;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class CheckoutService
{
public function __construct(
private readonly AttachmentService $attachmentService,
private readonly CatalogInventoryService $catalogInventoryService,
) {}
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
$cartId = (int) $purchaseData['cart_id'];
@@ -30,7 +39,7 @@ class CheckoutService
]);
}
$cartItems->load('buyable');
$this->loadCartItems($cartItems);
$cart->setRelation('items', $cartItems);
$totalAmount = $cart->getTotalAmount();
@@ -64,7 +73,7 @@ class CheckoutService
$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)) {
return $purchase->load(['items.buyable']);
return $this->loadPurchase($purchase);
}
$purchase->update([
@@ -91,67 +100,71 @@ class CheckoutService
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $purchase->load(['items.buyable']);
return $this->loadPurchase($purchase);
});
}
public function confirmPurchase(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
$snapshotPaths = [];
if ($purchase->items()->exists()) {
return;
try {
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 */
$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);
});
throw $throwable;
}
}
protected function verifyTenantBuyables(Tenant $tenant, Collection $cartItems): void
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
foreach ($cartItems as $item) {
$buyable = $item->buyable;
if ($buyable === null) {
$selectedItem = $item->selectedItem();
if ($selectedItem === null) {
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([
'cart_id' => 'One or more product variants 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.',
'cart_id' => 'One or more catalog items do not belong to the tenant.',
]);
}
}
@@ -159,7 +172,7 @@ class CheckoutService
/**
* @param Collection<int, CartItem> $cartItems
* @return Collection<int, ProductVariant>
* @return Collection<int, CartItem>
*/
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
{
@@ -185,17 +198,33 @@ class CheckoutService
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
protected function buildPurchaseItemsPayload(Collection $cartItems): array
{
protected function buildPurchaseItemsPayload(
Purchase $purchase,
Collection $cartItems,
array &$snapshotPaths = [],
): array {
return $cartItems
->map(function (CartItem $item): array {
$buyable = $item->buyable;
->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item['cantidad'];
$unitPrice = $buyable?->getPrice() ?? 0;
$unitPrice = $selectedItem?->getPrice() ?? 0;
$imageAttachment = $this->snapshotFirstImage($purchase, $item);
if ($imageAttachment !== null) {
$snapshotPaths[] = $imageAttachment->path;
}
return [
'buyable_type' => $item->buyable_type,
'buyable_id' => $item->buyable_id,
'source_catalog_item_id' => $item->catalog_item_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,
'precio_unitario' => $unitPrice,
'discount_total' => null,
@@ -212,11 +241,14 @@ class CheckoutService
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
{
foreach ($cartItems as $item) {
$buyable = $item->buyable;
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
try {
$buyable->buy($quantity);
$this->catalogInventoryService->commit(
$selectedItem,
$quantity,
);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart has inconsistent stock state.',
@@ -230,4 +262,52 @@ class CheckoutService
$cart->save();
$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();
}
}

View File

@@ -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;
}

View File

@@ -3,11 +3,13 @@
namespace App\Domains\Tenant\Models;
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\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([
@@ -71,15 +73,15 @@ class Tenant extends Model
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(
\App\Domains\Menu\Models\Menu::class,
Menu::class,
'tenant_menues',
'tenant_codigo',
'menu_code',
@@ -88,4 +90,3 @@ class Tenant extends Model
)->withTimestamps();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,8 +20,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
\Illuminate\Database\Eloquent\Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
/** @var \Illuminate\Database\Eloquent\Builder $this */
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
/** @var Builder $this */
$perPage = (int) request()->query('per_page', $defaultPerPage);
$page = $page ?? (int) request()->query('page', 1);