From 29a2ca19a33c0b633b5f54b994c99ac192a1e9bd Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 21 Jul 2026 09:23:19 -0300 Subject: [PATCH] refactor(catalog): Complete catalog refactor to simplify its data model and its querying. source commits: refactor/catalog --- .env.example | 2 +- .../Attachable/Services/AttachmentService.php | 36 + app/Domains/Bundle/Models/Bundle.php | 136 --- app/Domains/Bundle/Models/BundleItem.php | 46 - .../Cart/Controllers/CartController.php | 6 +- app/Domains/Cart/Models/Cart.php | 155 +++- app/Domains/Cart/Models/CartItem.php | 30 +- .../Cart/Requests/AddCartItemRequest.php | 30 +- .../UpdateCartItemQuantityRequest.php | 4 +- .../Cart/Resources/CartItemResource.php | 41 +- app/Domains/Cart/Resources/CartResource.php | 3 +- app/Domains/Cart/Services/CartService.php | 29 +- .../Controllers/AttributeController.php | 68 -- .../Catalog/Controllers/BrandController.php | 65 -- .../Catalog/Controllers/CatalogController.php | 123 ++- .../Controllers/CategoryController.php | 85 -- .../Controllers/FeaturedGroupController.php | 51 - .../Controllers/GroupItemController.php | 62 -- .../Catalog/Controllers/ProductController.php | 68 -- .../Controllers/ProductVariantController.php | 87 -- app/Domains/Catalog/Enums/CatalogItemType.php | 15 + app/Domains/Catalog/Enums/InventoryPolicy.php | 8 + app/Domains/Catalog/Enums/ProductLayout.php | 18 + app/Domains/Catalog/Models/Attribute.php | 24 - app/Domains/Catalog/Models/Brand.php | 7 + .../Catalog/Models/BundleComponent.php | 49 + app/Domains/Catalog/Models/CatalogItem.php | 170 ++++ app/Domains/Catalog/Models/Category.php | 9 + app/Domains/Catalog/Models/FeaturedGroup.php | 41 +- app/Domains/Catalog/Models/FeaturedItem.php | 43 + app/Domains/Catalog/Models/GroupItem.php | 29 - app/Domains/Catalog/Models/Inventory.php | 97 ++ app/Domains/Catalog/Models/ItemAttribute.php | 38 + app/Domains/Catalog/Models/Product.php | 304 ------ .../Catalog/Models/ProductAttribute.php | 44 - app/Domains/Catalog/Models/ProductVariant.php | 229 ----- .../Models/ProductVariantDefinition.php | 36 - app/Domains/Catalog/Models/Variant.php | 92 ++ .../Catalog/Models/VariantDefinition.php | 32 + app/Domains/Catalog/Policies/.gitkeep | 1 - ...quest.php => CatalogItemDetailRequest.php} | 5 +- ...quest.php => FeaturedGroupPageRequest.php} | 8 +- .../Requests/StoreAttributeRequest.php | 68 -- .../Catalog/Requests/StoreBrandRequest.php | 24 - .../Requests/StoreCatalogItemRequest.php | 105 +++ .../Catalog/Requests/StoreCategoryRequest.php | 24 - .../Requests/StoreFeaturedGroupRequest.php | 22 - .../Requests/StoreGroupItemRequest.php | 41 - .../Catalog/Requests/StoreProductRequest.php | 50 - .../Requests/StoreProductVariantRequest.php | 43 - .../Requests/UpdateAttributeRequest.php | 62 -- .../Catalog/Requests/UpdateBrandRequest.php | 24 - .../Requests/UpdateCategoryRequest.php | 34 - .../Requests/UpdateFeaturedGroupRequest.php | 22 - .../Catalog/Requests/UpdateProductRequest.php | 55 -- .../Requests/UpdateProductVariantRequest.php | 42 - .../Resources/AttributeOptionResource.php | 26 - .../Catalog/Resources/AttributeResource.php | 28 - .../Catalog/Resources/BrandResource.php | 24 - .../CatalogFeaturedGroupResource.php | 61 +- .../Resources/CatalogFeaturedItemResource.php | 64 ++ .../Resources/CatalogItemDetailResource.php | 136 +++ .../Catalog/Resources/CatalogItemResource.php | 48 + .../Catalog/Resources/CategoryResource.php | 44 - .../Resources/FeaturedGroupResource.php | 21 - .../Catalog/Resources/GroupItemResource.php | 26 - .../Catalog/Resources/ProductResource.php | 56 -- .../ProductVariantDefinitionResource.php | 52 -- .../Resources/ProductVariantResource.php | 60 -- .../Services/CatalogInventoryService.php | 218 +++++ .../Catalog/Services/CatalogService.php | 469 ++++++++++ .../Catalog/Services/FeaturedGroupService.php | 25 - .../Catalog/Services/ProductService.php | 390 -------- app/Domains/Catalog/routes/api.php | 27 +- .../TenantIntegrationController.php | 6 +- .../Controllers/PurchaseController.php | 23 +- app/Domains/Purchase/Models/Purchase.php | 2 +- app/Domains/Purchase/Models/PurchaseItem.php | 26 +- .../Resources/PurchaseItemResource.php | 152 ++- .../Purchase/Resources/PurchaseResource.php | 2 +- .../Purchase/Services/CheckoutService.php | 198 ++-- app/Domains/Shared/Contracts/Buyable.php | 20 - app/Domains/Tenant/Models/Tenant.php | 13 +- app/Providers/AppServiceProvider.php | 5 +- ...7_17_000100_make_group_items_groupable.php | 7 +- ...7_20_000200_create_catalog_items_table.php | 50 + ...0300_move_inventory_from_catalog_items.php | 193 ++++ ...0_000400_remove_cart_item_polymorphism.php | 95 ++ ...0500_remove_purchase_item_polymorphism.php | 119 +++ ...0_create_catalog_featured_items_tables.php | 48 + ...26_07_21_000000_add_bundles_to_catalog.php | 51 + ...07_21_000200_drop_legacy_bundle_tables.php | 18 + database/seeders/AttributeSeeder.php | 68 +- database/seeders/CategorySeeder.php | 2 +- .../FiestaFutbolInfantilProductSeeder.php | 214 +++-- .../ProductCatalogFromImagesSeeder.php | 129 +-- routes/api.php | 4 - tests/Feature/Cart/CartControllerTest.php | 560 ++++------- .../Catalog/AttributeControllerTest.php | 131 --- .../Feature/Catalog/BundleCatalogItemTest.php | 368 ++++++++ .../Feature/Catalog/CatalogControllerTest.php | 198 ++++ .../Catalog/CatalogItemControllerTest.php | 109 +++ .../CatalogItemDetailControllerTest.php | 229 +++++ tests/Feature/Catalog/CatalogSchemaTest.php | 176 ++++ tests/Feature/Catalog/CatalogServiceTest.php | 227 +++++ tests/Feature/Catalog/GroupItemTest.php | 114 --- .../Feature/Catalog/ProductControllerTest.php | 878 ------------------ .../Catalog/ProductVariantAttachmentTest.php | 229 ----- .../Integration/TelepagosWebhookTest.php | 58 +- .../TenantIntegrationControllerTest.php | 46 + .../Purchase/PurchaseCatalogItemTest.php | 152 +++ tests/Feature/Purchase/StorePurchaseTest.php | 122 +-- .../FiestaFutbolInfantilProductSeederTest.php | 89 +- .../ProductCatalogFromImagesSeederTest.php | 68 ++ tests/Unit/Catalog/CatalogModelsTest.php | 189 ++++ .../Catalog/ProductVariantInventoryTest.php | 153 --- 116 files changed, 5141 insertions(+), 5217 deletions(-) delete mode 100644 app/Domains/Bundle/Models/Bundle.php delete mode 100644 app/Domains/Bundle/Models/BundleItem.php delete mode 100644 app/Domains/Catalog/Controllers/AttributeController.php delete mode 100644 app/Domains/Catalog/Controllers/BrandController.php delete mode 100644 app/Domains/Catalog/Controllers/CategoryController.php delete mode 100644 app/Domains/Catalog/Controllers/FeaturedGroupController.php delete mode 100644 app/Domains/Catalog/Controllers/GroupItemController.php delete mode 100644 app/Domains/Catalog/Controllers/ProductController.php delete mode 100644 app/Domains/Catalog/Controllers/ProductVariantController.php create mode 100644 app/Domains/Catalog/Enums/CatalogItemType.php create mode 100644 app/Domains/Catalog/Enums/ProductLayout.php create mode 100644 app/Domains/Catalog/Models/BundleComponent.php create mode 100644 app/Domains/Catalog/Models/CatalogItem.php create mode 100644 app/Domains/Catalog/Models/FeaturedItem.php delete mode 100644 app/Domains/Catalog/Models/GroupItem.php create mode 100644 app/Domains/Catalog/Models/Inventory.php create mode 100644 app/Domains/Catalog/Models/ItemAttribute.php delete mode 100644 app/Domains/Catalog/Models/Product.php delete mode 100644 app/Domains/Catalog/Models/ProductAttribute.php delete mode 100644 app/Domains/Catalog/Models/ProductVariant.php delete mode 100644 app/Domains/Catalog/Models/ProductVariantDefinition.php create mode 100644 app/Domains/Catalog/Models/Variant.php create mode 100644 app/Domains/Catalog/Models/VariantDefinition.php delete mode 100644 app/Domains/Catalog/Policies/.gitkeep rename app/Domains/Catalog/Requests/{UpdateGroupItemRequest.php => CatalogItemDetailRequest.php} (60%) rename app/Domains/Catalog/Requests/{ProductDetailRequest.php => FeaturedGroupPageRequest.php} (61%) delete mode 100644 app/Domains/Catalog/Requests/StoreAttributeRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreBrandRequest.php create mode 100644 app/Domains/Catalog/Requests/StoreCatalogItemRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreCategoryRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreFeaturedGroupRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreGroupItemRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreProductRequest.php delete mode 100644 app/Domains/Catalog/Requests/StoreProductVariantRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateAttributeRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateBrandRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateCategoryRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateFeaturedGroupRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateProductRequest.php delete mode 100644 app/Domains/Catalog/Requests/UpdateProductVariantRequest.php delete mode 100644 app/Domains/Catalog/Resources/AttributeOptionResource.php delete mode 100644 app/Domains/Catalog/Resources/AttributeResource.php delete mode 100644 app/Domains/Catalog/Resources/BrandResource.php create mode 100644 app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php create mode 100644 app/Domains/Catalog/Resources/CatalogItemDetailResource.php create mode 100644 app/Domains/Catalog/Resources/CatalogItemResource.php delete mode 100644 app/Domains/Catalog/Resources/CategoryResource.php delete mode 100644 app/Domains/Catalog/Resources/FeaturedGroupResource.php delete mode 100644 app/Domains/Catalog/Resources/GroupItemResource.php delete mode 100644 app/Domains/Catalog/Resources/ProductResource.php delete mode 100644 app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php delete mode 100644 app/Domains/Catalog/Resources/ProductVariantResource.php create mode 100644 app/Domains/Catalog/Services/CatalogInventoryService.php create mode 100644 app/Domains/Catalog/Services/CatalogService.php delete mode 100644 app/Domains/Catalog/Services/FeaturedGroupService.php delete mode 100644 app/Domains/Catalog/Services/ProductService.php delete mode 100644 app/Domains/Shared/Contracts/Buyable.php create mode 100644 database/migrations/2026_07_20_000200_create_catalog_items_table.php create mode 100644 database/migrations/2026_07_20_000300_move_inventory_from_catalog_items.php create mode 100644 database/migrations/2026_07_20_000400_remove_cart_item_polymorphism.php create mode 100644 database/migrations/2026_07_20_000500_remove_purchase_item_polymorphism.php create mode 100644 database/migrations/2026_07_20_000600_create_catalog_featured_items_tables.php create mode 100644 database/migrations/2026_07_21_000000_add_bundles_to_catalog.php create mode 100644 database/migrations/2026_07_21_000200_drop_legacy_bundle_tables.php delete mode 100644 tests/Feature/Catalog/AttributeControllerTest.php create mode 100644 tests/Feature/Catalog/BundleCatalogItemTest.php create mode 100644 tests/Feature/Catalog/CatalogControllerTest.php create mode 100644 tests/Feature/Catalog/CatalogItemControllerTest.php create mode 100644 tests/Feature/Catalog/CatalogItemDetailControllerTest.php create mode 100644 tests/Feature/Catalog/CatalogSchemaTest.php create mode 100644 tests/Feature/Catalog/CatalogServiceTest.php delete mode 100644 tests/Feature/Catalog/GroupItemTest.php delete mode 100644 tests/Feature/Catalog/ProductControllerTest.php delete mode 100644 tests/Feature/Catalog/ProductVariantAttachmentTest.php create mode 100644 tests/Feature/Integration/TenantIntegrationControllerTest.php create mode 100644 tests/Feature/Purchase/PurchaseCatalogItemTest.php create mode 100644 tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php create mode 100644 tests/Unit/Catalog/CatalogModelsTest.php delete mode 100644 tests/Unit/Catalog/ProductVariantInventoryTest.php diff --git a/.env.example b/.env.example index 91e9d99..a3c1c20 100644 --- a/.env.example +++ b/.env.example @@ -62,7 +62,7 @@ MAIL_FROM_NAME="${APP_NAME}" AWS_ENDPOINT= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION= +AWS_DEFAULT_REGION=garage AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT= AWS_HTTP_VERIFY= diff --git a/app/Domains/Attachable/Services/AttachmentService.php b/app/Domains/Attachable/Services/AttachmentService.php index 0cd3cbe..d3c473e 100644 --- a/app/Domains/Attachable/Services/AttachmentService.php +++ b/app/Domains/Attachable/Services/AttachmentService.php @@ -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, '/'); diff --git a/app/Domains/Bundle/Models/Bundle.php b/app/Domains/Bundle/Models/Bundle.php deleted file mode 100644 index 54d657e..0000000 --- a/app/Domains/Bundle/Models/Bundle.php +++ /dev/null @@ -1,136 +0,0 @@ - 'decimal:2', - ]; - } - - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); - } - - /** - * @return HasMany - */ - public function items(): HasMany - { - return $this->hasMany(BundleItem::class, 'bundle_id'); - } - - /** - * @return MorphMany - */ - 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()); - } -} diff --git a/app/Domains/Bundle/Models/BundleItem.php b/app/Domains/Bundle/Models/BundleItem.php deleted file mode 100644 index 61f011b..0000000 --- a/app/Domains/Bundle/Models/BundleItem.php +++ /dev/null @@ -1,46 +0,0 @@ - 'integer', - 'producto_variante_id' => 'integer', - 'cantidad' => 'integer', - ]; - } - - /** - * @return BelongsTo - */ - public function bundle(): BelongsTo - { - return $this->belongsTo(Bundle::class, 'bundle_id'); - } - - /** - * @return BelongsTo - */ - public function variant(): BelongsTo - { - return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); - } -} diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index 610f67a..bde6581 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -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'), ); diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 25459a4..20ff68d 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -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(); } } diff --git a/app/Domains/Cart/Models/CartItem.php b/app/Domains/Cart/Models/CartItem.php index c60a90f..3841533 100644 --- a/app/Domains/Cart/Models/CartItem.php +++ b/app/Domains/Cart/Models/CartItem.php @@ -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 */ + public function catalogItem(): BelongsTo { - return $this->morphTo(); + return $this->belongsTo(CatalogItem::class); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(Variant::class); + } + + public function selectedItem(): CatalogItem|Variant|null + { + return $this->variant ?? $this->catalogItem; } } diff --git a/app/Domains/Cart/Requests/AddCartItemRequest.php b/app/Domains/Cart/Requests/AddCartItemRequest.php index 2b44551..4ece0a0 100644 --- a/app/Domains/Cart/Requests/AddCartItemRequest.php +++ b/app/Domains/Cart/Requests/AddCartItemRequest.php @@ -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'), - }; - } } diff --git a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php index c3db1ca..73e55a9 100644 --- a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php +++ b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php @@ -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'], ]; } } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index fc13f25..67e8316 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -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, '.', ''); diff --git a/app/Domains/Cart/Resources/CartResource.php b/app/Domains/Cart/Resources/CartResource.php index ff02f81..b26f5ff 100644 --- a/app/Domains/Cart/Resources/CartResource.php +++ b/app/Domains/Cart/Resources/CartResource.php @@ -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, ); diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 17d309b..cb81e35 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -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', ]); } diff --git a/app/Domains/Catalog/Controllers/AttributeController.php b/app/Domains/Catalog/Controllers/AttributeController.php deleted file mode 100644 index 9580296..0000000 --- a/app/Domains/Catalog/Controllers/AttributeController.php +++ /dev/null @@ -1,68 +0,0 @@ -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; - } -} diff --git a/app/Domains/Catalog/Controllers/BrandController.php b/app/Domains/Catalog/Controllers/BrandController.php deleted file mode 100644 index a4591f3..0000000 --- a/app/Domains/Catalog/Controllers/BrandController.php +++ /dev/null @@ -1,65 +0,0 @@ -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; - } -} diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index ec24e4e..f380f28 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -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 */ + 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, + ])); } } diff --git a/app/Domains/Catalog/Controllers/CategoryController.php b/app/Domains/Catalog/Controllers/CategoryController.php deleted file mode 100644 index 47156bc..0000000 --- a/app/Domains/Catalog/Controllers/CategoryController.php +++ /dev/null @@ -1,85 +0,0 @@ -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.'); - } - } -} diff --git a/app/Domains/Catalog/Controllers/FeaturedGroupController.php b/app/Domains/Catalog/Controllers/FeaturedGroupController.php deleted file mode 100644 index 2ada6ef..0000000 --- a/app/Domains/Catalog/Controllers/FeaturedGroupController.php +++ /dev/null @@ -1,51 +0,0 @@ -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); - } -} diff --git a/app/Domains/Catalog/Controllers/GroupItemController.php b/app/Domains/Catalog/Controllers/GroupItemController.php deleted file mode 100644 index 684f28c..0000000 --- a/app/Domains/Catalog/Controllers/GroupItemController.php +++ /dev/null @@ -1,62 +0,0 @@ -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); - } -} diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php deleted file mode 100644 index 6b74fb8..0000000 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ /dev/null @@ -1,68 +0,0 @@ -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; - } -} diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php deleted file mode 100644 index 88b36db..0000000 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ /dev/null @@ -1,87 +0,0 @@ -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; - } -} diff --git a/app/Domains/Catalog/Enums/CatalogItemType.php b/app/Domains/Catalog/Enums/CatalogItemType.php new file mode 100644 index 0000000..b9d45b6 --- /dev/null +++ b/app/Domains/Catalog/Enums/CatalogItemType.php @@ -0,0 +1,15 @@ + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Catalog/Enums/InventoryPolicy.php b/app/Domains/Catalog/Enums/InventoryPolicy.php index 970edb8..031bcd4 100644 --- a/app/Domains/Catalog/Enums/InventoryPolicy.php +++ b/app/Domains/Catalog/Enums/InventoryPolicy.php @@ -6,4 +6,12 @@ enum InventoryPolicy: string { case Tracked = 'tracked'; case Unlimited = 'unlimited'; + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } } diff --git a/app/Domains/Catalog/Enums/ProductLayout.php b/app/Domains/Catalog/Enums/ProductLayout.php new file mode 100644 index 0000000..4a34e49 --- /dev/null +++ b/app/Domains/Catalog/Enums/ProductLayout.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index f516556..7bb7ff1 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -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 - */ - public function productAttributes(): HasMany - { - return $this->hasMany(ProductAttribute::class, 'attribute_id'); - } - - /** - * @return HasManyThrough - */ - public function variantDefinitions(): HasManyThrough - { - return $this->hasManyThrough( - ProductVariantDefinition::class, - ProductAttribute::class, - 'attribute_id', - 'products_attribute_id', - 'id', - 'id' - ); - } } diff --git a/app/Domains/Catalog/Models/Brand.php b/app/Domains/Catalog/Models/Brand.php index 6b28721..d71d88d 100644 --- a/app/Domains/Catalog/Models/Brand.php +++ b/app/Domains/Catalog/Models/Brand.php @@ -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 */ + public function catalogItems(): HasMany + { + return $this->hasMany(CatalogItem::class); + } } diff --git a/app/Domains/Catalog/Models/BundleComponent.php b/app/Domains/Catalog/Models/BundleComponent.php new file mode 100644 index 0000000..4dec8e0 --- /dev/null +++ b/app/Domains/Catalog/Models/BundleComponent.php @@ -0,0 +1,49 @@ + 'integer', + 'component_catalog_item_id' => 'integer', + 'component_variant_id' => 'integer', + 'quantity' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function bundle(): BelongsTo + { + return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id'); + } + + /** @return BelongsTo */ + public function catalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id'); + } + + /** @return BelongsTo */ + public function variant(): BelongsTo + { + return $this->belongsTo(Variant::class, 'component_variant_id'); + } +} diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php new file mode 100644 index 0000000..c5c0fa7 --- /dev/null +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -0,0 +1,170 @@ + 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 */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return BelongsTo */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** @return BelongsTo */ + public function brand(): BelongsTo + { + return $this->belongsTo(Brand::class); + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return HasMany */ + public function bundleComponents(): HasMany + { + return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id'); + } + + /** @return HasMany */ + public function bundleComponentUsages(): HasMany + { + return $this->hasMany(BundleComponent::class, 'component_catalog_item_id'); + } + + /** @return HasMany */ + public function variants(): HasMany + { + return $this->hasMany(Variant::class); + } + + /** @return BelongsToMany */ + public function attributes(): BelongsToMany + { + return $this->belongsToMany(Attribute::class, 'item_attributes') + ->withTimestamps(); + } + + /** @return HasMany */ + public function itemAttributes(): HasMany + { + return $this->hasMany(ItemAttribute::class); + } + + /** @return HasMany */ + public function featuredItems(): HasMany + { + return $this->hasMany(FeaturedItem::class); + } + + /** @return BelongsToMany */ + 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; + } +} diff --git a/app/Domains/Catalog/Models/Category.php b/app/Domains/Catalog/Models/Category.php index 5c95de1..f38fc95 100644 --- a/app/Domains/Catalog/Models/Category.php +++ b/app/Domains/Catalog/Models/Category.php @@ -20,6 +20,9 @@ class Category extends Model protected $table = 'categorias'; + /** + * @return array + */ protected function casts(): array { return [ @@ -55,4 +58,10 @@ class Category extends Model { return $this->hasMany(self::class, 'categoria_id'); } + + /** @return HasMany */ + public function catalogItems(): HasMany + { + return $this->hasMany(CatalogItem::class); + } } diff --git a/app/Domains/Catalog/Models/FeaturedGroup.php b/app/Domains/Catalog/Models/FeaturedGroup.php index 0220d30..b41a6b5 100644 --- a/app/Domains/Catalog/Models/FeaturedGroup.php +++ b/app/Domains/Catalog/Models/FeaturedGroup.php @@ -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 */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return HasMany */ + public function featuredItems(): HasMany + { + return $this->hasMany(FeaturedItem::class)->orderBy('order'); } } diff --git a/app/Domains/Catalog/Models/FeaturedItem.php b/app/Domains/Catalog/Models/FeaturedItem.php new file mode 100644 index 0000000..0db78df --- /dev/null +++ b/app/Domains/Catalog/Models/FeaturedItem.php @@ -0,0 +1,43 @@ + 'integer', + 'catalog_item_id' => 'integer', + 'order' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function featuredGroup(): BelongsTo + { + return $this->belongsTo(FeaturedGroup::class); + } + + /** @return BelongsTo */ + public function catalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class); + } +} diff --git a/app/Domains/Catalog/Models/GroupItem.php b/app/Domains/Catalog/Models/GroupItem.php deleted file mode 100644 index 4baaee6..0000000 --- a/app/Domains/Catalog/Models/GroupItem.php +++ /dev/null @@ -1,29 +0,0 @@ -belongsTo(FeaturedGroup::class); - } - - public function groupable(): MorphTo - { - return $this->morphTo(); - } -} diff --git a/app/Domains/Catalog/Models/Inventory.php b/app/Domains/Catalog/Models/Inventory.php new file mode 100644 index 0000000..4bd24e7 --- /dev/null +++ b/app/Domains/Catalog/Models/Inventory.php @@ -0,0 +1,97 @@ + 0, + 'reserved_stock' => 0, + 'real_stock' => 0, + ]; + + protected function casts(): array + { + return [ + 'sold_units' => 'integer', + 'reserved_stock' => 'integer', + 'real_stock' => 'integer', + ]; + } + + /** @return HasOne */ + public function catalogItem(): HasOne + { + return $this->hasOne(CatalogItem::class); + } + + /** @return HasOne */ + 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(); + } +} diff --git a/app/Domains/Catalog/Models/ItemAttribute.php b/app/Domains/Catalog/Models/ItemAttribute.php new file mode 100644 index 0000000..89e4b31 --- /dev/null +++ b/app/Domains/Catalog/Models/ItemAttribute.php @@ -0,0 +1,38 @@ + */ + public function catalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class); + } + + /** @return BelongsTo */ + public function attribute(): BelongsTo + { + return $this->belongsTo(Attribute::class); + } + + /** @return HasMany */ + public function variantDefinitions(): HasMany + { + return $this->hasMany(VariantDefinition::class); + } +} diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php deleted file mode 100644 index eb2895c..0000000 --- a/app/Domains/Catalog/Models/Product.php +++ /dev/null @@ -1,304 +0,0 @@ - 'integer', - 'brand_id' => 'integer', - 'precio' => 'decimal:2', - ]; - } - - /** - * @return BelongsTo - */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); - } - - /** - * @return BelongsTo - */ - public function category(): BelongsTo - { - return $this->belongsTo(Category::class, 'categoria_id'); - } - - /** - * @return BelongsTo - */ - public function brand(): BelongsTo - { - return $this->belongsTo(Brand::class, 'brand_id'); - } - - /** - * @return HasMany - */ - public function variants(): HasMany - { - return $this->hasMany(ProductVariant::class, 'producto_id'); - } - - /** - * @return BelongsToMany - */ - public function attributes(): BelongsToMany - { - return $this->belongsToMany( - Attribute::class, - 'products_attributes', - 'product_id', - 'attribute_id' - )->withTimestamps(); - } - - /** - * @return HasMany - */ - public function productAttributes(): HasMany - { - return $this->hasMany(ProductAttribute::class, 'product_id'); - } - - /** - * @return BelongsToMany - */ - 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 $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> $variantsData - * @return \Illuminate\Database\Eloquent\Collection - */ - 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 $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 $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 $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; - } -} diff --git a/app/Domains/Catalog/Models/ProductAttribute.php b/app/Domains/Catalog/Models/ProductAttribute.php deleted file mode 100644 index 235df45..0000000 --- a/app/Domains/Catalog/Models/ProductAttribute.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - public function product(): BelongsTo - { - return $this->belongsTo(Product::class, 'product_id'); - } - - /** - * @return BelongsTo - */ - public function attribute(): BelongsTo - { - return $this->belongsTo(Attribute::class, 'attribute_id'); - } - - /** - * @return HasMany - */ - public function variantDefinitions(): HasMany - { - return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id'); - } -} diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php deleted file mode 100644 index 1657d59..0000000 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ /dev/null @@ -1,229 +0,0 @@ - '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 - */ - public function product(): BelongsTo - { - return $this->belongsTo(Product::class, 'producto_id'); - } - - /** - * @return HasMany - */ - public function definitions(): HasMany - { - return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id'); - } - - /** - * @return BelongsToMany - */ - public function attachments(): BelongsToMany - { - return $this->belongsToMany( - Attachment::class, - 'variantes_attachments', - 'variante_id', - 'attachment_id' - )->withTimestamps(); - } - - /** - * @return MorphMany - */ - public function groupItems(): MorphMany - { - return $this->morphMany(GroupItem::class, 'groupable'); - } -} diff --git a/app/Domains/Catalog/Models/ProductVariantDefinition.php b/app/Domains/Catalog/Models/ProductVariantDefinition.php deleted file mode 100644 index 212c781..0000000 --- a/app/Domains/Catalog/Models/ProductVariantDefinition.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public function variant(): BelongsTo - { - return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); - } - - /** - * @return BelongsTo - */ - public function productAttribute(): BelongsTo - { - return $this->belongsTo(ProductAttribute::class, 'products_attribute_id'); - } -} diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php new file mode 100644 index 0000000..44e5920 --- /dev/null +++ b/app/Domains/Catalog/Models/Variant.php @@ -0,0 +1,92 @@ + 'integer', + 'inventory_id' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function catalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class); + } + + /** @return BelongsTo */ + public function inventory(): BelongsTo + { + return $this->belongsTo(Inventory::class); + } + + /** @return HasMany */ + public function definitions(): HasMany + { + return $this->hasMany(VariantDefinition::class); + } + + /** @return BelongsToMany */ + 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})"; + } +} diff --git a/app/Domains/Catalog/Models/VariantDefinition.php b/app/Domains/Catalog/Models/VariantDefinition.php new file mode 100644 index 0000000..2c4fe38 --- /dev/null +++ b/app/Domains/Catalog/Models/VariantDefinition.php @@ -0,0 +1,32 @@ + */ + public function variant(): BelongsTo + { + return $this->belongsTo(Variant::class); + } + + /** @return BelongsTo */ + public function itemAttribute(): BelongsTo + { + return $this->belongsTo(ItemAttribute::class); + } +} diff --git a/app/Domains/Catalog/Policies/.gitkeep b/app/Domains/Catalog/Policies/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/app/Domains/Catalog/Policies/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/Domains/Catalog/Requests/UpdateGroupItemRequest.php b/app/Domains/Catalog/Requests/CatalogItemDetailRequest.php similarity index 60% rename from app/Domains/Catalog/Requests/UpdateGroupItemRequest.php rename to app/Domains/Catalog/Requests/CatalogItemDetailRequest.php index bae952b..c4686dd 100644 --- a/app/Domains/Catalog/Requests/UpdateGroupItemRequest.php +++ b/app/Domains/Catalog/Requests/CatalogItemDetailRequest.php @@ -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> */ public function rules(): array { return [ - 'order' => ['sometimes', 'integer'], + 'variant_id' => ['sometimes', 'integer', 'min:1'], ]; } } diff --git a/app/Domains/Catalog/Requests/ProductDetailRequest.php b/app/Domains/Catalog/Requests/FeaturedGroupPageRequest.php similarity index 61% rename from app/Domains/Catalog/Requests/ProductDetailRequest.php rename to app/Domains/Catalog/Requests/FeaturedGroupPageRequest.php index 05f8962..596cbe5 100644 --- a/app/Domains/Catalog/Requests/ProductDetailRequest.php +++ b/app/Domains/Catalog/Requests/FeaturedGroupPageRequest.php @@ -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 - */ + /** @return array> */ public function rules(): array { return [ - 'variant_id' => ['sometimes', 'integer'], + 'page' => ['sometimes', 'integer', 'min:1'], ]; } } diff --git a/app/Domains/Catalog/Requests/StoreAttributeRequest.php b/app/Domains/Catalog/Requests/StoreAttributeRequest.php deleted file mode 100644 index 70f7cad..0000000 --- a/app/Domains/Catalog/Requests/StoreAttributeRequest.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ - 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.'); - } - }); - } -} diff --git a/app/Domains/Catalog/Requests/StoreBrandRequest.php b/app/Domains/Catalog/Requests/StoreBrandRequest.php deleted file mode 100644 index 5122e77..0000000 --- a/app/Domains/Catalog/Requests/StoreBrandRequest.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'nombre' => ['required', 'string', 'max:255'], - 'descripcion' => ['nullable', 'string'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php new file mode 100644 index 0000000..d0b763a --- /dev/null +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -0,0 +1,105 @@ + */ + 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'], + ]; + } +} diff --git a/app/Domains/Catalog/Requests/StoreCategoryRequest.php b/app/Domains/Catalog/Requests/StoreCategoryRequest.php deleted file mode 100644 index da5d250..0000000 --- a/app/Domains/Catalog/Requests/StoreCategoryRequest.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'], - 'nombre' => ['required', 'string', 'max:255'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/StoreFeaturedGroupRequest.php b/app/Domains/Catalog/Requests/StoreFeaturedGroupRequest.php deleted file mode 100644 index 620182c..0000000 --- a/app/Domains/Catalog/Requests/StoreFeaturedGroupRequest.php +++ /dev/null @@ -1,22 +0,0 @@ - ['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'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/StoreGroupItemRequest.php b/app/Domains/Catalog/Requests/StoreGroupItemRequest.php deleted file mode 100644 index 8368fd5..0000000 --- a/app/Domains/Catalog/Requests/StoreGroupItemRequest.php +++ /dev/null @@ -1,41 +0,0 @@ - ['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'), - }; - } -} diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php deleted file mode 100644 index ec48acd..0000000 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - 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], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php deleted file mode 100644 index 5d280b9..0000000 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ - 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'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/UpdateAttributeRequest.php b/app/Domains/Catalog/Requests/UpdateAttributeRequest.php deleted file mode 100644 index 4daa37c..0000000 --- a/app/Domains/Catalog/Requests/UpdateAttributeRequest.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - 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.'); - } - }); - } -} diff --git a/app/Domains/Catalog/Requests/UpdateBrandRequest.php b/app/Domains/Catalog/Requests/UpdateBrandRequest.php deleted file mode 100644 index aa03dd6..0000000 --- a/app/Domains/Catalog/Requests/UpdateBrandRequest.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'nombre' => ['required', 'string', 'max:255'], - 'descripcion' => ['nullable', 'string'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/UpdateCategoryRequest.php b/app/Domains/Catalog/Requests/UpdateCategoryRequest.php deleted file mode 100644 index 62c677e..0000000 --- a/app/Domains/Catalog/Requests/UpdateCategoryRequest.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - 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'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/UpdateFeaturedGroupRequest.php b/app/Domains/Catalog/Requests/UpdateFeaturedGroupRequest.php deleted file mode 100644 index 754cc32..0000000 --- a/app/Domains/Catalog/Requests/UpdateFeaturedGroupRequest.php +++ /dev/null @@ -1,22 +0,0 @@ - ['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'], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php deleted file mode 100644 index 75b1ef1..0000000 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ - 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()], - ]; - } -} diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php deleted file mode 100644 index e31ea1c..0000000 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - 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'], - ]; - } -} diff --git a/app/Domains/Catalog/Resources/AttributeOptionResource.php b/app/Domains/Catalog/Resources/AttributeOptionResource.php deleted file mode 100644 index 2ffcae9..0000000 --- a/app/Domains/Catalog/Resources/AttributeOptionResource.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'value' => $this->value, - 'label' => $this->label, - 'sort_order' => $this->sort_order, - 'metadata' => $this->metadata, - ]; - } -} diff --git a/app/Domains/Catalog/Resources/AttributeResource.php b/app/Domains/Catalog/Resources/AttributeResource.php deleted file mode 100644 index 85c2789..0000000 --- a/app/Domains/Catalog/Resources/AttributeResource.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ - 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')), - ]; - } -} diff --git a/app/Domains/Catalog/Resources/BrandResource.php b/app/Domains/Catalog/Resources/BrandResource.php deleted file mode 100644 index a76d0aa..0000000 --- a/app/Domains/Catalog/Resources/BrandResource.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'nombre' => $this->nombre, - 'descripcion' => $this->descripcion, - ]; - } -} diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php index 5253eba..0f72528 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php @@ -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 $itemsPage */ + public function __construct($resource, private readonly array $itemsPage) + { + parent::__construct($resource); + } + + /** @return array */ 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, ]; } } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php new file mode 100644 index 0000000..86ff7c8 --- /dev/null +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -0,0 +1,64 @@ + */ + 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 */ + 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), + ]; + } +} diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php new file mode 100644 index 0000000..1c6e1da --- /dev/null +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -0,0 +1,136 @@ + */ + 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 */ + 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 */ + 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 */ + 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(); + } +} diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php new file mode 100644 index 0000000..9aa3a37 --- /dev/null +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -0,0 +1,48 @@ + */ + 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()), + ]; + } +} diff --git a/app/Domains/Catalog/Resources/CategoryResource.php b/app/Domains/Catalog/Resources/CategoryResource.php deleted file mode 100644 index 2f73aa2..0000000 --- a/app/Domains/Catalog/Resources/CategoryResource.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - 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|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, - ]; - } -} diff --git a/app/Domains/Catalog/Resources/FeaturedGroupResource.php b/app/Domains/Catalog/Resources/FeaturedGroupResource.php deleted file mode 100644 index 73d3f6e..0000000 --- a/app/Domains/Catalog/Resources/FeaturedGroupResource.php +++ /dev/null @@ -1,21 +0,0 @@ - $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')), - ]; - } -} diff --git a/app/Domains/Catalog/Resources/GroupItemResource.php b/app/Domains/Catalog/Resources/GroupItemResource.php deleted file mode 100644 index 45aacdf..0000000 --- a/app/Domains/Catalog/Resources/GroupItemResource.php +++ /dev/null @@ -1,26 +0,0 @@ - $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, - ]; - } -} diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php deleted file mode 100644 index c48a615..0000000 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - 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()) - ), - ]; - } -} diff --git a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php b/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php deleted file mode 100644 index 085ab4b..0000000 --- a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ - 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|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; - } -} diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php deleted file mode 100644 index 9c5619f..0000000 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ - 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(); - }), - ]; - } -} diff --git a/app/Domains/Catalog/Services/CatalogInventoryService.php b/app/Domains/Catalog/Services/CatalogInventoryService.php new file mode 100644 index 0000000..f1c01be --- /dev/null +++ b/app/Domains/Catalog/Services/CatalogInventoryService.php @@ -0,0 +1,218 @@ +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 + */ + 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 */ + 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 $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, + ]; + } +} diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php new file mode 100644 index 0000000..cc98f73 --- /dev/null +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -0,0 +1,469 @@ + $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> $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 $data + * @param array> $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 $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 $attributeCodes + * @return array + */ + 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 $data + * @param array $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 $data + * @param array> $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.', + ], + ]); + } + } +} diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php deleted file mode 100644 index f5ab6bb..0000000 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ /dev/null @@ -1,25 +0,0 @@ -update($data); - return $group; - } - - public function deleteGroup(FeaturedGroup $group): bool|null - { - return $group->delete(); - } -} diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php deleted file mode 100644 index 4f3461d..0000000 --- a/app/Domains/Catalog/Services/ProductService.php +++ /dev/null @@ -1,390 +0,0 @@ - $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 $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 $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 $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 $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 $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 $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 $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() - ); - } - } -} diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index f86796e..f39fed6 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -1,31 +1,12 @@ 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']); }); diff --git a/app/Domains/Integration/Controllers/TenantIntegrationController.php b/app/Domains/Integration/Controllers/TenantIntegrationController.php index 308e590..60b324c 100644 --- a/app/Domains/Integration/Controllers/TenantIntegrationController.php +++ b/app/Domains/Integration/Controllers/TenantIntegrationController.php @@ -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() diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 6a5b0c6..fffaf94 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -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 */ - 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', ]); } } diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index fd09ece..888332f 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -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; diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 03e3ddb..2894caa 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -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 */ + public function imageAttachment(): BelongsTo { - return $this->morphTo(); + return $this->belongsTo(Attachment::class, 'image_attachment_id'); } } diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index e1524b5..5712cbf 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -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 - */ + /** @return array */ 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 */ + 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 - */ - 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, '.', ''); } } diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index 2e0f6b8..4c842cf 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -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 diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index e2910bb..1fbe869 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -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 $cartItems - * @return Collection + * @return Collection */ protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart { @@ -185,17 +198,33 @@ class CheckoutService * @param Collection $cartItems * @return array> */ - 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 $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 */ + 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(); + } } diff --git a/app/Domains/Shared/Contracts/Buyable.php b/app/Domains/Shared/Contracts/Buyable.php deleted file mode 100644 index d33c060..0000000 --- a/app/Domains/Shared/Contracts/Buyable.php +++ /dev/null @@ -1,20 +0,0 @@ -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(); } } - diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5ad1abf..8a804f3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/database/migrations/2026_07_17_000100_make_group_items_groupable.php b/database/migrations/2026_07_17_000100_make_group_items_groupable.php index 000c1b9..e3945f5 100644 --- a/database/migrations/2026_07_17_000100_make_group_items_groupable.php +++ b/database/migrations/2026_07_17_000100_make_group_items_groupable.php @@ -1,6 +1,5 @@ update([ - 'groupable_type' => ProductVariant::class, + 'groupable_type' => self::LEGACY_PRODUCT_VARIANT, 'groupable_id' => DB::raw('product_variant_id'), ]); @@ -39,7 +40,7 @@ return new class extends Migration */ public function down(): void { - if (DB::table('group_items')->where('groupable_type', '!=', ProductVariant::class)->exists()) { + if (DB::table('group_items')->where('groupable_type', '!=', self::LEGACY_PRODUCT_VARIANT)->exists()) { throw new RuntimeException('Cannot roll back groupable group items while bundle items exist.'); } diff --git a/database/migrations/2026_07_20_000200_create_catalog_items_table.php b/database/migrations/2026_07_20_000200_create_catalog_items_table.php new file mode 100644 index 0000000..3f87f32 --- /dev/null +++ b/database/migrations/2026_07_20_000200_create_catalog_items_table.php @@ -0,0 +1,50 @@ +id(); + $table->unsignedBigInteger('sold_units')->default(0); + $table->unsignedInteger('reserved_stock')->default(0); + $table->unsignedInteger('real_stock')->default(0); + }); + + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->foreignId('category_id')->nullable()->constrained('categorias')->nullOnDelete(); + $table->foreignId('brand_id')->nullable()->constrained('brands')->nullOnDelete(); + $table->foreignId('inventory_id')->nullable()->unique()->constrained('inventories')->restrictOnDelete(); + $table->string('slug'); + $table->string('nombre'); + $table->text('descripcion')->nullable(); + $table->decimal('precio', 10, 2); + $table->enum('inventory_policy', InventoryPolicy::values()) + ->default(InventoryPolicy::Tracked->value); + $table->boolean('has_tickets')->default(false); + $table->dateTime('maximum_use_date')->nullable(); + $table->dateTime('minimum_use_date')->nullable(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->unique(['tenant_code', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('catalog_items'); + Schema::dropIfExists('inventories'); + } +}; diff --git a/database/migrations/2026_07_20_000300_move_inventory_from_catalog_items.php b/database/migrations/2026_07_20_000300_move_inventory_from_catalog_items.php new file mode 100644 index 0000000..484df33 --- /dev/null +++ b/database/migrations/2026_07_20_000300_move_inventory_from_catalog_items.php @@ -0,0 +1,193 @@ +orderBy('id')->each( + function (object $variant) use (&$inventoryIds): void { + $inventoryIds[$variant->id] = DB::table('inventories')->insertGetId([ + 'sold_units' => $variant->cantidad_vendida, + 'reserved_stock' => $variant->stock_reservado, + 'real_stock' => $variant->stock_real, + ]); + } + ); + + DB::table('productos')->orderBy('id')->each( + function (object $product): void { + $variants = DB::table('productos_variantes') + ->where('producto_id', $product->id) + ->orderBy('id'); + $firstVariant = (clone $variants)->first(); + $inventoryId = null; + + if ($firstVariant === null) { + $inventoryId = DB::table('inventories')->insertGetId([ + 'sold_units' => 0, + 'reserved_stock' => 0, + 'real_stock' => 0, + ]); + } + + DB::table('catalog_items')->insert([ + 'id' => $product->id, + 'tenant_code' => $product->tenant_codigo, + 'category_id' => $product->categoria_id, + 'brand_id' => $product->brand_id, + 'inventory_id' => $inventoryId, + 'slug' => $product->slug, + 'nombre' => $product->nombre, + 'descripcion' => $product->descripcion, + 'precio' => $product->precio, + 'inventory_policy' => $firstVariant->inventory_policy + ?? InventoryPolicy::Tracked->value, + 'has_tickets' => $firstVariant->has_tickets ?? false, + 'minimum_use_date' => $firstVariant->minimum_use_date ?? null, + 'maximum_use_date' => $firstVariant->maximum_use_date ?? null, + ]); + } + ); + + Schema::table('productos_variantes', function (Blueprint $table): void { + $table->dropForeign(['producto_id']); + $table->foreignId('inventory_id')->nullable()->after('producto_id'); + }); + + foreach ($inventoryIds as $variantId => $inventoryId) { + DB::table('productos_variantes')->where('id', $variantId)->update([ + 'inventory_id' => $inventoryId, + ]); + } + + Schema::table('productos_variantes', function (Blueprint $table): void { + $table->renameColumn('producto_id', 'catalog_item_id'); + $table->dropColumn([ + 'inventory_policy', + 'stock_real', + 'stock_reservado', + 'cantidad_vendida', + 'is_placeholder', + 'has_tickets', + 'minimum_use_date', + 'maximum_use_date', + 'created_at', + 'updated_at', + ]); + }); + + Schema::rename('productos_variantes', 'variantes'); + + Schema::table('variantes', function (Blueprint $table): void { + $table->unsignedBigInteger('inventory_id')->nullable(false)->change(); + $table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete(); + $table->foreign('inventory_id')->references('id')->on('inventories')->restrictOnDelete(); + $table->unique('inventory_id'); + }); + + Schema::table('products_attributes', function (Blueprint $table): void { + $table->dropForeign(['product_id']); + $table->renameColumn('product_id', 'catalog_item_id'); + }); + Schema::rename('products_attributes', 'item_attributes'); + Schema::table('item_attributes', function (Blueprint $table): void { + $table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete(); + }); + + Schema::table('productos_variantes_values', function (Blueprint $table): void { + $table->dropForeign('productos_variantes_definiciones_producto_variante_id_foreign'); + $table->dropForeign(['products_attribute_id']); + $table->dropUnique('prod_var_values_variant_product_attr_unique'); + $table->renameColumn('producto_variante_id', 'variant_id'); + $table->renameColumn('products_attribute_id', 'item_attribute_id'); + }); + Schema::rename('productos_variantes_values', 'variant_values'); + Schema::table('variant_values', function (Blueprint $table): void { + $table->foreign('variant_id')->references('id')->on('variantes')->cascadeOnDelete(); + $table->foreign('item_attribute_id')->references('id')->on('item_attributes')->cascadeOnDelete(); + $table->unique(['variant_id', 'item_attribute_id']); + }); + + Schema::table('productos_attachments', function (Blueprint $table): void { + $table->dropForeign(['producto_id']); + $table->dropUnique('productos_attachments_producto_id_attachment_id_unique'); + $table->renameColumn('producto_id', 'catalog_item_id'); + }); + Schema::rename('productos_attachments', 'catalog_items_attachments'); + Schema::table('catalog_items_attachments', function (Blueprint $table): void { + $table->foreignId('variant_id') + ->nullable() + ->after('id') + ->constrained('variantes') + ->cascadeOnDelete(); + $table->unsignedInteger('orden')->default(0)->after('attachment_id'); + $table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete(); + }); + + $catalogAttachmentOrder = []; + DB::table('catalog_items_attachments') + ->orderBy('catalog_item_id') + ->orderBy('id') + ->each(function (object $catalogAttachment) use (&$catalogAttachmentOrder): void { + $catalogItemId = $catalogAttachment->catalog_item_id; + $order = $catalogAttachmentOrder[$catalogItemId] ?? 0; + + DB::table('catalog_items_attachments') + ->where('id', $catalogAttachment->id) + ->update(['orden' => $order]); + + $catalogAttachmentOrder[$catalogItemId] = $order + 1; + }); + + $variantAttachmentOrder = []; + DB::table('variantes_attachments')->orderBy('id')->each( + function (object $variantAttachment) use (&$variantAttachmentOrder): void { + $variant = DB::table('variantes') + ->select('catalog_item_id') + ->where('id', $variantAttachment->variante_id) + ->first(); + + if ($variant === null) { + return; + } + + DB::table('catalog_items_attachments')->insert([ + 'variant_id' => $variantAttachment->variante_id, + 'catalog_item_id' => $variant->catalog_item_id, + 'attachment_id' => $variantAttachment->attachment_id, + 'orden' => $variantAttachmentOrder[$variantAttachment->variante_id] ?? 0, + 'created_at' => $variantAttachment->created_at, + 'updated_at' => $variantAttachment->updated_at, + ]); + + $variantAttachmentOrder[$variantAttachment->variante_id] = + ($variantAttachmentOrder[$variantAttachment->variante_id] ?? 0) + 1; + } + ); + + Schema::drop('variantes_attachments'); + + Schema::table('catalog_items_attachments', function (Blueprint $table): void { + $table->dropColumn(['created_at', 'updated_at']); + $table->unique( + ['catalog_item_id', 'variant_id', 'attachment_id'], + 'catalog_item_variant_attachment_unique' + ); + }); + + Schema::drop('productos'); + } + + public function down(): void + { + throw new RuntimeException('This destructive catalog migration cannot be reversed.'); + } +}; diff --git a/database/migrations/2026_07_20_000400_remove_cart_item_polymorphism.php b/database/migrations/2026_07_20_000400_remove_cart_item_polymorphism.php new file mode 100644 index 0000000..da22a38 --- /dev/null +++ b/database/migrations/2026_07_20_000400_remove_cart_item_polymorphism.php @@ -0,0 +1,95 @@ +foreignId('catalog_item_id')->nullable()->after('cart_id'); + $table->foreignId('variant_id')->nullable()->after('catalog_item_id'); + }); + + DB::table('carrito_items')->orderBy('id')->each(function (object $cartItem): void { + if ($cartItem->buyable_type !== self::LEGACY_PRODUCT_VARIANT) { + if ($cartItem->buyable_type === self::LEGACY_BUNDLE) { + DB::table('bundle_items') + ->where('bundle_id', $cartItem->buyable_id) + ->orderBy('id') + ->each(function (object $bundleItem) use ($cartItem): void { + $variant = DB::table('variantes') + ->select('inventory_id') + ->where('id', $bundleItem->producto_variante_id) + ->first(); + + if ($variant === null) { + return; + } + + $inventory = DB::table('inventories') + ->select('reserved_stock') + ->where('id', $variant->inventory_id) + ->first(); + + if ($inventory === null) { + return; + } + + $reservedAmount = $cartItem->cantidad * $bundleItem->cantidad; + DB::table('inventories')->where('id', $variant->inventory_id)->update([ + 'reserved_stock' => max(0, $inventory->reserved_stock - $reservedAmount), + ]); + }); + } + + DB::table('carrito_items')->where('id', $cartItem->id)->delete(); + + return; + } + + $variant = DB::table('variantes') + ->select(['id', 'catalog_item_id']) + ->where('id', $cartItem->buyable_id) + ->first(); + + if ($variant === null) { + DB::table('carrito_items')->where('id', $cartItem->id)->delete(); + + return; + } + + DB::table('carrito_items')->where('id', $cartItem->id)->update([ + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, + ]); + }); + + Schema::table('carrito_items', function (Blueprint $table): void { + $table->dropUnique(['cart_id', 'buyable_type', 'buyable_id']); + $table->dropColumn(['buyable_type', 'buyable_id']); + }); + + Schema::table('carrito_items', function (Blueprint $table): void { + $table->unsignedBigInteger('catalog_item_id')->nullable(false)->change(); + $table->foreign('catalog_item_id')->references('id')->on('catalog_items')->cascadeOnDelete(); + $table->foreign('variant_id')->references('id')->on('variantes')->cascadeOnDelete(); + $table->unique( + ['cart_id', 'catalog_item_id', 'variant_id'], + 'cart_catalog_item_variant_unique' + ); + }); + } + + public function down(): void + { + throw new RuntimeException('This destructive cart migration cannot be reversed.'); + } +}; diff --git a/database/migrations/2026_07_20_000500_remove_purchase_item_polymorphism.php b/database/migrations/2026_07_20_000500_remove_purchase_item_polymorphism.php new file mode 100644 index 0000000..b877130 --- /dev/null +++ b/database/migrations/2026_07_20_000500_remove_purchase_item_polymorphism.php @@ -0,0 +1,119 @@ +unsignedBigInteger('source_catalog_item_id')->nullable()->after('compra_id'); + $table->unsignedBigInteger('source_variant_id')->nullable()->after('source_catalog_item_id'); + $table->foreignId('image_attachment_id')->nullable()->after('source_variant_id'); + $table->string('nombre')->nullable()->after('image_attachment_id'); + $table->text('descripcion')->nullable()->after('nombre'); + $table->string('slug')->nullable()->after('descripcion'); + $table->string('item_nombre')->nullable()->after('slug'); + $table->json('variant_attributes')->nullable()->after('item_nombre'); + }); + + DB::table('compra_items')->orderBy('id')->each(function (object $purchaseItem): void { + if (in_array($purchaseItem->buyable_type, [CatalogItem::class, self::PREVIOUS_CATALOG_ITEM], true)) { + $catalogItem = DB::table('catalog_items') + ->where('id', $purchaseItem->buyable_id) + ->first(); + + if ($catalogItem !== null) { + DB::table('compra_items')->where('id', $purchaseItem->id)->update([ + 'source_catalog_item_id' => $catalogItem->id, + 'nombre' => $catalogItem->nombre, + 'descripcion' => $catalogItem->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => $catalogItem->nombre, + 'variant_attributes' => json_encode([]), + ]); + + return; + } + } + + if (in_array($purchaseItem->buyable_type, [self::LEGACY_PRODUCT_VARIANT, Variant::class, self::PREVIOUS_VARIANT], true)) { + $variant = DB::table('variantes') + ->select(['id', 'catalog_item_id']) + ->where('id', $purchaseItem->buyable_id) + ->first(); + + if ($variant !== null) { + $catalogItem = DB::table('catalog_items')->find($variant->catalog_item_id); + + if ($catalogItem === null) { + DB::table('compra_items')->where('id', $purchaseItem->id)->delete(); + + return; + } + + $attributes = DB::table('variant_values as variant_value') + ->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id') + ->leftJoin('attribute', 'attribute.id', '=', 'item_attribute.attribute_id') + ->where('variant_value.variant_id', $variant->id) + ->orderBy('variant_value.id') + ->get(['attribute.nombre as name', 'variant_value.value']) + ->map(fn (object $attribute): array => [ + 'name' => (string) ($attribute->name ?? ''), + 'value' => $attribute->value, + ]) + ->all(); + $attributeDescription = collect($attributes) + ->map(fn (array $attribute): string => $attribute['name'] !== '' + ? "{$attribute['name']}: {$attribute['value']}" + : (string) $attribute['value']) + ->filter() + ->implode(', '); + + DB::table('compra_items')->where('id', $purchaseItem->id)->update([ + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => $variant->id, + 'nombre' => $catalogItem->nombre, + 'descripcion' => $catalogItem->descripcion, + 'slug' => $catalogItem->slug, + 'item_nombre' => $attributeDescription === '' + ? $catalogItem->nombre + : "{$catalogItem->nombre} ({$attributeDescription})", + 'variant_attributes' => json_encode($attributes), + ]); + + return; + } + } + + DB::table('compra_items')->where('id', $purchaseItem->id)->delete(); + }); + + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn(['buyable_type', 'buyable_id']); + }); + + Schema::table('compra_items', function (Blueprint $table): void { + $table->unsignedBigInteger('source_catalog_item_id')->nullable(false)->change(); + $table->string('nombre')->nullable(false)->change(); + $table->string('item_nombre')->nullable(false)->change(); + $table->foreign('image_attachment_id')->references('id')->on('attachments')->restrictOnDelete(); + }); + } + + public function down(): void + { + throw new RuntimeException('This destructive purchase migration cannot be reversed.'); + } +}; diff --git a/database/migrations/2026_07_20_000600_create_catalog_featured_items_tables.php b/database/migrations/2026_07_20_000600_create_catalog_featured_items_tables.php new file mode 100644 index 0000000..bb075ae --- /dev/null +++ b/database/migrations/2026_07_20_000600_create_catalog_featured_items_tables.php @@ -0,0 +1,48 @@ +id(); + $table->string('tenant_code'); + $table->enum('product_layout', ProductLayout::values()); + $table->string('group_name'); + $table->unsignedInteger('group_order')->default(0); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + + Schema::create('featured_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('featured_group_id') + ->constrained('featured_groups') + ->cascadeOnDelete(); + $table->foreignId('catalog_item_id') + ->constrained('catalog_items') + ->cascadeOnDelete(); + $table->unsignedInteger('order')->default(0); + }); + } + + public function down(): void + { + throw new RuntimeException('This destructive featured catalog migration cannot be reversed.'); + } +}; diff --git a/database/migrations/2026_07_21_000000_add_bundles_to_catalog.php b/database/migrations/2026_07_21_000000_add_bundles_to_catalog.php new file mode 100644 index 0000000..162e7a2 --- /dev/null +++ b/database/migrations/2026_07_21_000000_add_bundles_to_catalog.php @@ -0,0 +1,51 @@ +enum('type', CatalogItemType::values()) + ->default(CatalogItemType::Standard->value) + ->after('inventory_id'); + $table->enum('inventory_policy', InventoryPolicy::values()) + ->nullable() + ->default(InventoryPolicy::Tracked->value) + ->change(); + }); + + Schema::create('bundle_components', function (Blueprint $table): void { + $table->id(); + $table->foreignId('bundle_catalog_item_id') + ->constrained('catalog_items') + ->cascadeOnDelete(); + $table->foreignId('component_catalog_item_id') + ->constrained('catalog_items') + ->restrictOnDelete(); + $table->foreignId('component_variant_id') + ->nullable() + ->constrained('variantes') + ->restrictOnDelete(); + $table->unsignedInteger('quantity'); + }); + } + + public function down(): void + { + Schema::dropIfExists('bundle_components'); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn('type'); + $table->enum('inventory_policy', InventoryPolicy::values()) + ->nullable(false) + ->default(InventoryPolicy::Tracked->value) + ->change(); + }); + } +}; diff --git a/database/migrations/2026_07_21_000200_drop_legacy_bundle_tables.php b/database/migrations/2026_07_21_000200_drop_legacy_bundle_tables.php new file mode 100644 index 0000000..bd710cc --- /dev/null +++ b/database/migrations/2026_07_21_000200_drop_legacy_bundle_tables.php @@ -0,0 +1,18 @@ +where('tenant_codigo', $tenant->codigo) - ->where('codigo', 'color') - ->first(); - - if ($existingColor) { - Product::deleteAttribute($existingColor); + if ($tenant->codigo === 'fiesta_futbol_infantil') { + Attribute::query() + ->where('tenant_codigo', $tenant->codigo) + ->whereIn('codigo', ['color', 'talle', 'talle_numerico']) + ->delete(); } if ($tenant->codigo !== 'fiesta_futbol_infantil') { - Product::createAttribute($tenant, [ + $this->seedAttribute($tenant, [ 'codigo' => 'color', 'nombre' => 'Color', 'type' => FieldType::Select->value, @@ -67,17 +64,8 @@ class AttributeSeeder extends Seeder } // Seed Talle (Size - Text options) attribute - $existingTalle = Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('codigo', 'talle') - ->first(); - - if ($existingTalle) { - Product::deleteAttribute($existingTalle); - } - if ($tenant->codigo !== 'fiesta_futbol_infantil') { - Product::createAttribute($tenant, [ + $this->seedAttribute($tenant, [ 'codigo' => 'talle', 'nombre' => 'Talle', 'type' => FieldType::Select->value, @@ -92,17 +80,8 @@ class AttributeSeeder extends Seeder } // Seed Talle Numérico (Numeric Size options) attribute - $existingTalleNumerico = Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('codigo', 'talle_numerico') - ->first(); - - if ($existingTalleNumerico) { - Product::deleteAttribute($existingTalleNumerico); - } - if ($tenant->codigo !== 'fiesta_futbol_infantil') { - Product::createAttribute($tenant, [ + $this->seedAttribute($tenant, [ 'codigo' => 'talle_numerico', 'nombre' => 'Talle Numérico', 'type' => FieldType::Select->value, @@ -117,16 +96,7 @@ class AttributeSeeder extends Seeder } // Seed Fecha attribute - $existingFecha = Attribute::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('codigo', 'fecha') - ->first(); - - if ($existingFecha) { - Product::deleteAttribute($existingFecha); - } - - Product::createAttribute($tenant, [ + $this->seedAttribute($tenant, [ 'codigo' => 'fecha', 'nombre' => 'Fecha', 'type' => FieldType::Select->value, @@ -141,4 +111,24 @@ class AttributeSeeder extends Seeder } } + + /** + * @param array $data + */ + private function seedAttribute(Tenant $tenant, array $data): void + { + $options = $data['options'] ?? []; + unset($data['options']); + + $attribute = Attribute::query()->updateOrCreate( + [ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => $data['codigo'], + ], + $data, + ); + + $attribute->options()->delete(); + $attribute->options()->createMany($options); + } } diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php index 7efcb17..4ad5635 100644 --- a/database/seeders/CategorySeeder.php +++ b/database/seeders/CategorySeeder.php @@ -17,7 +17,7 @@ class CategorySeeder extends Seeder 'Pantalones', 'Zapatillas', 'Buzos', - 'Accesorios' + 'Accesorios', ]; foreach ($categories as $nombre) { diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 9b1e1da..b25da17 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -2,20 +2,20 @@ namespace Database\Seeders; -use App\Domains\Bundle\Models\Bundle; +use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\InventoryPolicy; -use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Enums\ProductLayout; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; -use App\Domains\Catalog\Models\Product; -use App\Domains\Catalog\Services\ProductService; +use App\Domains\Catalog\Models\FeaturedGroup; +use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; use RuntimeException; class FiestaFutbolInfantilProductSeeder extends Seeder { - public function __construct(private readonly ProductService $productService) {} + public function __construct(private readonly CatalogService $catalogService) {} public function run(): void { @@ -25,110 +25,160 @@ class FiestaFutbolInfantilProductSeeder extends Seeder throw new RuntimeException("Tenant 'fiesta_futbol_infantil' no encontrado."); } - // Bundles reference product variants, so remove them before reseeding products. - Bundle::query()->where('tenant_codigo', $tenant->codigo)->delete(); + $this->deleteExistingCatalog($tenant); - // Delete existing products for this tenant - $existingProducts = Product::query() - ->where('tenant_codigo', $tenant->codigo) - ->get(); - - foreach ($existingProducts as $product) { - $this->productService->delete($product); - } - - // We need a category, let's just use 'Accesorios' or create an 'Entradas' category - $category = Category::firstOrCreate(['nombre' => 'Entradas', 'tenant_code' => null]); + $ticketCategory = Category::query()->firstOrCreate([ + 'nombre' => 'Entradas', + 'tenant_code' => null, + ]); + $foodCategory = Category::query()->firstOrCreate([ + 'nombre' => 'Gastronomía', + 'tenant_code' => null, + ]); $dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']; - $entradaVariants = []; - // 1. Entrada General - $entrada = $this->productService->create($tenant, [ - 'categoria_id' => $category->id, + $generalAdmission = $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $ticketCategory->id, 'slug' => 'entrada-general', 'nombre' => 'Entrada General', 'descripcion' => 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.', 'precio' => 10000, - 'stock' => 0, 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'attribute_ids' => [Attribute::where('codigo', 'fecha')->where('tenant_codigo', $tenant->codigo)->first()?->id], + 'has_tickets' => true, + 'minimum_use_date' => $dates[0].' 00:00:00', + 'maximum_use_date' => $dates[array_key_last($dates)].' 23:59:59', + 'attribute_codes' => ['fecha'], + 'variants' => array_map( + fn (string $date): array => [ + 'real_stock' => 0, + 'values' => ['fecha' => $date], + ], + $dates, + ), ]); - foreach ($dates as $date) { - $entradaVariants[] = $this->productService->createVariant($entrada, [ - 'stock' => 0, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'has_tickets' => true, - 'minimum_use_date' => $date.' 00:00:00', - 'maximum_use_date' => $date.' 23:59:59', - 'definitions' => [ - [ - 'products_attribute_id' => DB::table('products_attributes') - ->where('product_id', $entrada->id) - ->first()?->id, - 'value' => $date, - ], - ], - ]); - } - - // Other products without variants - $gastronomiaCategory = Category::firstOrCreate(['nombre' => 'Gastronomía', 'tenant_code' => null]); - - $simpleProducts = [ - ['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'cat' => $gastronomiaCategory->id], - ['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'cat' => $gastronomiaCategory->id], - ['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'cat' => $gastronomiaCategory->id], - ['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'cat' => $gastronomiaCategory->id], - ['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'cat' => $category->id], - ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'cat' => $category->id], + $items = [ + ['slug' => 'hamburguesa-papa-frita', 'nombre' => 'Hamburguesa con papa frita', 'precio' => 8000, 'category_id' => $foodCategory->id], + ['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id], + ['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id], + ['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id], + ['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id], + ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id], ]; - $createdProducts = []; - - foreach ($simpleProducts as $p) { - $createdProducts[$p['slug']] = $this->productService->create($tenant, [ - 'categoria_id' => $p['cat'], - 'slug' => $p['slug'], - 'nombre' => $p['nombre'], - 'descripcion' => $p['nombre'], - 'precio' => $p['precio'], - 'stock' => 0, + $createdItems = []; + foreach ($items as $item) { + $createdItems[$item['slug']] = $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'descripcion' => $item['descripcion'] ?? $item['nombre'], 'inventory_policy' => InventoryPolicy::Unlimited->value, + 'real_stock' => 0, + ...$item, ]); } - $allDaysBundle = Bundle::query()->create([ - 'tenant_codigo' => $tenant->codigo, + $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'type' => CatalogItemType::Bundle->value, + 'slug' => 'entrada-general-todos-los-dias', 'nombre' => 'Entrada General - Todos los días', 'descripcion' => 'Incluye una entrada para cada día de la Fiesta Nacional del Fútbol Infantil.', 'precio' => 40000, + 'category_id' => $ticketCategory->id, + 'components' => $generalAdmission->variants + ->map(fn ($variant): array => [ + 'catalog_item_id' => $generalAdmission->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + ]) + ->all(), ]); - foreach ($entradaVariants as $variant) { - $allDaysBundle->items()->create([ - 'producto_variante_id' => $variant->id, - 'cantidad' => 1, - ]); - } - - $foodBundle = Bundle::query()->create([ - 'tenant_codigo' => $tenant->codigo, + $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'type' => CatalogItemType::Bundle->value, + 'slug' => 'combo-2-panchos-2-hamburguesas', 'nombre' => 'Combo 2 Panchos + 2 Hamburguesas', 'descripcion' => 'Incluye 2 panchos y 2 hamburguesas con papa frita.', 'precio' => 24000, + 'category_id' => $foodCategory->id, + 'components' => [ + [ + 'catalog_item_id' => $createdItems['pancho']->id, + 'quantity' => 2, + ], + [ + 'catalog_item_id' => $createdItems['hamburguesa-papa-frita']->id, + 'quantity' => 2, + ], + ], ]); - $foodBundle->items()->createMany([ - [ - 'producto_variante_id' => $createdProducts['pancho']->variants()->sole()->id, - 'cantidad' => 2, + $this->seedFeaturedGroups($tenant); + } + + private function deleteExistingCatalog(Tenant $tenant): void + { + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('type', CatalogItemType::Bundle->value) + ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); + + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('type', CatalogItemType::Standard->value) + ->each(fn (CatalogItem $item) => $this->catalogService->delete($item)); + } + + private function seedFeaturedGroups(Tenant $tenant): void + { + FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); + + $groups = [ + 'Entradas' => [ + 'entrada-general', + 'entrada-general-todos-los-dias', ], - [ - 'producto_variante_id' => $createdProducts['hamburguesa-papa-frita']->variants()->sole()->id, - 'cantidad' => 2, + 'Estacionamiento' => [ + 'estacionamiento-auto', + 'estacionamiento-moto', ], - ]); + 'Comidas' => [ + 'hamburguesa-papa-frita', + 'pancho', + 'combo-2-panchos-2-hamburguesas', + ], + 'Bebidas' => [ + 'coca-cola-500ml', + 'agua-mineral-1l', + ], + ]; + + $catalogItems = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereIn('slug', collect($groups)->flatten()->all()) + ->get() + ->keyBy('slug'); + + $groupOrder = 0; + foreach ($groups as $groupName => $slugs) { + $featuredGroup = FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'product_layout' => ProductLayout::Row, + 'group_name' => $groupName, + 'group_order' => $groupOrder++, + ]); + + $featuredGroup->featuredItems()->createMany( + collect($slugs)->values()->map( + fn (string $slug, int $order): array => [ + 'catalog_item_id' => $catalogItems->get($slug)->id, + 'order' => $order, + ] + )->all() + ); + } } } diff --git a/database/seeders/ProductCatalogFromImagesSeeder.php b/database/seeders/ProductCatalogFromImagesSeeder.php index 3d9d97b..108fdb6 100644 --- a/database/seeders/ProductCatalogFromImagesSeeder.php +++ b/database/seeders/ProductCatalogFromImagesSeeder.php @@ -3,15 +3,16 @@ namespace Database\Seeders; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Brand; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; -use App\Domains\Catalog\Models\Product; -use App\Domains\Catalog\Services\ProductService; +use App\Domains\Catalog\Models\FeaturedGroup; +use App\Domains\Catalog\Services\CatalogService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Seeder; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use RuntimeException; @@ -65,7 +66,7 @@ class ProductCatalogFromImagesSeeder extends Seeder 'istockphoto-1675347112-2048x2048.jpg', ]; - public function __construct(private readonly ProductService $productService) {} + public function __construct(private readonly CatalogService $catalogService) {} /** * Run the database seeds. @@ -140,14 +141,14 @@ class ProductCatalogFromImagesSeeder extends Seeder ->unique() ->values(); - $existingProducts = Product::query() - ->where('tenant_codigo', $tenant->codigo) + $existingProducts = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) ->whereIn('slug', $productSlugsToDelete) - ->with(['attachments', 'variants.attachments']) + ->with('variants:id,catalog_item_id,inventory_id') ->get(); foreach ($existingProducts as $existingProduct) { - $this->productService->delete($existingProduct); + $this->catalogService->delete($existingProduct); } foreach ($productsToSeed as $catalogProduct) { @@ -158,6 +159,34 @@ class ProductCatalogFromImagesSeeder extends Seeder $catalogProduct['variant_groups'], ); } + + $this->seedFeaturedProducts($tenant); + } + + private function seedFeaturedProducts(Tenant $tenant): void + { + FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->delete(); + + $group = FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'product_layout' => ProductLayout::ColumnWithImage, + 'group_name' => 'Productos', + 'group_order' => 0, + ]); + + $items = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->orderBy('id') + ->get('id'); + + $group->featuredItems()->createMany( + $items->values()->map( + fn (CatalogItem $item, int $order): array => [ + 'catalog_item_id' => $item->id, + 'order' => $order, + ] + )->all() + ); } /** @@ -189,33 +218,21 @@ class ProductCatalogFromImagesSeeder extends Seeder throw new RuntimeException("Category '{$metadata['category_name']}' not found."); } - $attributeIds = Attribute::query() + $attributeCodes = Attribute::query() ->where('tenant_codigo', $tenant->codigo) ->whereIn('codigo', $metadata['attribute_codes']) - ->pluck('id', 'codigo'); + ->pluck('codigo'); - if ($attributeIds->count() !== count($metadata['attribute_codes'])) { + if ($attributeCodes->count() !== count($metadata['attribute_codes'])) { throw new RuntimeException("Missing attributes for product '{$metadata['product_slug']}'."); } - $product = $this->productService->create($tenant, [ - 'categoria_id' => $category->id, - 'brand_id' => $brand?->id, - 'slug' => $metadata['product_slug'], - 'nombre' => $metadata['product_name'], - 'descripcion' => $metadata['description'], - 'precio' => $pricing['price'], - 'inventory_policy' => InventoryPolicy::Tracked->value, - 'attribute_ids' => array_values($attributeIds->all()), - ]); - - $productAttributeIds = DB::table('products_attributes') - ->where('product_id', $product->id) - ->pluck('id', 'attribute_id'); - $sizes = $metadata['type'] === 'zapatillas' ? self::SHOE_SIZES : self::CLOTHING_SIZES; + $variants = []; + $itemImages = []; + $directStock = 0; foreach ($variantGroups as $variantGroup) { $images = array_map( @@ -223,47 +240,29 @@ class ProductCatalogFromImagesSeeder extends Seeder $variantGroup['files'] ); - if ($attributeIds->isEmpty()) { - $this->productService->createVariant($product, [ - 'stock' => $variantGroup['stock'], - 'inventory_policy' => InventoryPolicy::Tracked->value, - 'definitions' => [], - 'images' => $images, - ]); + if ($attributeCodes->isEmpty()) { + $directStock = $variantGroup['stock']; + $itemImages = [...$itemImages, ...$images]; continue; } foreach ($sizes as $size) { - $definitions = []; + $values = []; - if (isset($attributeIds['color']) && $variantGroup['metadata']['color'] !== null) { - $colorProductAttributeId = $productAttributeIds[$attributeIds['color']] ?? null; - - if ($colorProductAttributeId === null) { - throw new RuntimeException("Missing product attribute for color on product '{$metadata['product_slug']}'."); - } - - $definitions[] = [ - 'products_attribute_id' => $colorProductAttributeId, - 'value' => $variantGroup['metadata']['color'], - ]; + if ($attributeCodes->contains('color') && $variantGroup['metadata']['color'] !== null) { + $values['color'] = $variantGroup['metadata']['color']; } $sizeAttributeCode = $metadata['type'] === 'zapatillas' ? 'talle_numerico' : 'talle'; - $sizeProductAttributeId = $productAttributeIds[$attributeIds[$sizeAttributeCode]] ?? null; - - if ($sizeProductAttributeId === null) { + if (! $attributeCodes->contains($sizeAttributeCode)) { throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'."); } - $definitions[] = [ - 'products_attribute_id' => $sizeProductAttributeId, - 'value' => $size, - ]; + $values[$sizeAttributeCode] = $size; // Override stock for specific variants as requested by the user $stock = $variantGroup['stock']; @@ -280,14 +279,30 @@ class ProductCatalogFromImagesSeeder extends Seeder $stock = 0; } - $this->productService->createVariant($product, [ - 'stock' => $stock, - 'inventory_policy' => InventoryPolicy::Tracked->value, - 'definitions' => $definitions, + $variants[] = [ + 'real_stock' => $stock, + 'values' => $values, 'images' => $images, - ]); + ]; } } + + $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'brand_id' => $brand?->id, + 'slug' => $metadata['product_slug'], + 'nombre' => $metadata['product_name'], + 'descripcion' => $metadata['description'], + 'precio' => $pricing['price'], + 'inventory_policy' => InventoryPolicy::Tracked->value, + ...($attributeCodes->isEmpty() + ? ['real_stock' => $directStock, 'images' => $itemImages] + : [ + 'attribute_codes' => array_values($metadata['attribute_codes']), + 'variants' => $variants, + ]), + ]); } /** diff --git a/routes/api.php b/routes/api.php index 5ce1f7f..a4b3d0d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,5 @@ createTenant('acme', 'Acme', 'acme.com'); - - $this->getJson('/api/tenants/acme/cart') - ->assertOk() - ->assertJson([ - 'data' => [ - 'id' => null, - 'tenant_codigo' => 'acme', - 'status' => 'active', - 'items' => [], - 'subtotal' => '0.00', - ], - ]); + $this->assertTrue(Schema::hasColumns('carrito_items', [ + 'catalog_item_id', + 'variant_id', + ])); + $this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type')); + $this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id')); } - public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void + public function test_it_adds_a_catalog_item_without_a_variant(): void { - $variant = $this->createVariantForTenant('acme', 10, '49.90'); - $attribute = Attribute::query()->create([ - 'tenant_codigo' => 'acme', - 'codigo' => 'color', - 'nombre' => 'Color', - 'type' => 'string', - ]); - - $productAttribute = ProductAttribute::query()->create([ - 'product_id' => $variant->producto_id, - 'attribute_id' => $attribute->id, - ]); - - ProductVariantDefinition::query()->create([ - 'producto_variante_id' => $variant->id, - 'products_attribute_id' => $productAttribute->id, - 'value' => 'Red', - ]); + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem($tenant, 10, '49.90'); $response = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $item->id, 'cantidad' => 2, ]); $response ->assertOk() ->assertCookie('guest_token') - ->assertJsonPath('data.tenant_codigo', 'acme') + ->assertJsonPath('data.items.0.catalog_item_id', $item->id) + ->assertJsonPath('data.items.0.variant_id', null) ->assertJsonPath('data.items.0.cantidad', 2) ->assertJsonPath('data.items.0.precio_unitario', '49.90') - ->assertJsonPath('data.items.0.buyable_type', 'variant') - ->assertJsonPath('data.items.0.buyable_id', $variant->id) - ->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)') - ->assertJsonPath('data.items.0.product.imagen', null) + ->assertJsonPath('data.items.0.product.nombre', 'Item acme') ->assertJsonPath('data.subtotal', '99.80'); - $this->assertDatabaseHas('carritos', [ - 'tenant_codigo' => 'acme', - 'guest_token' => $response->getCookie('guest_token', false)?->getValue(), - 'status' => 'active', - ]); - $this->assertDatabaseHas('carrito_items', [ - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, + 'catalog_item_id' => $item->id, + 'variant_id' => null, 'cantidad' => 2, ]); - - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 10, - 'stock_reservado' => 2, + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'reserved_stock' => 2, ]); } - public function test_it_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void + public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void { - $variant = $this->createVariantForTenant('acme', 12, '25.00'); + $tenant = $this->createTenant('acme'); + [$item, $variant] = $this->createVariantItem($tenant, 12, '25.00'); $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $item->id, + 'variant_id' => $variant->id, 'cantidad' => 2, - ]); + ])->assertOk(); $guestToken = $firstResponse->getCookie('guest_token', false)?->getValue(); @@ -116,279 +79,38 @@ class CartControllerTest extends TestCase [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode([ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $item->id, + 'variant_id' => $variant->id, 'cantidad' => 3, - ]) + ]), ); $response ->assertOk() + ->assertJsonPath('data.items.0.catalog_item_id', $item->id) + ->assertJsonPath('data.items.0.variant_id', $variant->id) ->assertJsonPath('data.items.0.cantidad', 5) ->assertJsonPath('data.subtotal', '125.00'); - $this->assertDatabaseCount('carritos', 1); $this->assertDatabaseCount('carrito_items', 1); - $this->assertDatabaseHas('carrito_items', [ - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, - 'cantidad' => 5, - ]); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 12, - 'stock_reservado' => 5, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 5, ]); } - public function test_it_updates_item_quantity_and_adjusts_stock(): void + public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void { - $variant = $this->createVariantForTenant('acme', 10, '15.00'); - + $tenant = $this->createTenant('acme'); + [$item, $variant] = $this->createVariantItem($tenant, 10, '15.00'); $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $item->id, + 'variant_id' => $variant->id, 'cantidad' => 2, ]); - $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); $cartItemId = $createResponse->json('data.items.0.id'); - $response = $this->call( - 'PATCH', - "/api/tenants/acme/cart/items/{$cartItemId}", - [], - ['guest_token' => $guestToken], - [], - ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], - json_encode([ - 'cantidad' => 5, - ]) - ); - - $response - ->assertOk() - ->assertJsonPath('data.items.0.cantidad', 5) - ->assertJsonPath('data.subtotal', '75.00'); - - $this->assertDatabaseHas('carrito_items', [ - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, - 'cantidad' => 5, - ]); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 10, - 'stock_reservado' => 5, - ]); - } - - public function test_it_removes_an_item_and_restores_stock(): void - { - $variant = $this->createVariantForTenant('acme', 10, '15.00'); - - $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 4, - ]); - - $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); - $cartItemId = $createResponse->json('data.items.0.id'); - - $response = $this->call( - 'DELETE', - "/api/tenants/acme/cart/items/{$cartItemId}", - [], - ['guest_token' => $guestToken], - [], - ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'] - ); - - $response - ->assertOk() - ->assertJsonPath('data.items', []) - ->assertJsonPath('data.subtotal', '0.00'); - - $this->assertDatabaseCount('carrito_items', 0); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 10, - 'stock_reservado' => 0, - ]); - } - - public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void - { - $user = User::factory()->create(); - $acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00'); - $acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie'); - $globexVariant = $this->createVariantForTenant('globex', 6, '30.00'); - - $this->actingAs($user) - ->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $acmeVariantA->id, - 'cantidad' => 1, - ]) - ->assertOk(); - - $this->actingAs($user) - ->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $acmeVariantB->id, - 'cantidad' => 2, - ]) - ->assertOk() - ->assertJsonPath('data.subtotal', '50.00'); - - $this->actingAs($user) - ->postJson('/api/tenants/globex/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $globexVariant->id, - 'cantidad' => 1, - ]) - ->assertOk(); - - $this->assertDatabaseCount('carritos', 2); - $this->assertDatabaseHas('carritos', [ - 'tenant_codigo' => 'acme', - 'user_id' => $user->id, - ]); - $this->assertDatabaseHas('carritos', [ - 'tenant_codigo' => 'globex', - 'user_id' => $user->id, - ]); - } - - public function test_it_rejects_variants_from_another_tenant(): void - { - $this->createVariantForTenant('acme', 10, '10.00'); - $otherVariant = $this->createVariantForTenant('globex', 10, '20.00'); - - $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $otherVariant->id, - 'cantidad' => 1, - ])->assertNotFound(); - } - - public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void - { - $variant = $this->createVariantForTenant('acme', 10, '10.00'); - - $this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [ - 'cantidad' => 2, - ])->assertNotFound(); - - $this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}") - ->assertNotFound(); - } - - public function test_it_validates_quantity_and_stock_constraints(): void - { - $variant = $this->createVariantForTenant('acme', 2, '10.00'); - - $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 0, - ])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']); - - $response = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 2, - ]); - - $guestToken = $response->getCookie('guest_token', false)?->getValue(); - $cartItemId = $response->json('data.items.0.id'); - - $response1 = $this->call( - 'POST', - '/api/tenants/acme/cart/items', - [], - ['guest_token' => $guestToken], - [], - ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], - json_encode([ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 1, - ]) - ); - - $response1 - ->assertUnprocessable() - ->assertJsonValidationErrors(['cantidad']); - - $response2 = $this->call( - 'PATCH', - "/api/tenants/acme/cart/items/{$cartItemId}", - [], - ['guest_token' => $guestToken], - [], - ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], - json_encode([ - 'cantidad' => 3, - ]) - ); - - $response2 - ->assertUnprocessable() - ->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']); - } - - public function test_it_only_accepts_buyable_identity_when_adding_an_item(): void - { - $variant = $this->createVariantForTenant('acme', 10, '10.00'); - - $this->postJson('/api/tenants/acme/cart/items', [ - 'product_variant_id' => $variant->id, - 'cantidad' => 1, - ])->assertUnprocessable() - ->assertJsonValidationErrors(['buyable_type', 'buyable_id']); - - $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 1, - ])->assertOk(); - - $cartItemId = $createResponse->json('data.items.0.id'); - - $this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [ - 'cantidad' => 2, - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - ])->assertUnprocessable() - ->assertJsonValidationErrors(['buyable_type', 'buyable_id']); - } - - public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void - { - $variant = $this->createVariantForTenant( - 'acme', - 0, - '10.00', - 'unlimited', - InventoryPolicy::Unlimited, - ); - - $response = $this->postJson('/api/tenants/acme/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, - 'cantidad' => 100, - ])->assertOk(); - - $guestToken = $response->getCookie('guest_token', false)?->getValue(); - $cartItemId = $response->json('data.items.0.id'); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 0, - 'stock_reservado' => 100, - ]); - $this->call( 'PATCH', "/api/tenants/acme/cart/items/{$cartItemId}", @@ -396,13 +118,14 @@ class CartControllerTest extends TestCase ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], - json_encode(['cantidad' => 150]), - )->assertOk(); + json_encode(['cantidad' => 5]), + ) + ->assertOk() + ->assertJsonPath('data.items.0.cantidad', 5); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 0, - 'stock_reservado' => 150, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 5, ]); $this->call( @@ -412,84 +135,143 @@ class CartControllerTest extends TestCase ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json'], - )->assertOk(); + ) + ->assertOk() + ->assertJsonPath('data.items', []); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 0, - 'stock_reservado' => 0, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 0, ]); } - protected function createVariantForTenant( - string $tenantCode, + public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void + { + $tenant = $this->createTenant('acme'); + [$item] = $this->createVariantItem($tenant, 10, '10.00'); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + } + + public function test_it_rejects_a_variant_from_another_item_or_tenant(): void + { + $acme = $this->createTenant('acme'); + $globex = $this->createTenant('globex'); + [$acmeItem] = $this->createVariantItem($acme, 10, '10.00'); + [, $globexVariant] = $this->createVariantItem($globex, 10, '20.00'); + + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $acmeItem->id, + 'variant_id' => $globexVariant->id, + 'cantidad' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + } + + public function test_unlimited_inventory_can_be_reserved_and_released(): void + { + $tenant = $this->createTenant('acme'); + $item = $this->createDirectItem( + $tenant, + 0, + '10.00', + InventoryPolicy::Unlimited, + ); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 100, + ])->assertOk(); + + $guestToken = $response->getCookie('guest_token', false)?->getValue(); + $cartItemId = $response->json('data.items.0.id'); + + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'real_stock' => 0, + 'reserved_stock' => 100, + ]); + + $this->call( + 'DELETE', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json'], + ) + ->assertOk(); + + $this->assertDatabaseHas('inventories', [ + 'id' => $item->inventory_id, + 'reserved_stock' => 0, + ]); + } + + private function createDirectItem( + Tenant $tenant, int $stock, string $price, - string $slugPrefix = 'shirt', - InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, - ): ProductVariant { - $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); - if (! $tenant) { - $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); - } + InventoryPolicy $policy = InventoryPolicy::Tracked, + ): CatalogItem { + $inventory = Inventory::query()->create(['real_stock' => $stock]); - $category = Category::query()->create([ - 'tenant_code' => $tenantCode, - 'nombre' => "{$slugPrefix} category {$tenantCode}", - ]); - - $product = Product::query()->create([ - 'tenant_codigo' => $tenantCode, - 'categoria_id' => $category->id, - 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), - 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", - 'descripcion' => 'Test product', + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'item-'.$tenant->codigo.'-'.CatalogItem::query()->count(), + 'nombre' => 'Item '.$tenant->codigo, 'precio' => $price, + 'inventory_policy' => $policy, ]); - - return ProductVariant::query()->create([ - 'producto_id' => $product->id, - 'inventory_policy' => $inventoryPolicy->value, - 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), - 'nombre' => ucfirst($slugPrefix).' Variant', - 'stock' => $stock, - 'descripcion' => 'Test variant', - 'precio' => $price, - ])->load('product'); } - protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant + /** @return array{CatalogItem, Variant} */ + private function createVariantItem(Tenant $tenant, int $stock, string $price): array { - $hdrKey = (string) Str::uuid(); - $ftrKey = (string) Str::uuid(); + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => null, + 'slug' => 'variant-item-'.$tenant->codigo.'-'.CatalogItem::query()->count(), + 'nombre' => 'Variant item '.$tenant->codigo, + 'precio' => $price, + 'inventory_policy' => InventoryPolicy::Tracked, + ]); + $inventory = Inventory::query()->create(['real_stock' => $stock]); + $variant = $item->variants()->create(['inventory_id' => $inventory->id]); - $headerAttachment = Attachment::create([ - 'key' => $hdrKey, - 'path' => 'tenants/'.$hdrKey.'.png', - 'filename' => 'logo_header.png', + return [$item, $variant]; + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $footerAttachment = Attachment::create([ - 'key' => $ftrKey, - 'path' => 'tenants/'.$ftrKey.'.png', - 'filename' => 'logo_footer.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - return Tenant::create([ - 'codigo' => $codigo, - 'nombre' => $nombre, - 'dominio' => $dominio, - 'primary_color' => '#111111', - 'secondary_color' => '#222222', - 'danger_color' => '#333333', - 'success_color' => '#28a745', - 'header_bg_color' => '#444444', - 'footer_bg_color' => '#444444', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); } } diff --git a/tests/Feature/Catalog/AttributeControllerTest.php b/tests/Feature/Catalog/AttributeControllerTest.php deleted file mode 100644 index cb1a789..0000000 --- a/tests/Feature/Catalog/AttributeControllerTest.php +++ /dev/null @@ -1,131 +0,0 @@ -createTenant('acme', 'Acme', 'acme.com'); - - $response = $this->postJson('/api/tenants/acme/attributes', [ - 'codigo' => 'color', - 'nombre' => 'Color', - 'is_required' => true, - 'type' => 'select', - 'metadata_schema' => [ - 'swatch' => ['type' => 'hex'], - ], - 'options' => [ - [ - 'value' => 'red', - 'label' => 'Red', - 'sort_order' => 1, - 'metadata' => ['hex' => '#ff0000'], - ], - [ - 'value' => 'blue', - 'label' => 'Blue', - 'sort_order' => 2, - 'metadata' => ['hex' => '#0000ff'], - ], - ], - ]); - - $response - ->assertCreated() - ->assertJsonPath('data.codigo', 'color') - ->assertJsonPath('data.type', 'select') - ->assertJsonPath('data.options.0.value', 'red') - ->assertJsonPath('data.options.0.label', 'Red') - ->assertJsonPath('data.options.1.metadata.hex', '#0000ff'); - - $this->assertDatabaseHas('attribute', [ - 'tenant_codigo' => 'acme', - 'codigo' => 'color', - 'type' => 'select', - ]); - - $this->assertDatabaseHas('attribute_options', [ - 'value' => 'red', - 'label' => 'Red', - 'sort_order' => 1, - ]); - } - - public function test_it_rejects_options_for_string_attributes(): void - { - $this->createTenant('acme', 'Acme', 'acme.com'); - - $response = $this->postJson('/api/tenants/acme/attributes', [ - 'codigo' => 'material', - 'nombre' => 'Material', - 'type' => 'string', - 'options' => [ - ['label' => 'Cotton'], - ], - ]); - - $response - ->assertUnprocessable() - ->assertJsonValidationErrors(['options']); - } - - public function test_it_throws_exception_when_creating_non_select_attribute_with_options_directly_on_model(): void - { - $tenant = $this->createTenant('acme2', 'Acme 2', 'acme2.com'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Options are only allowed for select and multiselect attributes.'); - - \App\Domains\Catalog\Models\Product::createAttribute($tenant, [ - 'codigo' => 'material2', - 'nombre' => 'Material 2', - 'type' => 'string', - 'options' => [ - ['label' => 'Cotton', 'value' => 'cotton'], - ], - ]); - } - - protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant - { - $hdrKey = (string) \Illuminate\Support\Str::uuid(); - $ftrKey = (string) \Illuminate\Support\Str::uuid(); - - $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ - 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', - 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ - 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', - 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - return Tenant::create([ - 'codigo' => $codigo, - 'nombre' => $nombre, - 'dominio' => $dominio, - 'primary_color' => '#111111', - 'secondary_color' => '#222222', - 'danger_color' => '#333333', - 'success_color' => '#28a745', - 'header_bg_color' => '#444444', - 'footer_bg_color' => '#444444', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - } -} diff --git a/tests/Feature/Catalog/BundleCatalogItemTest.php b/tests/Feature/Catalog/BundleCatalogItemTest.php new file mode 100644 index 0000000..d0b404d --- /dev/null +++ b/tests/Feature/Catalog/BundleCatalogItemTest.php @@ -0,0 +1,368 @@ +catalogService = app(CatalogService::class); + $this->inventoryService = app(CatalogInventoryService::class); + $this->tenant = $this->createTenant('bundle-tenant'); + } + + public function test_it_creates_a_bundle_and_derives_its_inventory(): void + { + $shirt = $this->createStandardItem('shirt', 11); + $cap = $this->createStandardItem('cap', 3); + $bundle = $this->createBundle('training-kit', [ + ['catalog_item_id' => $shirt->id, 'quantity' => 2], + ['catalog_item_id' => $cap->id, 'quantity' => 1], + ]); + + $this->assertSame(CatalogItemType::Bundle, $bundle->type); + $this->assertNull($bundle->inventory_id); + $this->assertNull($bundle->inventory_policy); + $this->assertCount(2, $bundle->bundleComponents); + $this->assertSame(3, $bundle->availableStock()); + + $this->inventoryService->reserve($bundle, 2); + $this->assertSame(4, $shirt->inventory->fresh()->reserved_stock); + $this->assertSame(2, $cap->inventory->fresh()->reserved_stock); + + $this->inventoryService->release($bundle, 1); + $this->inventoryService->commit($bundle, 1); + + $this->assertDatabaseHas('inventories', [ + 'id' => $shirt->inventory_id, + 'real_stock' => 9, + 'reserved_stock' => 0, + 'sold_units' => 2, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $cap->inventory_id, + 'real_stock' => 2, + 'reserved_stock' => 0, + 'sold_units' => 1, + ]); + } + + public function test_bundle_inventory_rolls_back_when_one_component_has_insufficient_stock(): void + { + $available = $this->createStandardItem('available', 10); + $scarce = $this->createStandardItem('scarce', 1); + $bundle = $this->createBundle('invalid-reservation', [ + ['catalog_item_id' => $available->id, 'quantity' => 2], + ['catalog_item_id' => $scarce->id, 'quantity' => 1], + ]); + + try { + $this->inventoryService->reserve($bundle, 2); + $this->fail('The reservation should have failed.'); + } catch (\InvalidArgumentException) { + $this->assertSame(0, $available->inventory->fresh()->reserved_stock); + $this->assertSame(0, $scarce->inventory->fresh()->reserved_stock); + } + } + + public function test_bundle_supports_fixed_variants_and_unlimited_components(): void + { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Size', + 'type' => FieldType::String, + ]); + $variantItem = $this->catalogService->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'variant-component', + 'nombre' => 'Variant component', + 'precio' => 20, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [ + ['real_stock' => 6, 'values' => ['size' => 'M']], + ], + ]); + $unlimited = $this->createStandardItem( + 'unlimited-component', + 0, + inventoryPolicy: InventoryPolicy::Unlimited, + ); + + try { + $this->createBundle('variant-without-selection', [ + ['catalog_item_id' => $variantItem->id, 'quantity' => 1], + ]); + $this->fail('A variant component should require variant_id.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('components.0.variant_id', $exception->errors()); + } + + $variant = $variantItem->variants->firstOrFail(); + $bundle = $this->createBundle('mixed-inventory-bundle', [ + [ + 'catalog_item_id' => $variantItem->id, + 'variant_id' => $variant->id, + 'quantity' => 2, + ], + ['catalog_item_id' => $unlimited->id, 'quantity' => 5], + ]); + + $this->assertSame(3, $bundle->availableStock()); + $this->inventoryService->reserve($bundle, 2); + $this->assertSame(4, $variant->inventory->fresh()->reserved_stock); + $this->assertSame(10, $unlimited->inventory->fresh()->reserved_stock); + + $unlimitedOnly = $this->createBundle('unlimited-bundle', [ + ['catalog_item_id' => $unlimited->id, 'quantity' => 2], + ]); + $this->assertNull($unlimitedOnly->availableStock()); + $this->assertTrue($unlimitedOnly->isAvailable()); + } + + public function test_cart_treats_the_bundle_as_a_single_catalog_item(): void + { + $originalItem = $this->createStandardItem('original', 10); + $bundle = $this->createBundle('cart-kit', [ + ['catalog_item_id' => $originalItem->id, 'quantity' => 1], + ]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'guest_token' => 'bundle-guest', + 'status' => 'active', + ]); + + $cartItem = $cart->addItem($bundle->id, null, 1); + $this->assertSame($bundle->id, $cartItem->catalog_item_id); + $this->assertNull($cartItem->variant_id); + $this->assertArrayNotHasKey( + 'components', + CartItemResource::make($cartItem->load('catalogItem.attachments'))->resolve(), + ); + + $cart->updateItem($cartItem->id, 2); + + $this->assertSame(2, $originalItem->inventory->fresh()->reserved_stock); + + $cart->removeItem($cartItem->id); + $this->assertSame(0, $originalItem->inventory->fresh()->reserved_stock); + } + + public function test_checkout_snapshots_only_the_bundle_and_commits_component_inventory(): void + { + $component = $this->createStandardItem('checkout-component', 10); + $bundle = $this->createBundle('checkout-kit', [ + ['catalog_item_id' => $component->id, 'quantity' => 2], + ], '100.00'); + $user = User::factory()->create(); + $cart = Cart::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($bundle->id, null, 2); + + $checkoutService = app(CheckoutService::class); + $purchase = $checkoutService->startCheckout($this->tenant, $user->id, [ + 'cart_id' => $cart->id, + 'dni' => '12345678', + 'telefono' => '+54 9 341 555-0000', + 'nombre_apellido' => 'Bundle Buyer', + 'email' => 'bundle@example.com', + ]); + $purchase->update(['payment_method' => 'transfer']); + $checkoutService->confirmPurchase($checkoutService->completePurchase($purchase)); + + $this->assertDatabaseCount('compra_items', 1); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $bundle->id, + 'source_variant_id' => null, + 'nombre' => $bundle->nombre, + 'cantidad' => 2, + 'precio_unitario' => '100.00', + 'total' => '200.00', + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $component->inventory_id, + 'real_stock' => 6, + 'reserved_stock' => 0, + 'sold_units' => 4, + ]); + } + + public function test_bundle_validation_rejects_invalid_shapes(): void + { + $standard = $this->createStandardItem('standard', 10); + $otherTenant = $this->createTenant('other-tenant'); + $foreign = $this->createStandardItem('foreign', 10, $otherTenant); + + foreach ([ + [], + [['catalog_item_id' => $foreign->id, 'quantity' => 1]], + ] as $components) { + try { + $this->createBundle('invalid-'.count($components), $components); + $this->fail('The invalid bundle should not have been created.'); + } catch (ValidationException) { + $this->assertTrue(true); + } + } + + $bundle = $this->createBundle('valid-bundle', [ + ['catalog_item_id' => $standard->id, 'quantity' => 1], + ]); + + $this->expectException(ValidationException::class); + $this->createBundle('nested-bundle', [ + ['catalog_item_id' => $bundle->id, 'quantity' => 1], + ]); + } + + public function test_catalog_api_creates_and_returns_bundle_details(): void + { + $component = $this->createStandardItem('api-component', 8); + + $bundleId = $this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [ + 'type' => CatalogItemType::Bundle->value, + 'slug' => 'api-bundle', + 'nombre' => 'API Bundle', + 'precio' => 75, + 'components' => [ + ['catalog_item_id' => $component->id, 'quantity' => 2], + ], + ]) + ->assertCreated() + ->assertJsonPath('data.type', CatalogItemType::Bundle->value) + ->json('data.id'); + + $this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}") + ->assertOk() + ->assertJsonPath('data.type', CatalogItemType::Bundle->value) + ->assertJsonPath('data.stock_tecnico', 4) + ->assertJsonCount(1, 'data.components') + ->assertJsonPath('data.components.0.catalog_item_id', $component->id) + ->assertJsonPath('data.components.0.variant_id', null) + ->assertJsonPath('data.components.0.quantity', 2); + + $this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [ + 'type' => CatalogItemType::Bundle->value, + 'slug' => 'bundle-with-stock', + 'nombre' => 'Invalid Bundle', + 'precio' => 75, + 'real_stock' => 10, + 'components' => [ + ['catalog_item_id' => $component->id, 'quantity' => 1], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['real_stock']); + } + + public function test_bundle_components_are_deleted_with_the_bundle(): void + { + $component = $this->createStandardItem('deletion-component', 5); + $bundle = $this->createBundle('deletable-bundle', [ + ['catalog_item_id' => $component->id, 'quantity' => 1], + ]); + $this->catalogService->delete($bundle); + + $this->assertDatabaseMissing('catalog_items', ['id' => $bundle->id]); + $this->assertDatabaseMissing('bundle_components', [ + 'bundle_catalog_item_id' => $bundle->id, + ]); + $this->assertDatabaseHas('catalog_items', ['id' => $component->id]); + } + + private function createStandardItem( + string $slug, + int $stock, + ?Tenant $tenant = null, + InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, + ): CatalogItem { + $tenant ??= $this->tenant; + + return $this->catalogService->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => $slug, + 'nombre' => ucfirst($slug), + 'precio' => '25.00', + 'inventory_policy' => $inventoryPolicy, + 'real_stock' => $stock, + ]); + } + + /** @param array> $components */ + private function createBundle( + string $slug, + array $components, + string $price = '50.00', + ): CatalogItem { + return $this->catalogService->create([ + 'tenant_code' => $this->tenant->codigo, + 'type' => CatalogItemType::Bundle->value, + 'slug' => $slug, + 'nombre' => ucfirst($slug), + 'precio' => $price, + 'components' => $components, + ]); + } + + private function createTenant(string $code): Tenant + { + $header = Attachment::query()->create([ + 'path' => "test/{$code}-header.png", + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footer = Attachment::query()->create([ + 'path' => "test/{$code}-footer.png", + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $header->id, + 'footer_logo_id' => $footer->id, + ]); + } +} diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php new file mode 100644 index 0000000..ce9c7b3 --- /dev/null +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -0,0 +1,198 @@ +createTenant('catalog-index'); + $row = $this->createGroup($tenant, ProductLayout::Row, 'Row', 2); + $cart = $this->createGroup($tenant, ProductLayout::ColumnWithCart, 'Cart', 1); + + $directInventory = Inventory::query()->create([ + 'real_stock' => 10, + 'reserved_stock' => 2, + ]); + $directItem = $this->createItem($tenant, 'Direct', $directInventory); + $row->featuredItems()->create(['catalog_item_id' => $directItem->id]); + + $variantItem = $this->createItem($tenant, 'Variants'); + $firstInventory = Inventory::query()->create([ + 'real_stock' => 5, + 'reserved_stock' => 1, + ]); + $secondInventory = Inventory::query()->create([ + 'real_stock' => 4, + 'reserved_stock' => 1, + ]); + $variantItem->variants()->create(['inventory_id' => $firstInventory->id]); + $variantItem->variants()->create(['inventory_id' => $secondInventory->id]); + $cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]); + + $response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog"); + + $response + ->assertOk() + ->assertJsonCount(2) + ->assertJsonPath('0.title', 'Cart') + ->assertJsonPath('0.layout', ProductLayout::ColumnWithCart->value) + ->assertJsonPath('0.items.meta.current_page', 1) + ->assertJsonPath('0.items.data.0.nombre', 'Variants') + ->assertJsonPath('0.items.data.0.descripcion', 'Variants description') + ->assertJsonPath('0.items.data.0.precio', '100.00') + ->assertJsonPath('0.items.data.0.stock_tecnico', 7) + ->assertJsonCount(2, '0.items.data.0.variants') + ->assertJsonPath('0.items.data.0.variants.0.stock_tecnico', 4) + ->assertJsonPath('0.items.data.0.variants.1.stock_tecnico', 3) + ->assertJsonPath('1.title', 'Row') + ->assertJsonPath('1.items.data.0.stock_tecnico', 8) + ->assertJsonCount(0, '1.items.data.0.variants'); + } + + public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void + { + Storage::fake('s3'); + $tenant = $this->createTenant('catalog-images'); + $group = $this->createGroup($tenant, ProductLayout::ColumnWithImage, 'Images'); + + $directItem = $this->createItem($tenant, 'Direct image'); + $directImage = $this->createAttachment('direct'); + $directItem->attachments()->attach($directImage, ['orden' => 0]); + $group->featuredItems()->create([ + 'catalog_item_id' => $directItem->id, + 'order' => 0, + ]); + + $variantItem = $this->createItem($tenant, 'Variant image'); + $variantInventory = Inventory::query()->create(); + $variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]); + $variantImage = $this->createAttachment('variant'); + $variant->attachments()->attach($variantImage, ['orden' => 0]); + $group->featuredItems()->create([ + 'catalog_item_id' => $variantItem->id, + 'order' => 1, + ]); + + $emptyItem = $this->createItem($tenant, 'No image'); + $group->featuredItems()->create([ + 'catalog_item_id' => $emptyItem->id, + 'order' => 2, + ]); + + $response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog"); + + $response + ->assertOk() + ->assertJsonPath('0.layout', ProductLayout::ColumnWithImage->value) + ->assertJsonPath('0.items.data.0.nombre', 'Direct image') + ->assertJsonPath('0.items.data.0.precio', '100.00') + ->assertJsonPath('0.items.data.0.image', fn (?string $url): bool => str_contains($url ?? '', 'direct.png')) + ->assertJsonPath('0.items.data.1.image', fn (?string $url): bool => str_contains($url ?? '', 'variant.png')) + ->assertJsonPath('0.items.data.2.image', null); + } + + public function test_index_always_returns_page_one_and_group_endpoint_returns_other_pages(): void + { + $tenant = $this->createTenant('catalog-pagination'); + $group = $this->createGroup($tenant, ProductLayout::Row, 'Paginated'); + + foreach (range(1, 13) as $number) { + $item = $this->createItem($tenant, "Item {$number}"); + $group->featuredItems()->create([ + 'catalog_item_id' => $item->id, + 'order' => $number, + ]); + } + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog?page=2") + ->assertOk() + ->assertJsonPath('0.items.meta.current_page', 1) + ->assertJsonPath('0.items.meta.last_page', 2) + ->assertJsonPath('0.items.meta.per_page', 12) + ->assertJsonPath('0.items.meta.total', 13) + ->assertJsonCount(12, '0.items.data') + ->assertJsonPath('0.items.data.0.nombre', 'Item 1'); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items?page=2" + ) + ->assertOk() + ->assertJsonPath('meta.current_page', 2) + ->assertJsonPath('meta.last_page', 2) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.nombre', 'Item 13'); + } + + private function createGroup( + Tenant $tenant, + ProductLayout $layout, + string $name, + int $order = 0, + ): FeaturedGroup { + return FeaturedGroup::query()->create([ + 'tenant_code' => $tenant->codigo, + 'product_layout' => $layout, + 'group_name' => $name, + 'group_order' => $order, + ]); + } + + private function createItem( + Tenant $tenant, + string $name, + ?Inventory $inventory = null, + ): CatalogItem { + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory?->id, + 'slug' => str($name)->slug()->toString(), + 'nombre' => $name, + 'descripcion' => "{$name} description", + 'precio' => 100, + ]); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/Feature/Catalog/CatalogItemControllerTest.php b/tests/Feature/Catalog/CatalogItemControllerTest.php new file mode 100644 index 0000000..48eb7d1 --- /dev/null +++ b/tests/Feature/Catalog/CatalogItemControllerTest.php @@ -0,0 +1,109 @@ +createTenant(); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Size', + 'type' => FieldType::String, + ]); + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [ + 'slug' => 'shirt', + 'nombre' => 'Shirt', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + 'images' => [$image, $image], + 'variants' => [ + [ + 'real_stock' => 5, + 'values' => ['size' => 'M'], + 'images' => [$image], + ], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.nombre', 'Shirt') + ->assertJsonCount(2, 'data.images') + ->assertJsonCount(1, 'data.variants') + ->assertJsonCount(1, 'data.variants.0.images'); + + $item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail(); + $variant = $item->variants()->firstOrFail(); + + $this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all()); + $this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all()); + $this->assertDatabaseHas('catalog_items_attachments', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $variant->id, + 'orden' => 0, + ]); + } + + public function test_it_validates_images_before_creating_the_catalog_item(): void + { + $tenant = $this->createTenant('validation'); + + $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [ + 'slug' => 'invalid-image', + 'nombre' => 'Invalid image', + 'precio' => 100, + 'real_stock' => 1, + 'images' => ['not-an-image'], + ])->assertUnprocessable()->assertJsonValidationErrors('images.0'); + + $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-image']); + } + + private function createTenant(string $code = 'catalog-controller'): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php new file mode 100644 index 0000000..c392e7a --- /dev/null +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -0,0 +1,229 @@ +createTenant('detail-direct'); + $inventory = Inventory::query()->create([ + 'real_stock' => 10, + 'reserved_stock' => 3, + ]); + $item = $this->createItem($tenant, 'Direct item', $inventory); + $itemImage = $this->createAttachment('direct-item'); + $item->attachments()->attach($itemImage, ['orden' => 0]); + + $response = $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}" + ); + + $response + ->assertOk() + ->assertJsonPath('data.stock_tecnico', 7) + ->assertJsonCount(0, 'data.variants') + ->assertJsonCount(1, 'data.images'); + $response->assertJsonMissingPath('data.selected_variant'); + $this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); + } + + public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void + { + Storage::fake('s3'); + $tenant = $this->createTenant('detail-default'); + $item = $this->createItem($tenant, 'Variant item'); + $itemImage = $this->createAttachment('item-image'); + $item->attachments()->attach($itemImage, ['orden' => 0]); + + $firstVariant = $this->createVariant($item, 0, 0); + $secondVariant = $this->createVariant($item, 8, 2); + $firstImage = $this->createAttachment('first-variant'); + $secondImage = $this->createAttachment('second-variant'); + $firstVariant->attachments()->attach($firstImage, ['orden' => 0]); + $secondVariant->attachments()->attach($secondImage, ['orden' => 0]); + + $response = $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}" + ); + + $response + ->assertOk() + ->assertJsonPath('data.selected_variant.id', $firstVariant->id) + ->assertJsonPath('data.selected_variant.stock_tecnico', 0) + ->assertJsonCount(1, 'data.selected_variant.images'); + $response + ->assertJsonMissingPath('data.stock_tecnico') + ->assertJsonMissingPath('data.images'); + $this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); + $this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0')); + } + + public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void + { + Storage::fake('s3'); + $tenant = $this->createTenant('detail-requested'); + $item = $this->createItem($tenant, 'Shirt'); + $size = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Size', + 'type' => FieldType::String, + ]); + $itemSize = ItemAttribute::query()->create([ + 'catalog_item_id' => $item->id, + 'attribute_id' => $size->id, + ]); + $size->options()->createMany([ + ['value' => 'S', 'label' => 'Small', 'sort_order' => 0], + ['value' => 'M', 'label' => 'Medium', 'sort_order' => 1], + ['value' => 'L', 'label' => 'Large', 'sort_order' => 2], + ]); + $firstVariant = $this->createVariant($item, 5, 1); + $secondVariant = $this->createVariant($item, 9, 2); + $firstVariant->definitions()->create([ + 'item_attribute_id' => $itemSize->id, + 'value' => 'S', + ]); + $secondVariant->definitions()->create([ + 'item_attribute_id' => $itemSize->id, + 'value' => 'M', + ]); + $secondImage = $this->createAttachment('selected-variant'); + $secondVariant->attachments()->attach($secondImage, ['orden' => 0]); + + $response = $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$secondVariant->id}" + ); + + $response + ->assertOk() + ->assertJsonPath('data.variants.0.id', $firstVariant->id) + ->assertJsonPath('data.variants.0.stock_tecnico', 4) + ->assertJsonPath('data.variants.0.values.size', 'S') + ->assertJsonPath('data.variants.1.id', $secondVariant->id) + ->assertJsonPath('data.variants.1.stock_tecnico', 7) + ->assertJsonPath('data.variants.1.values.size', 'M') + ->assertJsonPath('data.attributes.0.codigo', 'size') + ->assertJsonPath('data.attributes.0.options.0.value', 'S') + ->assertJsonPath('data.attributes.0.options.1.value', 'M') + ->assertJsonCount(2, 'data.attributes.0.options') + ->assertJsonPath('data.selected_variant.id', $secondVariant->id) + ->assertJsonPath('data.selected_variant.stock_tecnico', 7) + ->assertJsonPath('data.selected_variant.values.size', 'M') + ->assertJsonCount(1, 'data.selected_variant.images'); + $response + ->assertJsonMissingPath('data.stock_tecnico') + ->assertJsonMissingPath('data.images') + ->assertJsonMissingPath('data.variants.0.images') + ->assertJsonMissingPath('data.variants.1.images'); + $this->assertStringContainsString( + $secondImage->path, + $response->json('data.selected_variant.images.0'), + ); + } + + public function test_it_rejects_an_invalid_or_foreign_variant(): void + { + $tenant = $this->createTenant('detail-invalid'); + $item = $this->createItem($tenant, 'Requested item'); + $otherItem = $this->createItem($tenant, 'Other item'); + $foreignVariant = $this->createVariant($otherItem, 5, 0); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id=abc" + )->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$foreignVariant->id}" + )->assertNotFound(); + } + + public function test_it_returns_null_technical_stock_for_unlimited_variants(): void + { + $tenant = $this->createTenant('detail-unlimited'); + $item = $this->createItem($tenant, 'Unlimited item', policy: InventoryPolicy::Unlimited); + $variant = $this->createVariant($item, 0, 20); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") + ->assertOk() + ->assertJsonPath('data.selected_variant.id', $variant->id) + ->assertJsonPath('data.selected_variant.stock_tecnico', null) + ->assertJsonMissingPath('data.stock_tecnico') + ->assertJsonPath('data.variants.0.stock_tecnico', null); + } + + private function createItem( + Tenant $tenant, + string $name, + ?Inventory $inventory = null, + InventoryPolicy $policy = InventoryPolicy::Tracked, + ): CatalogItem { + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory?->id, + 'slug' => str($name)->slug()->toString(), + 'nombre' => $name, + 'descripcion' => "{$name} description", + 'precio' => 100, + 'inventory_policy' => $policy, + ]); + } + + private function createVariant(CatalogItem $item, int $realStock, int $reservedStock): Variant + { + $inventory = Inventory::query()->create([ + 'real_stock' => $realStock, + 'reserved_stock' => $reservedStock, + ]); + + return $item->variants()->create(['inventory_id' => $inventory->id]); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php new file mode 100644 index 0000000..5b6102b --- /dev/null +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -0,0 +1,176 @@ +assertFalse(Schema::hasTable('productos')); + $this->assertFalse(Schema::hasTable('productos_variantes')); + $this->assertFalse(Schema::hasTable('products_attributes')); + $this->assertFalse(Schema::hasTable('bundles')); + $this->assertFalse(Schema::hasTable('bundle_items')); + $this->assertTrue(Schema::hasTable('variantes')); + $this->assertTrue(Schema::hasTable('item_attributes')); + $this->assertTrue(Schema::hasTable('variant_values')); + } + + public function test_catalog_items_contains_catalog_classification_and_optional_inventory(): void + { + $this->assertEqualsCanonicalizing([ + 'id', + 'tenant_code', + 'category_id', + 'brand_id', + 'inventory_id', + 'type', + 'slug', + 'nombre', + 'descripcion', + 'precio', + 'inventory_policy', + 'has_tickets', + 'maximum_use_date', + 'minimum_use_date', + ], Schema::getColumnListing('catalog_items')); + } + + public function test_bundle_components_directly_link_catalog_items(): void + { + $this->assertFalse(Schema::hasTable('bundle_compositions')); + $this->assertEqualsCanonicalizing([ + 'id', + 'bundle_catalog_item_id', + 'component_catalog_item_id', + 'component_variant_id', + 'quantity', + ], Schema::getColumnListing('bundle_components')); + $this->assertFalse(Schema::hasColumn('carrito_items', 'bundle_composition_id')); + } + + public function test_inventories_have_no_polymorphic_columns(): void + { + $this->assertEqualsCanonicalizing([ + 'id', + 'sold_units', + 'reserved_stock', + 'real_stock', + ], Schema::getColumnListing('inventories')); + } + + public function test_featured_catalog_tables_replace_legacy_group_items(): void + { + $this->assertFalse(Schema::hasTable('group_items')); + $this->assertFalse(Schema::hasTable('featured_variants')); + $this->assertEqualsCanonicalizing([ + 'id', + 'tenant_code', + 'product_layout', + 'group_name', + 'group_order', + ], Schema::getColumnListing('featured_groups')); + $this->assertEqualsCanonicalizing([ + 'id', + 'featured_group_id', + 'catalog_item_id', + 'order', + ], Schema::getColumnListing('featured_items')); + } + + public function test_catalog_and_variant_attachments_share_the_catalog_pivot(): void + { + $this->assertFalse(Schema::hasTable('productos_attachments')); + $this->assertFalse(Schema::hasTable('variantes_attachments')); + $this->assertEqualsCanonicalizing([ + 'id', + 'variant_id', + 'catalog_item_id', + 'attachment_id', + 'orden', + ], Schema::getColumnListing('catalog_items_attachments')); + } + + public function test_catalog_item_can_have_no_variants_and_variant_requires_inventory(): void + { + $headerLogo = Attachment::query()->create([ + 'path' => 'test/header.png', + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footerLogo = Attachment::query()->create([ + 'path' => 'test/footer.png', + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'test-tenant', + 'nombre' => 'Test Tenant', + 'dominio' => 'test.local', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + $inventory = Inventory::query()->create(); + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'item', + 'nombre' => 'Item', + 'precio' => 10, + ]); + + $variantInventory = Inventory::query()->create(); + $variant = $item->variants()->create(['inventory_id' => $variantInventory->id]); + $itemAttachment = Attachment::query()->create([ + 'path' => 'test/item.png', + 'filename' => 'item.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $variantAttachment = Attachment::query()->create([ + 'path' => 'test/variant.png', + 'filename' => 'variant.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + $item->attachments()->attach($itemAttachment, ['orden' => 2]); + $variant->attachments()->attach($variantAttachment, ['orden' => 3]); + + $this->assertTrue($variant->catalogItem->is($item)); + $this->assertTrue($variant->inventory->is($variantInventory)); + $this->assertTrue($item->attachments()->firstOrFail()->is($itemAttachment)); + $this->assertTrue($variant->attachments()->firstOrFail()->is($variantAttachment)); + $this->assertDatabaseHas('catalog_items_attachments', [ + 'catalog_item_id' => $item->id, + 'variant_id' => null, + 'attachment_id' => $itemAttachment->id, + 'orden' => 2, + ]); + $this->assertDatabaseHas('catalog_items_attachments', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $variant->id, + 'attachment_id' => $variantAttachment->id, + 'orden' => 3, + ]); + } +} diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php new file mode 100644 index 0000000..5215b8f --- /dev/null +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -0,0 +1,227 @@ +service = app(CatalogService::class); + $this->tenant = $this->createTenant(); + } + + public function test_it_creates_an_item_with_direct_inventory_when_it_has_no_variants(): void + { + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'simple-item', + 'nombre' => 'Simple item', + 'precio' => 100, + 'real_stock' => 12, + ]); + + $this->assertNotNull($item->inventory_id); + $this->assertSame(CatalogItemType::Standard, $item->type); + $this->assertCount(0, $item->variants); + $this->assertSame(12, $item->inventory->real_stock); + $this->assertSame(0, $item->inventory->reserved_stock); + $this->assertSame(0, $item->inventory->sold_units); + } + + public function test_it_creates_variant_inventory_without_direct_item_inventory(): void + { + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Size', + 'type' => FieldType::String, + ]); + $item = $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'variant-item', + 'nombre' => 'Variant item', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [ + [ + 'real_stock' => 5, + 'values' => [$attribute->codigo => 'S'], + ], + [ + 'real_stock' => 8, + 'values' => [$attribute->codigo => 'M'], + ], + ], + ]); + + $this->assertNull($item->inventory_id); + $this->assertCount(2, $item->variants); + $this->assertCount(1, $item->itemAttributes); + $this->assertTrue($item->itemAttributes->first()->attribute->is($attribute)); + $this->assertSame([5, 8], $item->variants->pluck('inventory.real_stock')->all()); + $this->assertSame( + ['S', 'M'], + $item->variants->pluck('definitions')->flatten()->pluck('value')->all(), + ); + + foreach ($item->variants as $variant) { + $this->assertNotNull($variant->inventory_id); + $this->assertSame(0, $variant->inventory->reserved_stock); + $this->assertSame(0, $variant->inventory->sold_units); + } + } + + public function test_it_rejects_direct_inventory_together_with_variants(): void + { + $attribute = $this->createAttribute('size'); + + try { + $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'invalid-item', + 'nombre' => 'Invalid item', + 'precio' => 100, + 'real_stock' => 10, + 'attribute_codes' => [$attribute->codigo], + 'variants' => [ + ['real_stock' => 5], + ], + ]); + + $this->fail('A validation exception was not thrown.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('real_stock', $exception->errors()); + } + + $this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-item']); + $this->assertSame(0, CatalogItem::query()->count()); + $this->assertSame(0, Variant::query()->count()); + $this->assertSame(0, Inventory::query()->count()); + } + + public function test_it_rejects_variants_when_attribute_codes_are_empty(): void + { + $this->expectException(ValidationException::class); + + $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'variants-without-attributes', + 'nombre' => 'Variants without attributes', + 'precio' => 100, + 'variants' => [ + ['real_stock' => 5], + ], + ]); + } + + public function test_it_requires_variants_when_attribute_codes_are_present(): void + { + $attribute = $this->createAttribute('color'); + + $this->expectException(ValidationException::class); + + $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'attributes-without-variants', + 'nombre' => 'Attributes without variants', + 'precio' => 100, + 'attribute_codes' => [$attribute->codigo], + ]); + } + + public function test_it_only_resolves_attribute_codes_from_the_item_tenant(): void + { + $otherTenant = $this->createTenant('other-tenant'); + Attribute::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Size', + 'type' => FieldType::String, + ]); + + try { + $this->service->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => 'foreign-attribute', + 'nombre' => 'Foreign attribute', + 'precio' => 100, + 'attribute_codes' => ['size'], + 'variants' => [ + [ + 'real_stock' => 5, + 'values' => ['size' => 'M'], + ], + ], + ]); + + $this->fail('A validation exception was not thrown.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('attribute_codes', $exception->errors()); + } + + $this->assertDatabaseMissing('catalog_items', ['slug' => 'foreign-attribute']); + $this->assertSame(0, Inventory::query()->count()); + } + + private function createTenant(string $code = 'catalog-service'): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header"); + $footerLogo = $this->createAttachment("{$code}-footer"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAttribute(string $code): Attribute + { + return Attribute::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'type' => FieldType::String, + ]); + } +} diff --git a/tests/Feature/Catalog/GroupItemTest.php b/tests/Feature/Catalog/GroupItemTest.php deleted file mode 100644 index 80b7c42..0000000 --- a/tests/Feature/Catalog/GroupItemTest.php +++ /dev/null @@ -1,114 +0,0 @@ -createAttachment('header.png'); - $footerAttachment = $this->createAttachment('footer.png'); - - $tenant = Tenant::query()->create([ - 'codigo' => 'group-test', - 'nombre' => 'Group Test', - 'dominio' => 'group.test', - 'primary_color' => '#111111', - 'secondary_color' => '#222222', - 'danger_color' => '#333333', - 'success_color' => '#28a745', - 'header_bg_color' => '#444444', - 'footer_bg_color' => '#555555', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - - $category = Category::query()->create([ - 'tenant_code' => $tenant->codigo, - 'nombre' => 'Group items', - ]); - - $product = Product::query()->create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => $category->id, - 'slug' => 'group-item-product', - 'nombre' => 'Group Item Product', - 'precio' => 100, - ]); - - $this->variant = ProductVariant::query()->create([ - 'producto_id' => $product->id, - 'stock' => 10, - ]); - - $this->bundle = Bundle::query()->create([ - 'tenant_codigo' => $tenant->codigo, - 'nombre' => 'Group Item Bundle', - 'precio' => 150, - ]); - - $this->featuredGroup = FeaturedGroup::query()->create([ - 'tenant_codigo' => $tenant->codigo, - 'group_name' => 'Featured', - 'product_layout' => 'row', - 'group_order' => 0, - ]); - } - - public function test_a_group_item_can_reference_a_product_variant(): void - { - $groupItem = $this->variant->groupItems()->create([ - 'featured_group_id' => $this->featuredGroup->id, - 'order' => 1, - ]); - - $this->assertInstanceOf(ProductVariant::class, $groupItem->groupable); - $this->assertTrue($groupItem->groupable->is($this->variant)); - $this->assertTrue($this->variant->groupItems->first()->is($groupItem)); - } - - public function test_a_group_item_can_reference_a_bundle(): void - { - $groupItem = $this->bundle->groupItems()->create([ - 'featured_group_id' => $this->featuredGroup->id, - 'order' => 2, - ]); - - $this->assertInstanceOf(Bundle::class, $groupItem->groupable); - $this->assertTrue($groupItem->groupable->is($this->bundle)); - $this->assertTrue($this->bundle->groupItems->first()->is($groupItem)); - } - - private function createAttachment(string $filename): Attachment - { - return Attachment::query()->create([ - 'key' => (string) Str::uuid(), - 'path' => 'tests/'.$filename, - 'filename' => $filename, - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - } -} diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php deleted file mode 100644 index 810ea0f..0000000 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ /dev/null @@ -1,878 +0,0 @@ -tenant = $this->createTenant('acme', 'Acme Inc.', 'acme.com'); - - $this->brand = Brand::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'nombre' => 'Adidas', - ]); - - // Create attributes for variants - $this->sizeAttr = Attribute::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'codigo' => 'talle', - 'nombre' => 'Talle', - 'is_required' => true, - 'type' => 'select', - ]); - $this->sizeAttr->options()->createMany([ - ['value' => 'S', 'label' => 'S'], - ['value' => '38', 'label' => '38'], - ]); - - $this->colorAttr = Attribute::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'codigo' => 'color', - 'nombre' => 'Color', - 'is_required' => true, - 'type' => 'select', - ]); - $this->colorAttr->options()->createMany([ - ['value' => 'Azul', 'label' => 'Azul'], - ['value' => 'Rojo', 'label' => 'Rojo'], - ]); - - $this->extraAttr = Attribute::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'codigo' => 'extra', - 'nombre' => 'Extra Attribute', - 'is_required' => false, - 'type' => 'string', - ]); - } - - public function test_it_creates_product_with_attributes_and_then_creates_variants(): void - { - $payload = [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'remera-sport', - 'nombre' => 'Remera Sport', - 'descripcion' => 'Remera para hacer deportes', - 'precio' => 15000.00, - 'attribute_ids' => [ - $this->extraAttr->id, - $this->sizeAttr->id, - $this->colorAttr->id, - ], - ]; - - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload); - - $response->assertCreated(); - - // Assert JSON structure - $response->assertJsonPath('data.nombre', 'Remera Sport'); - - // Assert Database - $this->assertDatabaseHas('productos', [ - 'tenant_codigo' => $this->tenant->codigo, - 'slug' => 'remera-sport', - ]); - - $product = Product::where('slug', 'remera-sport')->firstOrFail(); - - // Assert that products_attributes has both explicit extraAttr and those from variants (sizeAttr, colorAttr) - $this->assertDatabaseHas('products_attributes', [ - 'product_id' => $product->id, - 'attribute_id' => $this->extraAttr->id, - ]); - $this->assertDatabaseHas('products_attributes', [ - 'product_id' => $product->id, - 'attribute_id' => $this->sizeAttr->id, - ]); - $this->assertDatabaseHas('products_attributes', [ - 'product_id' => $product->id, - 'attribute_id' => $this->colorAttr->id, - ]); - - $productAttributes = [ - 'size' => $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail(), - 'color' => $product->productAttributes()->where('attribute_id', $this->colorAttr->id)->firstOrFail(), - ]; - - // Create a variant - $variantPayload = [ - 'stock' => 10, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - [ - 'products_attribute_id' => $productAttributes['color']->id, - 'value' => 'Azul', - ], - ], - ]; - - $variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload); - $variantResponse->assertCreated(); - - $this->assertDatabaseHas('productos_variantes', [ - 'producto_id' => $product->id, - 'stock_real' => 10, - ]); - - $variantS = ProductVariant::where('producto_id', $product->id)->where('stock_real', 10)->firstOrFail(); - $this->assertDatabaseHas('productos_variantes_values', [ - 'producto_variante_id' => $variantS->id, - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ]); - $this->assertDatabaseHas('productos_variantes_values', [ - 'producto_variante_id' => $variantS->id, - 'products_attribute_id' => $productAttributes['color']->id, - 'value' => 'Azul', - ]); - } - - public function test_it_updates_product_attributes_independently(): void - { - // 1. Create a product with extraAttr - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pantalon-cargo', - 'nombre' => 'Pantalon Cargo', - 'precio' => 20000.00, - ]); - - $product->attributes()->sync([$this->extraAttr->id]); - - // 2. Perform update payload - change name and update attribute_ids to sizeAttr - $payload = [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pantalon-cargo-new-slug', - 'nombre' => 'Pantalon Cargo V2', - 'precio' => 22000.00, - 'attribute_ids' => [$this->sizeAttr->id], - ]; - - $response = $this->putJson( - "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}", - $payload - ); - - $response->assertOk(); - - // Assert updated values - $response->assertJsonPath('data.nombre', 'Pantalon Cargo V2'); - - // Check DB state - // extraAttr must be detached - $this->assertDatabaseMissing('products_attributes', [ - 'product_id' => $product->id, - 'attribute_id' => $this->extraAttr->id, - ]); - // sizeAttr must be attached - $this->assertDatabaseHas('products_attributes', [ - 'product_id' => $product->id, - 'attribute_id' => $this->sizeAttr->id, - ]); - } - - public function test_it_rejects_variant_creation_with_attributes_not_associated_with_product(): void - { - // Create product with only sizeAttr associated - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pantalon-cargo', - 'nombre' => 'Pantalon Cargo', - 'precio' => 20000.00, - ]); - $product->attributes()->sync([$this->sizeAttr->id]); - $sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail(); - - // Attempt to create variant with colorAttr (which is not associated with the product) - $variantPayload = [ - 'stock' => 5, - 'definitions' => [ - [ - 'products_attribute_id' => $sizeProductAttr->id, - 'value' => '38', - ], - [ - 'products_attribute_id' => 99999, // not associated! - 'value' => 'Rojo', - ], - ], - ]; - - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['definitions.1.products_attribute_id']); - } - - public function test_it_rejects_variant_update_with_attributes_not_associated_with_product(): void - { - // Create product with only sizeAttr associated - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pantalon-cargo', - 'nombre' => 'Pantalon Cargo', - 'precio' => 20000.00, - ]); - $product->attributes()->sync([$this->sizeAttr->id]); - $sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail(); - - $variant = $product->variants()->create([ - 'stock' => 5, - ]); - $variant->definitions()->create([ - 'products_attribute_id' => $sizeProductAttr->id, - 'value' => '38', - ]); - - // Attempt to update variant with colorAttr (which is not associated with the product) - $variantPayload = [ - 'stock' => 5, - 'definitions' => [ - [ - 'products_attribute_id' => $sizeProductAttr->id, - 'value' => '38', - ], - [ - 'products_attribute_id' => 99999, // not associated! - 'value' => 'Rojo', - ], - ], - ]; - - $response = $this->putJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", $variantPayload); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['definitions.1.products_attribute_id']); - } - - public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'short-running', - 'nombre' => 'Short Running', - 'precio' => 8000.00, - ]); - - $v1 = $product->variants()->create([ - 'stock' => 5, - ]); - - $payload = [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'short-running', - 'nombre' => 'Short Running Updated', - 'precio' => 9000.00, - ]; - - $response = $this->putJson( - "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}", - $payload - ); - - $response->assertOk(); - $this->assertDatabaseHas('productos', [ - 'id' => $product->id, - 'nombre' => 'Short Running Updated', - ]); - - // Variant should still exist untouched - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $v1->id, - 'stock_real' => 5, - ]); - } - - public function test_it_rejects_variants_with_invalid_attributes(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pantalon-cargo', - 'nombre' => 'Pantalon Cargo', - 'precio' => 20000.00, - ]); - - $payload = [ - 'stock' => 10, - 'definitions' => [ - [ - 'products_attribute_id' => 99999, // Non-existent ID - 'value' => 'S', - ], - ], - ]; - - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $payload); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['definitions.0.products_attribute_id']); - } - - public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void - { - // 1. Create a select attribute with options - $selectAttr = Product::createAttribute($this->tenant, [ - 'codigo' => 'tamanho', - 'nombre' => 'Tamanho', - 'type' => 'select', - 'options' => [ - ['value' => 'P', 'label' => 'Piqueno'], - ['value' => 'M', 'label' => 'Medio'], - ], - ]); - - // 2. Create product and associate attribute - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-prod-validation', - 'nombre' => 'Test Prod Validation', - 'precio' => 100.00, - ]); - $product->attributes()->sync([$selectAttr->id]); - $productAttr = $product->productAttributes()->where('attribute_id', $selectAttr->id)->firstOrFail(); - - // 3. Expect exception when creating variant with invalid value 'G' - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage("The value 'G' is not a valid option for the select attribute 'Tamanho'."); - - $product->createVariant([ - 'stock' => 5, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttr->id, - 'value' => 'G', // Invalid value - ], - ], - ]); - } - - public function test_it_returns_product_detail_with_variant_mapping_and_default_selected_variant(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-show', - 'nombre' => 'Test Product Show', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - $this->colorAttr->options()->create([ - 'value' => 'Verde', - 'label' => 'Verde', - ]); - - $variant = $product->createVariant([ - 'stock' => 0, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - [ - 'products_attribute_id' => $productAttributes['color']->id, - 'value' => 'Azul', - ], - ], - ]); - - $secondVariant = $product->createVariant([ - 'stock' => 4, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => '38', - ], - [ - 'products_attribute_id' => $productAttributes['color']->id, - 'value' => 'Rojo', - ], - ], - ]); - $variantAttachment = $this->createAttachment('attachments/selected-variant.png'); - $secondVariant->attachments()->attach($variantAttachment->id); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}"); - - $response->assertOk(); - $response->assertJsonStructure([ - 'data' => [ - 'id', - 'nombre', - 'attributes', - 'variants_map' => [ - '*' => [ - 'variant_id', - 'cantidad_maxima', - 'attributes' => [ - 'talle', - 'color', - ], - ], - ], - 'variant' => [ - 'id', - 'cantidad_maxima', - 'definitions', - 'images', - ], - ], - ]); - - $response->assertJsonMissingPath('data.variants'); - $response->assertJsonPath('data.variants_map.0.variant_id', $variant->id); - $response->assertJsonPath('data.variants_map.0.cantidad_maxima', 0); - $response->assertJsonPath('data.variants_map.0.attributes.talle', 'S'); - $response->assertJsonPath('data.variants_map.0.attributes.color', 'Azul'); - $response->assertJsonPath('data.variants_map.1.variant_id', $secondVariant->id); - $response->assertJsonPath('data.variants_map.1.cantidad_maxima', 4); - $response->assertJsonPath('data.variants_map.1.attributes.talle', '38'); - $response->assertJsonPath('data.variants_map.1.attributes.color', 'Rojo'); - $response->assertJsonCount(2, 'data.variants_map'); - $response->assertJsonPath('data.variant.id', $secondVariant->id); - $response->assertJsonPath('data.variant.cantidad_maxima', 4); - $response->assertJsonPath('data.variant.definitions.talle', '38'); - $response->assertJsonPath('data.variant.definitions.color', 'Rojo'); - $response->assertJsonCount(1, 'data.variant.images'); - $this->assertStringContainsString($variantAttachment->path, $response->json('data.variant.images.0')); - - $colorAttribute = collect($response->json('data.attributes'))->firstWhere('codigo', 'color'); - $this->assertNotNull($colorAttribute); - $this->assertEqualsCanonicalizing( - ['Azul', 'Rojo'], - collect($colorAttribute['options'])->pluck('value')->all() - ); - - $attributesResponse = $this->getJson("/api/tenants/{$this->tenant->codigo}/attributes"); - $attributesResponse->assertOk(); - - $masterColorAttribute = collect($attributesResponse->json('data'))->firstWhere('codigo', 'color'); - $this->assertNotNull($masterColorAttribute); - $this->assertEqualsCanonicalizing( - ['Azul', 'Rojo', 'Verde'], - collect($masterColorAttribute['options'])->pluck('value')->all() - ); - } - - public function test_it_returns_requested_variant_in_product_detail(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-requested-variant', - 'nombre' => 'Test Product Requested Variant', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - - $firstVariant = $product->createVariant([ - 'stock' => 5, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - ], - ]); - $secondVariant = $product->createVariant([ - 'stock' => 7, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => '38', - ], - ], - ]); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$firstVariant->id}"); - - $response->assertOk(); - $response->assertJsonPath('data.variant.id', $firstVariant->id); - $response->assertJsonPath('data.variant.cantidad_maxima', 5); - $response->assertJsonPath('data.variants_map.1.variant_id', $secondVariant->id); - } - - public function test_it_rejects_product_detail_variant_id_from_another_product(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-invalid-variant', - 'nombre' => 'Test Product Invalid Variant', - 'precio' => 100.00, - ]); - $otherProduct = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-other-variant', - 'nombre' => 'Test Product Other Variant', - 'precio' => 100.00, - ]); - - $otherVariant = $otherProduct->variants()->create(['stock' => 3]); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$otherVariant->id}"); - - $response->assertNotFound(); - } - - public function test_selected_variant_images_fall_back_to_product_images(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-fallback-images', - 'nombre' => 'Test Product Fallback Images', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - $productAttachment = $this->createAttachment('attachments/product-fallback.png'); - $product->attachments()->attach($productAttachment->id); - - $variant = $product->createVariant([ - 'stock' => 6, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - ], - ]); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}"); - - $response->assertOk(); - $response->assertJsonPath('data.variant.id', $variant->id); - $response->assertJsonCount(1, 'data.variant.images'); - $this->assertStringContainsString($productAttachment->path, $response->json('data.variant.images.0')); - } - - /** - * @return array{size: ProductAttribute, color: ProductAttribute} - */ - private function syncVariantAttributes(Product $product): array - { - $product->attributes()->sync([$this->sizeAttr->id, $this->colorAttr->id]); - - return [ - 'size' => $product->productAttributes() - ->where('attribute_id', $this->sizeAttr->id) - ->firstOrFail(), - 'color' => $product->productAttributes() - ->where('attribute_id', $this->colorAttr->id) - ->firstOrFail(), - ]; - } - - public function test_it_creates_default_variant_on_product_creation(): void - { - $payload = [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'pelota-mundial-test', - 'nombre' => 'Pelota Mundial Test', - 'precio' => 45000.00, - 'stock' => 15, - ]; - - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload); - $response->assertCreated(); - - $product = Product::where('slug', 'pelota-mundial-test')->firstOrFail(); - - // Should have exactly 1 variant - $this->assertEquals(1, $product->variants()->count()); - - $variant = $product->variants()->first(); - $this->assertEquals(15, $variant->stock); - $this->assertTrue($variant->is_placeholder); - // Should have no definitions - $this->assertEquals(0, $variant->definitions()->count()); - } - - public function test_it_removes_default_variant_when_creating_real_variant(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-default-variant-lifecycle', - 'nombre' => 'Test Default Variant Lifecycle', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - - $defaultVariant = $product->variants()->create([ - 'stock' => 10, - 'is_placeholder' => true, - ]); - - $this->assertEquals(1, $product->variants()->count()); - - // Create a real variant (with definitions) - $variantPayload = [ - 'stock' => 5, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - ], - ]; - - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload); - $response->assertCreated(); - - // The default variant should be deleted - $this->assertDatabaseMissing('productos_variantes', [ - 'id' => $defaultVariant->id, - ]); - - // Only the new variant should remain - $this->assertEquals(1, $product->variants()->count()); - $newVariant = $product->variants()->first(); - $this->assertEquals(5, $newVariant->stock); - $this->assertFalse($newVariant->is_placeholder); - } - - public function test_it_restores_default_variant_when_all_variants_are_deleted(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-default-variant-restore', - 'nombre' => 'Test Default Variant Restore', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - - $realVariant = $product->createVariant([ - 'stock' => 5, - 'is_placeholder' => false, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - ], - ]); - - $this->assertEquals(1, $product->variants()->count()); - - // Delete the real variant via API - $response = $this->deleteJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$realVariant->id}"); - $response->assertNoContent(); - - // A default variant should be recreated with stock 0 and is_placeholder = true - $this->assertEquals(1, $product->variants()->count()); - $defaultVariant = $product->variants()->first(); - $this->assertEquals(0, $defaultVariant->stock); - $this->assertTrue($defaultVariant->is_placeholder); - $this->assertEquals(0, $defaultVariant->definitions()->count()); - } - - public function test_it_rejects_product_detail_when_requested_variant_has_no_stock(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-no-stock', - 'nombre' => 'Test Product No Stock', - 'precio' => 100.00, - ]); - $productAttributes = $this->syncVariantAttributes($product); - - $outOfStockVariant = $product->createVariant([ - 'stock' => 0, - 'definitions' => [ - [ - 'products_attribute_id' => $productAttributes['size']->id, - 'value' => 'S', - ], - ], - ]); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$outOfStockVariant->id}"); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['variant_id']); - $response->assertJsonPath('errors.variant_id.0', 'La variante seleccionada no tiene stock.'); - } - - public function test_it_rejects_product_detail_when_requested_variant_id_is_invalid_format(): void - { - $product = Product::create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'test-product-invalid-format', - 'nombre' => 'Test Product Invalid Format', - 'precio' => 100.00, - ]); - - $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id=abc"); - - $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['variant_id']); - } - - public function test_it_creates_an_unlimited_default_variant_and_exposes_inventory_fields(): void - { - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'unlimited-product', - 'nombre' => 'Unlimited Product', - 'precio' => 100, - 'stock' => 0, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - ]); - - $response->assertCreated(); - - $product = Product::query()->where('slug', 'unlimited-product')->firstOrFail(); - $variant = $product->variants()->firstOrFail(); - $this->assertSame(InventoryPolicy::Unlimited, $variant->inventory_policy); - - $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}") - ->assertOk() - ->assertJsonPath('data.variant.id', $variant->id) - ->assertJsonPath('data.variant.inventory_policy', InventoryPolicy::Unlimited->value) - ->assertJsonPath('data.variant.cantidad_maxima', null) - ->assertJsonPath('data.variant.cantidad_vendida', 0) - ->assertJsonPath('data.variants_map.0.inventory_policy', InventoryPolicy::Unlimited->value) - ->assertJsonPath('data.variants_map.0.cantidad_maxima', null) - ->assertJsonPath('data.variants_map.0.cantidad_vendida', 0); - } - - public function test_it_rejects_invalid_or_updated_inventory_policies(): void - { - $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [ - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'invalid-policy', - 'nombre' => 'Invalid Policy', - 'precio' => 100, - 'inventory_policy' => 'sometimes', - ])->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']); - - $product = Product::query()->create([ - 'tenant_codigo' => $this->tenant->codigo, - 'categoria_id' => 1, - 'brand_id' => $this->brand->id, - 'slug' => 'immutable-policy', - 'nombre' => 'Immutable Policy', - 'precio' => 100, - ]); - $variant = $product->variants()->create([ - 'stock' => 0, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - ]); - - $this->putJson( - "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", - ['inventory_policy' => InventoryPolicy::Tracked->value], - )->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']); - - $this->assertSame(InventoryPolicy::Unlimited, $variant->fresh()->inventory_policy); - } - - private function createAttachment(string $path): Attachment - { - return Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => $path, - 'filename' => basename($path), - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - } - - protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant - { - $hdrKey = (string) Str::uuid(); - $ftrKey = (string) Str::uuid(); - - $headerAttachment = Attachment::create([ - 'key' => $hdrKey, - 'path' => 'tenants/'.$hdrKey.'.png', - 'filename' => 'logo_header.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerAttachment = Attachment::create([ - 'key' => $ftrKey, - 'path' => 'tenants/'.$ftrKey.'.png', - 'filename' => 'logo_footer.png', - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - return Tenant::create([ - 'codigo' => $codigo, - 'nombre' => $nombre, - 'dominio' => $dominio, - 'primary_color' => '#111111', - 'secondary_color' => '#222222', - 'danger_color' => '#333333', - 'success_color' => '#28a745', - 'header_bg_color' => '#444444', - 'footer_bg_color' => '#444444', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - } -} diff --git a/tests/Feature/Catalog/ProductVariantAttachmentTest.php b/tests/Feature/Catalog/ProductVariantAttachmentTest.php deleted file mode 100644 index c03fa0a..0000000 --- a/tests/Feature/Catalog/ProductVariantAttachmentTest.php +++ /dev/null @@ -1,229 +0,0 @@ - $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', - 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerAttachment = Attachment::create([ - 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', - 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - $tenant = Tenant::create([ - 'codigo' => 'acme', - 'nombre' => 'Acme Inc.', - 'dominio' => 'acme.com', - 'primary_color' => '#ffffff', - 'secondary_color' => '#ffffff', - 'danger_color' => '#ffffff', - 'success_color' => '#ffffff', - 'header_bg_color' => '#ffffff', - 'footer_bg_color' => '#ffffff', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - - // 2. Create Product - $product = Product::create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => 1, - 'slug' => 'test-product', - 'nombre' => 'Test Product', - 'descripcion' => 'A test product description', - 'precio' => 99.99, - ]); - - // 3. Create Variant - $variant = ProductVariant::create([ - 'producto_id' => $product->id, - 'slug' => 'test-variant-1', - 'nombre' => 'Test Variant 1', - 'stock' => 10, - 'precio' => 99.99, - ]); - - // 4. Create Attachments - $attachment1 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/image1.png', - 'filename' => 'image1.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - $attachment2 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/image2.png', - 'filename' => 'image2.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - // 5. Associate - $variant->attachments()->attach([$attachment1->id, $attachment2->id]); - - // 6. Assert relations - $this->assertCount(2, $variant->attachments); - $this->assertTrue($variant->attachments->contains($attachment1)); - $this->assertTrue($variant->attachments->contains($attachment2)); - } - - public function test_getProductos_listing_image_fallback(): void - { - // 1. Create Tenant - $hdrKey = (string) Str::uuid(); - $ftrKey = (string) Str::uuid(); - $headerAttachment = Attachment::create([ - 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', - 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $footerAttachment = Attachment::create([ - 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', - 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - - $tenant = Tenant::create([ - 'codigo' => 'acme', - 'nombre' => 'Acme Inc.', - 'dominio' => 'acme.com', - 'primary_color' => '#ffffff', - 'secondary_color' => '#ffffff', - 'danger_color' => '#ffffff', - 'header_bg_color' => '#ffffff', - 'footer_bg_color' => '#ffffff', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - - // 2. Create Product 1 (has 2 attachments itself) - $product1 = Product::create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => 1, - 'slug' => 'product-1', - 'nombre' => 'Product 1', - 'precio' => 10.00, - ]); - $p1Attachment1 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/p1_1.png', - 'filename' => 'p1_1.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $p1Attachment2 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/p1_2.png', - 'filename' => 'p1_2.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $product1->attachments()->attach([$p1Attachment1->id, $p1Attachment2->id]); - - // 3. Create Product 2 (no attachments itself, has 2 variants: first variant has 2 attachments, second has 1) - $product2 = Product::create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => 1, - 'slug' => 'product-2', - 'nombre' => 'Product 2', - 'precio' => 20.00, - ]); - $v1 = ProductVariant::create([ - 'producto_id' => $product2->id, - 'slug' => 'p2-v1', - 'nombre' => 'P2 V1', - 'stock' => 10, - 'precio' => 20.00, - ]); - $v2 = ProductVariant::create([ - 'producto_id' => $product2->id, - 'slug' => 'p2-v2', - 'nombre' => 'P2 V2', - 'stock' => 5, - 'precio' => 20.00, - ]); - $v1Attachment1 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/v1_1.png', - 'filename' => 'v1_1.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $v1Attachment2 = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/v1_2.png', - 'filename' => 'v1_2.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $v1->attachments()->attach([$v1Attachment1->id, $v1Attachment2->id]); - - $v2Attachment = Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/v2_1.png', - 'filename' => 'v2_1.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - $v2->attachments()->attach([$v2Attachment->id]); - - // 4. Create Product 3 (no attachments, no variants) - $product3 = Product::create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => 1, - 'slug' => 'product-3', - 'nombre' => 'Product 3', - 'precio' => 30.00, - ]); - - // Call the listing API - $response = $this->getJson("/api/tenants/{$tenant->codigo}/productos"); - $response->assertOk(); - - // Check response data - // Since we order products by latest() (created_at desc), the order is: Product 3, Product 2, Product 1. - $data = $response->json('data'); - $this->assertCount(3, $data); - - // Product 1 (index 0) has attachments -> should have exactly its first attachment (p1Attachment1) - $this->assertCount(1, $data[0]['images']); - $this->assertStringContainsString($p1Attachment1->path, $data[0]['images'][0]); - - // Product 2 (index 1) has no attachments, falls back to first variant (v1) first attachment (v1Attachment1) -> should have exactly 1 image - $this->assertCount(1, $data[1]['images']); - $this->assertStringContainsString($v1Attachment1->path, $data[1]['images'][0]); - - // Product 3 (index 2) has no attachments, no variants -> should have empty images - $this->assertEmpty($data[2]['images']); - } -} diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index e6ccf3d..877bf10 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -7,9 +7,10 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; -use App\Domains\Catalog\Models\Product; -use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\TenantIntegration; use App\Domains\Purchase\Models\Purchase; @@ -177,17 +178,17 @@ class TelepagosWebhookTest extends TestCase $this->assertDatabaseHas('compra_items', [ 'compra_id' => $matchingPurchase->id, - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, 'cantidad' => 1, 'total' => 50, ]); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 9, - 'stock_reservado' => 2, - 'cantidad_vendida' => 1, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 9, + 'reserved_stock' => 2, + 'sold_units' => 1, ]); $this->assertDatabaseMissing('compra_items', [ @@ -244,12 +245,11 @@ class TelepagosWebhookTest extends TestCase 'id' => $purchase->id, 'status' => Purchase::STATUS_PAID, ]); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'stock_real' => 0, - 'stock_reservado' => 0, - 'cantidad_vendida' => 3, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 0, + 'reserved_stock' => 0, + 'sold_units' => 3, ]); } @@ -266,7 +266,8 @@ class TelepagosWebhookTest extends TestCase 'status' => 'active', ]); - $cart->addItem(ProductVariant::class, $variantId, $quantity); + $variant = Variant::query()->findOrFail($variantId); + $cart->addItem($variant->catalog_item_id, $variant->id, $quantity); /** @var CheckoutService $checkoutService */ $checkoutService = app(CheckoutService::class); @@ -317,30 +318,27 @@ class TelepagosWebhookTest extends TestCase string $price, string $slugPrefix = 'shirt', InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, - ): ProductVariant { + ): Variant { $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); - $product = Product::query()->create([ - 'tenant_codigo' => $tenantCode, - 'categoria_id' => $category->id, - 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), + $inventory = Inventory::query()->create(['real_stock' => $stock]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenantCode, + 'category_id' => $category->id, + 'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(), 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", 'descripcion' => 'Test product', 'precio' => $price, + 'inventory_policy' => $inventoryPolicy, ]); - return ProductVariant::query()->create([ - 'producto_id' => $product->id, - 'inventory_policy' => $inventoryPolicy->value, - 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), - 'nombre' => ucfirst($slugPrefix).' Variant', - 'stock' => $stock, - 'descripcion' => 'Test variant', - 'precio' => $price, - ])->load('product'); + return Variant::query()->create([ + 'catalog_item_id' => $catalogItem->id, + 'inventory_id' => $inventory->id, + ])->load(['catalogItem', 'inventory']); } private function createTenant(string $codigo, string $nombre, string $dominio): Tenant diff --git a/tests/Feature/Integration/TenantIntegrationControllerTest.php b/tests/Feature/Integration/TenantIntegrationControllerTest.php new file mode 100644 index 0000000..d56be2c --- /dev/null +++ b/tests/Feature/Integration/TenantIntegrationControllerTest.php @@ -0,0 +1,46 @@ + 'test_integration', + 'name' => 'Test Integration', + 'integration_data_schema' => [ + 'api_key' => 'required|string', + ], + ]); + + $this->mock(TenantIntegrationService::class, function ($mock) use ($integration) { + $mock->shouldReceive('updateOrCreateIntegration') + ->once() + ->with( + 'test-tenant', + Mockery::on(fn (Integration $argument) => $argument->is($integration)), + ['api_key' => 'secret'] + ) + ->andReturn(new TenantIntegration()); + }); + + $this->postJson('/api/test-tenant/integrations/test_integration', [ + 'integration_data' => [ + 'api_key' => 'secret', + ], + ])->assertOk() + ->assertExactJson([ + 'message' => 'integration configured correctly', + ]); + } +} diff --git a/tests/Feature/Purchase/PurchaseCatalogItemTest.php b/tests/Feature/Purchase/PurchaseCatalogItemTest.php new file mode 100644 index 0000000..5a94919 --- /dev/null +++ b/tests/Feature/Purchase/PurchaseCatalogItemTest.php @@ -0,0 +1,152 @@ +buildTemporaryUrlsUsing( + fn (string $path): string => "https://snapshots.test/{$path}", + ); + + $tenant = $this->createTenant(); + $user = User::factory()->create(); + $inventory = Inventory::query()->create(['real_stock' => 10]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => $inventory->id, + 'slug' => 'checkout-item', + 'nombre' => 'Checkout item', + 'descripcion' => 'Original description', + 'precio' => 25, + ]); + $productImage = $this->createAttachment('checkout-item'); + Storage::disk('s3')->put($productImage->path, 'original-image'); + $catalogItem->attachments()->attach($productImage->id, ['orden' => 0]); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($catalogItem->id, null, 2); + + $service = app(CheckoutService::class); + $purchase = $service->startCheckout($tenant, $user->id, [ + 'cart_id' => $cart->id, + 'dni' => '12345678', + 'telefono' => '123456789', + 'nombre_apellido' => 'Test User', + 'email' => 'test@example.com', + ]); + $service->confirmPurchase($purchase); + + $this->assertTrue(Schema::hasColumns('compra_items', [ + 'source_catalog_item_id', + 'source_variant_id', + 'image_attachment_id', + 'nombre', + 'descripcion', + 'slug', + 'item_nombre', + 'variant_attributes', + ])); + $this->assertFalse(Schema::hasColumn('compra_items', 'catalog_item_id')); + $this->assertFalse(Schema::hasColumn('compra_items', 'variant_id')); + $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type')); + $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id')); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => null, + 'image_attachment_id' => $purchase->items()->value('image_attachment_id'), + 'nombre' => 'Checkout item', + 'descripcion' => 'Original description', + 'slug' => 'checkout-item', + 'item_nombre' => 'Checkout item', + 'cantidad' => 2, + 'precio_unitario' => '25.00', + 'total' => '50.00', + ]); + $purchaseItem = $purchase->items()->with('imageAttachment')->firstOrFail(); + $this->assertNotNull($purchaseItem->imageAttachment); + $this->assertNotSame($productImage->id, $purchaseItem->image_attachment_id); + $this->assertSame('original-image', Storage::disk('s3')->get($purchaseItem->imageAttachment->path)); + $this->assertStringStartsWith( + "purchase/{$purchase->id}/", + $purchaseItem->imageAttachment->path, + ); + $catalogItem->update([ + 'nombre' => 'Changed catalog item', + 'descripcion' => 'Changed description', + ]); + $catalogItem->delete(); + + $purchaseItem->refresh(); + $this->assertSame('Checkout item', $purchaseItem->nombre); + $this->assertSame('Original description', $purchaseItem->descripcion); + $this->assertDatabaseHas('compra_items', ['id' => $purchaseItem->id]); + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/{$tenant->codigo}/compras/{$purchase->id}") + ->assertOk() + ->assertJsonPath('data.items.0.source_catalog_item_id', $catalogItem->id) + ->assertJsonPath('data.items.0.item_details.nombre', 'Checkout item') + ->assertJsonPath('data.items.0.item_details.descripcion', 'Original description') + ->assertJsonMissingPath('data.items.0.product') + ->assertJsonMissingPath('data.items.0.variant'); + $this->assertDatabaseHas('inventories', [ + 'id' => $inventory->id, + 'real_stock' => 8, + 'reserved_stock' => 0, + 'sold_units' => 2, + ]); + } + + private function createTenant(): Tenant + { + $headerLogo = $this->createAttachment('purchase-header'); + $footerLogo = $this->createAttachment('purchase-footer'); + + return Tenant::query()->create([ + 'codigo' => 'purchase-catalog', + 'nombre' => 'Purchase Catalog', + 'dominio' => 'purchase-catalog.local', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $name): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$name}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 14, + ]); + } +} diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index b822797..ccbf3a4 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -7,9 +7,10 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; -use App\Domains\Catalog\Models\Product; -use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; @@ -32,27 +33,25 @@ class StorePurchaseTest extends TestCase 'nombre' => 'Test Category', ]); - $product = Product::query()->create([ - 'tenant_codigo' => 'sonder', - 'categoria_id' => $category->id, + $inventory = Inventory::query()->create(['real_stock' => 10]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => 'sonder', + 'category_id' => $category->id, 'slug' => 'test-product', 'nombre' => 'Test Product', 'descripcion' => 'Test', 'precio' => '50.00', ]); - $variant = ProductVariant::query()->create([ - 'producto_id' => $product->id, - 'slug' => 'test-variant', - 'nombre' => 'Test Variant', - 'stock' => 10, - 'precio' => '50.00', + $variant = Variant::query()->create([ + 'catalog_item_id' => $catalogItem->id, + 'inventory_id' => $inventory->id, ]); $cartResponse = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $catalogItem->id, + 'variant_id' => $variant->id, 'cantidad' => 2, ]) ->assertOk(); @@ -112,14 +111,14 @@ class StorePurchaseTest extends TestCase ]); $this->assertDatabaseHas('carrito_items', [ 'cart_id' => $cartId, - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, + 'catalog_item_id' => $catalogItem->id, + 'variant_id' => $variant->id, 'cantidad' => 2, ]); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 10, - 'stock_reservado' => 2, + $this->assertDatabaseHas('inventories', [ + 'id' => $inventory->id, + 'real_stock' => 10, + 'reserved_stock' => 2, ]); } @@ -133,8 +132,8 @@ class StorePurchaseTest extends TestCase $cartId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, 'cantidad' => 2, ]) ->assertOk() @@ -187,9 +186,9 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') - ->assertJsonPath('data.items.0.product.id', $variant->product->id) - ->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre) - ->assertJsonPath('data.items.0.product.slug', $variant->product->slug) + ->assertJsonPath('data.items.0.product.id', $variant->catalogItem->id) + ->assertJsonPath('data.items.0.product.nombre', $variant->catalogItem->nombre) + ->assertJsonPath('data.items.0.product.slug', $variant->catalogItem->slug) ->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.items.0.variant.id', $variant->id) ->assertJsonPath('data.items.0.variant.attributes', []) @@ -245,11 +244,11 @@ class StorePurchaseTest extends TestCase $checkoutService->confirmPurchase($purchase); $purchase->refresh()->markAsPaid(); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'stock_real' => 8, - 'stock_reservado' => 0, - 'cantidad_vendida' => 2, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 8, + 'reserved_stock' => 0, + 'sold_units' => 2, ]); $this->assertSoftDeleted('carritos', [ @@ -265,10 +264,10 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') - ->assertJsonPath('data.items.0.product.id', $variant->product->id) - ->assertJsonPath('data.items.0.product.imagen', null) - ->assertJsonPath('data.items.0.variant.id', $variant->id) - ->assertJsonPath('data.items.0.variant.attributes', []) + ->assertJsonPath('data.items.0.source_catalog_item_id', $variant->catalog_item_id) + ->assertJsonPath('data.items.0.source_variant_id', $variant->id) + ->assertJsonPath('data.items.0.item_details.imagen', null) + ->assertJsonPath('data.items.0.item_details.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } @@ -283,8 +282,13 @@ class StorePurchaseTest extends TestCase $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $purchase->items()->create([ - 'buyable_type' => ProductVariant::class, - 'buyable_id' => $variant->id, + 'source_catalog_item_id' => $variant->catalog_item_id, + 'source_variant_id' => $variant->id, + 'nombre' => $variant->catalogItem->nombre, + 'descripcion' => $variant->catalogItem->descripcion, + 'slug' => $variant->catalogItem->slug, + 'item_nombre' => $variant->getName(), + 'variant_attributes' => [], 'cantidad' => 1, 'precio_unitario' => '50.00', 'discount_total' => null, @@ -330,8 +334,8 @@ class StorePurchaseTest extends TestCase $cartId = $this->actingAs($owner, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, 'cantidad' => 1, ]) ->assertOk() @@ -357,8 +361,8 @@ class StorePurchaseTest extends TestCase $cartId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/globex/cart/items', [ - 'buyable_type' => 'variant', - 'buyable_id' => $variant->id, + 'catalog_item_id' => $variant->catalog_item_id, + 'variant_id' => $variant->id, 'cantidad' => 1, ]) ->assertOk() @@ -438,12 +442,11 @@ class StorePurchaseTest extends TestCase $checkoutService->confirmPurchase($purchase); $checkoutService->confirmPurchase($purchase); - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $variant->id, - 'inventory_policy' => InventoryPolicy::Unlimited->value, - 'stock_real' => 0, - 'stock_reservado' => 0, - 'cantidad_vendida' => 25, + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'real_stock' => 0, + 'reserved_stock' => 0, + 'sold_units' => 25, ]); $this->assertDatabaseCount('compra_items', 1); } @@ -454,7 +457,7 @@ class StorePurchaseTest extends TestCase string $price, string $slugPrefix = 'shirt', InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, - ): ProductVariant { + ): Variant { $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); if (! $tenant) { $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); @@ -465,30 +468,27 @@ class StorePurchaseTest extends TestCase 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); - $product = Product::query()->create([ - 'tenant_codigo' => $tenantCode, - 'categoria_id' => $category->id, - 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), + $inventory = Inventory::query()->create(['real_stock' => $stock]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenantCode, + 'category_id' => $category->id, + 'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(), 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", 'descripcion' => 'Test product', 'precio' => $price, + 'inventory_policy' => $inventoryPolicy, ]); - return ProductVariant::query()->create([ - 'producto_id' => $product->id, - 'inventory_policy' => $inventoryPolicy->value, - 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), - 'nombre' => ucfirst($slugPrefix).' Variant', - 'stock' => $stock, - 'descripcion' => 'Test variant', - 'precio' => $price, - ])->load('product'); + return Variant::query()->create([ + 'catalog_item_id' => $catalogItem->id, + 'inventory_id' => $inventory->id, + ])->load(['catalogItem', 'inventory']); } protected function createCheckoutPurchase( User $user, string $tenantCode, - ProductVariant $variant, + Variant $variant, int $quantity, ): Purchase { $tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); @@ -498,7 +498,7 @@ class StorePurchaseTest extends TestCase 'status' => 'active', ]); - $cart->addItem(ProductVariant::class, $variant->id, $quantity); + $cart->addItem($variant->catalog_item_id, $variant->id, $quantity); return app(CheckoutService::class)->startCheckout($tenant, $user->id, [ 'cart_id' => $cart->id, diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 5388b7f..2f9cef5 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -4,8 +4,11 @@ namespace Tests\Feature\Seeders; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Bundle\Models\Bundle; +use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\FeaturedGroup; +use App\Domains\Catalog\Models\Inventory; use App\Domains\Tenant\Models\Tenant; use Database\Seeders\AttributeSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; @@ -16,7 +19,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase { use RefreshDatabase; - public function test_it_seeds_event_attributes_and_bundles(): void + public function test_it_seeds_event_items_for_the_new_catalog(): void { $headerLogo = Attachment::query()->create([ 'path' => 'tests/header.png', @@ -45,6 +48,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase 'footer_logo_id' => $footerLogo->id, ]); + $this->seed([ + AttributeSeeder::class, + FiestaFutbolInfantilProductSeeder::class, + ]); $this->seed([ AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class, @@ -59,37 +66,77 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase Attribute::query()->where('tenant_codigo', $tenant->codigo)->pluck('codigo')->all() ); - $allDaysBundle = Bundle::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('nombre', 'Entrada General - Todos los días') - ->with('items.variant.product', 'items.variant.definitions') + $generalAdmission = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'entrada-general') + ->with('variants.definitions') ->sole(); - $this->assertSame('40000.00', $allDaysBundle->precio); - $this->assertCount(4, $allDaysBundle->items); + $this->assertNull($generalAdmission->inventory_id); + $this->assertCount(4, $generalAdmission->variants); $this->assertEqualsCanonicalizing( ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], - $allDaysBundle->items->map(fn ($item) => $item->variant->definitions->sole()->value)->all() + $generalAdmission->variants->map(fn ($variant) => $variant->definitions->sole()->value)->all() ); - $this->assertTrue($allDaysBundle->items->every( - fn ($item) => $item->cantidad === 1 && $item->variant->product->slug === 'entrada-general' - )); - $foodBundle = Bundle::query() - ->where('tenant_codigo', $tenant->codigo) - ->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas') - ->with('items.variant.product') + $allDaysItem = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('nombre', 'Entrada General - Todos los días') + ->with('bundleComponents.variant.definitions') ->sole(); - $this->assertSame('24000.00', $foodBundle->precio); + $this->assertSame(CatalogItemType::Bundle, $allDaysItem->type); + $this->assertSame('40000.00', $allDaysItem->precio); + $this->assertNull($allDaysItem->inventory_id); + $this->assertFalse($allDaysItem->has_tickets); + $this->assertCount(4, $allDaysItem->bundleComponents); $this->assertEqualsCanonicalizing( + ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], + $allDaysItem->bundleComponents + ->map(fn ($component) => $component->variant->definitions->sole()->value) + ->all() + ); + + $foodCombo = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas') + ->with('bundleComponents.catalogItem') + ->sole(); + + $this->assertSame(CatalogItemType::Bundle, $foodCombo->type); + $this->assertSame('24000.00', $foodCombo->precio); + $this->assertNull($foodCombo->inventory_id); + $this->assertSame( [ - 'pancho' => 2, 'hamburguesa-papa-frita' => 2, + 'pancho' => 2, ], - $foodBundle->items->mapWithKeys( - fn ($item) => [$item->variant->product->slug => $item->cantidad] - )->all() + $foodCombo->bundleComponents + ->mapWithKeys(fn ($component): array => [ + $component->catalogItem->slug => $component->quantity, + ]) + ->sortKeys() + ->all() + ); + $this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count()); + $this->assertSame(10, Inventory::query()->count()); + + $this->assertSame( + [ + 'Entradas' => ['entrada-general', 'entrada-general-todos-los-dias'], + 'Estacionamiento' => ['estacionamiento-auto', 'estacionamiento-moto'], + 'Comidas' => ['hamburguesa-papa-frita', 'pancho', 'combo-2-panchos-2-hamburguesas'], + 'Bebidas' => ['coca-cola-500ml', 'agua-mineral-1l'], + ], + FeaturedGroup::query() + ->where('tenant_code', $tenant->codigo) + ->with('featuredItems.catalogItem') + ->orderBy('group_order') + ->get() + ->mapWithKeys(fn (FeaturedGroup $group): array => [ + $group->group_name => $group->featuredItems->pluck('catalogItem.slug')->all(), + ]) + ->all() ); } } diff --git a/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php new file mode 100644 index 0000000..5efab8d --- /dev/null +++ b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php @@ -0,0 +1,68 @@ +create([ + 'path' => 'tests/sonder.png', + 'filename' => 'sonder.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'sonder', + 'nombre' => 'Sonder', + 'dominio' => 'sonder.localhost', + 'primary_color' => '#6376F3', + 'secondary_color' => '#A0A0A0', + 'danger_color' => '#FF8888', + 'success_color' => '#198754', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#313131', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + ]); + + $this->seed([ + AttributeSeeder::class, + CategorySeeder::class, + BrandSeeder::class, + ProductCatalogFromImagesSeeder::class, + ]); + + $group = FeaturedGroup::query() + ->where('tenant_code', $tenant->codigo) + ->with('featuredItems.catalogItem') + ->sole(); + + $this->assertSame('Productos', $group->group_name); + $this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout); + $this->assertSame(0, $group->group_order); + $this->assertSame( + CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('id')->pluck('slug')->all(), + $group->featuredItems->pluck('catalogItem.slug')->all(), + ); + $this->assertCount(10, $group->featuredItems); + } +} diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php new file mode 100644 index 0000000..a994bc5 --- /dev/null +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -0,0 +1,189 @@ +setRawAttributes([ + 'is_required' => 1, + 'metadata_schema' => '{"swatch":true}', + 'type' => FieldType::Select->value, + ]); + + $this->assertSame('attribute', $attribute->getTable()); + $this->assertTrue($attribute->is_required); + $this->assertSame(['swatch' => true], $attribute->metadata_schema); + $this->assertSame(FieldType::Select, $attribute->type); + $this->assertInstanceOf(Tenant::class, $attribute->tenant()->getRelated()); + $this->assertInstanceOf(AttributeOption::class, $attribute->options()->getRelated()); + } + + public function test_catalog_item_is_the_catalog_root(): void + { + $item = new CatalogItem; + $item->setRawAttributes([ + 'category_id' => '10', + 'brand_id' => '20', + 'inventory_id' => '30', + 'type' => CatalogItemType::Standard->value, + 'precio' => '12.50', + 'inventory_policy' => InventoryPolicy::Tracked->value, + 'has_tickets' => 1, + ]); + + $this->assertSame('catalog_items', $item->getTable()); + $this->assertFalse($item->usesTimestamps()); + $this->assertSame(10, $item->category_id); + $this->assertSame(20, $item->brand_id); + $this->assertSame(30, $item->inventory_id); + $this->assertSame(CatalogItemType::Standard, $item->type); + $this->assertSame('12.50', $item->precio); + $this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy); + $this->assertTrue($item->has_tickets); + $this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated()); + $this->assertInstanceOf(Category::class, $item->category()->getRelated()); + $this->assertInstanceOf(Brand::class, $item->brand()->getRelated()); + $this->assertInstanceOf(Inventory::class, $item->inventory()->getRelated()); + $this->assertInstanceOf(BundleComponent::class, $item->bundleComponents()->getRelated()); + $this->assertInstanceOf(BundleComponent::class, $item->bundleComponentUsages()->getRelated()); + $this->assertInstanceOf(Variant::class, $item->variants()->getRelated()); + $this->assertInstanceOf(Attribute::class, $item->attributes()->getRelated()); + $this->assertInstanceOf(ItemAttribute::class, $item->itemAttributes()->getRelated()); + $this->assertInstanceOf(FeaturedItem::class, $item->featuredItems()->getRelated()); + $this->assertInstanceOf(Attachment::class, $item->attachments()->getRelated()); + $this->assertSame('catalog_items_attachments', $item->attachments()->getTable()); + } + + public function test_featured_models_map_catalog_relations_and_layout(): void + { + $group = new FeaturedGroup; + $group->setRawAttributes([ + 'product_layout' => ProductLayout::ColumnWithImage->value, + 'group_order' => '2', + ]); + $featuredItem = new FeaturedItem; + $featuredItem->setRawAttributes([ + 'featured_group_id' => '10', + 'catalog_item_id' => '20', + 'order' => '3', + ]); + + $this->assertSame('featured_groups', $group->getTable()); + $this->assertFalse($group->usesTimestamps()); + $this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout); + $this->assertSame(2, $group->group_order); + $this->assertInstanceOf(Tenant::class, $group->tenant()->getRelated()); + $this->assertInstanceOf(FeaturedItem::class, $group->featuredItems()->getRelated()); + + $this->assertSame('featured_items', $featuredItem->getTable()); + $this->assertFalse($featuredItem->usesTimestamps()); + $this->assertSame(10, $featuredItem->featured_group_id); + $this->assertSame(20, $featuredItem->catalog_item_id); + $this->assertSame(3, $featuredItem->order); + $this->assertInstanceOf(FeaturedGroup::class, $featuredItem->featuredGroup()->getRelated()); + $this->assertInstanceOf(CatalogItem::class, $featuredItem->catalogItem()->getRelated()); + } + + public function test_variant_has_direct_catalog_and_inventory_relations(): void + { + $variant = new Variant; + $variant->setRawAttributes(['catalog_item_id' => '10', 'inventory_id' => '20']); + + $this->assertSame('variantes', $variant->getTable()); + $this->assertSame(10, $variant->catalog_item_id); + $this->assertSame(20, $variant->inventory_id); + $this->assertInstanceOf(CatalogItem::class, $variant->catalogItem()->getRelated()); + $this->assertInstanceOf(Inventory::class, $variant->inventory()->getRelated()); + $this->assertInstanceOf(VariantDefinition::class, $variant->definitions()->getRelated()); + $this->assertInstanceOf(Attachment::class, $variant->attachments()->getRelated()); + $this->assertSame('catalog_items_attachments', $variant->attachments()->getTable()); + } + + public function test_inventory_maps_stock_without_a_polymorphic_owner(): void + { + $inventory = $this->trackedInventory(realStock: 10, reservedStock: 3); + $inventory->sold_units = '2'; + + $this->assertSame('inventories', $inventory->getTable()); + $this->assertFalse($inventory->usesTimestamps()); + $this->assertSame(2, $inventory->sold_units); + $this->assertSame(7, $inventory->availableStock()); + $this->assertInstanceOf(CatalogItem::class, $inventory->catalogItem()->getRelated()); + $this->assertInstanceOf(Variant::class, $inventory->variant()->getRelated()); + } + + public function test_catalog_item_aggregates_variant_inventory(): void + { + $first = (new Variant)->setRelation('inventory', $this->trackedInventory(10, 3)); + $second = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 1)); + $item = new CatalogItem; + $item->inventory_policy = InventoryPolicy::Tracked; + $item->setRelation('variants', new EloquentCollection([$first, $second])); + $item->setRelation('inventory', null); + + $this->assertSame(11, $item->availableStock()); + $this->assertTrue($item->isAvailable()); + } + + public function test_catalog_item_prioritizes_its_inventory_over_variants(): void + { + $item = new CatalogItem; + $item->inventory_policy = InventoryPolicy::Tracked; + $item->setRelation('variants', new EloquentCollection([ + (new Variant)->setRelation('inventory', $this->trackedInventory(100, 0)), + ])); + $item->setRelation('inventory', $this->trackedInventory(8, 2)); + + $this->assertSame(6, $item->availableStock()); + $this->assertTrue($item->isAvailable()); + } + + public function test_item_attribute_and_variant_definition_use_catalog_keys(): void + { + $itemAttribute = new ItemAttribute; + $definition = new VariantDefinition; + + $this->assertSame('item_attributes', $itemAttribute->getTable()); + $this->assertInstanceOf(CatalogItem::class, $itemAttribute->catalogItem()->getRelated()); + $this->assertInstanceOf(Attribute::class, $itemAttribute->attribute()->getRelated()); + $this->assertInstanceOf(VariantDefinition::class, $itemAttribute->variantDefinitions()->getRelated()); + $this->assertSame('variant_values', $definition->getTable()); + $this->assertInstanceOf(Variant::class, $definition->variant()->getRelated()); + $this->assertInstanceOf(ItemAttribute::class, $definition->itemAttribute()->getRelated()); + } + + private function trackedInventory(int $realStock, int $reservedStock): Inventory + { + $inventory = new Inventory; + $inventory->setRawAttributes([ + 'real_stock' => $realStock, + 'reserved_stock' => $reservedStock, + ]); + + return $inventory; + } +} diff --git a/tests/Unit/Catalog/ProductVariantInventoryTest.php b/tests/Unit/Catalog/ProductVariantInventoryTest.php deleted file mode 100644 index d2002dc..0000000 --- a/tests/Unit/Catalog/ProductVariantInventoryTest.php +++ /dev/null @@ -1,153 +0,0 @@ -createAttachment('header.png'); - $footerAttachment = $this->createAttachment('footer.png'); - - $tenant = Tenant::query()->create([ - 'codigo' => 'inventory-test', - 'nombre' => 'Inventory Test', - 'dominio' => 'inventory.test', - 'primary_color' => '#111111', - 'secondary_color' => '#222222', - 'danger_color' => '#333333', - 'success_color' => '#28a745', - 'header_bg_color' => '#444444', - 'footer_bg_color' => '#444444', - 'header_logo_id' => $headerAttachment->id, - 'footer_logo_id' => $footerAttachment->id, - ]); - - $category = Category::query()->create([ - 'tenant_code' => $tenant->codigo, - 'nombre' => 'Inventory', - ]); - - $this->product = Product::query()->create([ - 'tenant_codigo' => $tenant->codigo, - 'categoria_id' => $category->id, - 'slug' => 'inventory-product', - 'nombre' => 'Inventory Product', - 'precio' => 100, - ]); - } - - public function test_it_defaults_to_tracked_inventory_with_no_sales(): void - { - $variant = $this->createVariant(5); - - $this->assertSame(InventoryPolicy::Tracked, $variant->inventory_policy); - $this->assertSame(5, $variant->availableQuantity()); - $this->assertSame(0, $variant->cantidad_vendida); - $this->assertTrue($variant->isAvailableForSale()); - } - - public function test_tracked_inventory_cannot_reserve_more_than_available_stock(): void - { - $variant = $this->createVariant(5); - $variant->reserveStock(3); - - $this->assertSame(2, $variant->fresh()->availableQuantity()); - - $this->expectException(\InvalidArgumentException::class); - $variant->reserveStock(3); - } - - public function test_unlimited_inventory_can_reserve_more_than_real_stock(): void - { - $variant = $this->createVariant(0, InventoryPolicy::Unlimited); - $variant->reserveStock(50); - - $variant->refresh(); - $this->assertNull($variant->availableQuantity()); - $this->assertSame(50, $variant->stock_reservado); - $this->assertTrue($variant->isAvailableForSale()); - } - - public function test_buying_tracked_inventory_consumes_stock_and_records_the_sale(): void - { - $variant = $this->createVariant(10); - $variant->reserveStock(4); - $variant->buy(3); - - $variant->refresh(); - $this->assertSame(7, $variant->stock_real); - $this->assertSame(1, $variant->stock_reservado); - $this->assertSame(3, $variant->cantidad_vendida); - } - - public function test_buying_unlimited_inventory_preserves_real_stock_and_records_the_sale(): void - { - $variant = $this->createVariant(0, InventoryPolicy::Unlimited); - $variant->reserveStock(4); - $variant->buy(3); - - $variant->refresh(); - $this->assertSame(0, $variant->stock_real); - $this->assertSame(1, $variant->stock_reservado); - $this->assertSame(3, $variant->cantidad_vendida); - } - - public function test_buy_requires_enough_reserved_stock(): void - { - $variant = $this->createVariant(10); - $variant->reserveStock(1); - - $this->expectException(\InvalidArgumentException::class); - $variant->buy(2); - } - - public function test_inventory_policy_cannot_change_after_creation(): void - { - $variant = $this->createVariant(10); - $variant->inventory_policy = InventoryPolicy::Unlimited; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('La politica de inventario no puede modificarse.'); - $variant->save(); - } - - private function createVariant( - int $stock, - InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, - ): ProductVariant { - return ProductVariant::query()->create([ - 'producto_id' => $this->product->id, - 'stock' => $stock, - 'inventory_policy' => $inventoryPolicy->value, - ]); - } - - private function createAttachment(string $filename): Attachment - { - return Attachment::query()->create([ - 'key' => (string) Str::uuid(), - 'path' => 'tests/'.$filename, - 'filename' => $filename, - 'type' => AttachmentType::Image, - 'mime_type' => 'image/png', - ]); - } -}