From 07d2129410d74c2d4dce713f1c2a8cc47dcd85ff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 09:32:56 -0300 Subject: [PATCH] feat(catalog): exclude out-of-stock items and update variant visibility logic --- app/Domains/Catalog/Models/CatalogItem.php | 20 ++++++++++--- .../Resources/CatalogFeaturedItemResource.php | 4 +-- .../Resources/CatalogSearchItemResource.php | 5 ++-- .../Catalog/Services/CatalogService.php | 9 ++++++ .../Catalog/Services/FeaturedGroupService.php | 1 + .../Feature/Catalog/CatalogControllerTest.php | 28 +++++++----------- .../CatalogItemDetailControllerTest.php | 29 +++++++++++++------ tests/Feature/Catalog/CatalogSearchTest.php | 20 +++++++++++++ tests/Feature/Catalog/CategoryDetailTest.php | 12 ++++++++ 9 files changed, 94 insertions(+), 34 deletions(-) diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 2828f65..1a3a4ad 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -172,17 +172,29 @@ class CatalogItem extends Model } /** @param Builder $query */ - public function scopeWhereVariantsAvailable(Builder $query): Builder + public function scopeWhereAvailable(Builder $query): Builder { return $query->where(function (Builder $query): void { $query - ->whereDoesntHave('variants') - ->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) ->orWhereHas( 'variants.inventory', fn (Builder $inventoryQuery): Builder => $inventoryQuery ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') - ); + ) + ->orWhere(function (Builder $directItemQuery): void { + $directItemQuery + ->whereDoesntHave('variants') + ->where(function (Builder $inventoryQuery): void { + $inventoryQuery + ->whereNull('catalog_items.inventory_id') + ->orWhereHas( + 'inventory', + fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ); + }); + }); }); } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 4a02a78..3aabebe 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource $availableStock, $remainingUserQuota, ), - 'variants' => $catalogItem->variants + 'variants' => $catalogItem->visibleVariants() ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array { $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null @@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource private function firstImageUrl(CatalogItem $catalogItem): ?string { $attachment = $catalogItem->attachments->first() - ?? $catalogItem->variants + ?? $catalogItem->visibleVariants() ->flatMap(fn (Variant $variant) => $variant->attachments) ->first(); diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 3d1c2d8..86e13b0 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource public function toArray(Request $request): array { $availableStock = $this->availableStock(); + $visibleVariants = $this->visibleVariants(); $attachment = $this->attachments->first() - ?? $this->variants + ?? $visibleVariants ->flatMap(fn (Variant $variant) => $variant->attachments) ->first(); @@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource 'image' => $attachment?->getTemporaryUrl(1440), 'maximum_addable_quantity' => $this->maximumAddable($availableStock), 'unavailable_message' => $this->unavailableMessage($availableStock), - 'variants' => $this->variants + 'variants' => $visibleVariants ->map(function (Variant $variant): array { $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited ? null diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index d5b4f1b..2b46377 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -205,6 +205,13 @@ class CatalogService ]); $visibleVariants = $catalogItem->visibleVariants(); + if ($catalogItem->type === CatalogItemType::Standard + && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) + && ! $catalogItem->isAvailable()) { + throw new NotFoundHttpException('Catalog item is out of stock.'); + } + + $catalogItem->setRelation('variants', $visibleVariants); $selectedVariant = $variantId === null ? $visibleVariants->first() : $visibleVariants->firstWhere('id', $variantId); @@ -231,6 +238,7 @@ class CatalogService $paginator = CatalogItem::query() ->where('tenant_code', $tenant->codigo) + ->whereAvailable() ->where(function (Builder $query) use ($containsPattern): void { $query ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) @@ -280,6 +288,7 @@ class CatalogService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) + ->whereAvailable() ->with([ 'attachments', 'inventory', diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 0302a1f..cca82b7 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,6 +49,7 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->whereAvailable() ->where(function (Builder $query): void { $query ->whereDoesntHave('category') diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index cdc98d2..0147ada 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -73,19 +73,18 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.precio', '100.00') ->assertJsonPath('0.items.0.maximum_addable_quantity', 7) - ->assertJsonCount(3, '0.items.0.variants') + ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) - ->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id) - ->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0) - ->assertJsonPath( - '0.items.0.variants.2.unavailable_message', - 'Este producto no tiene stock disponible.', - ) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonMissingPath('1.items.data.0.stock_tecnico') ->assertJsonCount(0, '1.items.data.0.variants'); + + $this->assertNotContains( + $unavailableVariant->id, + collect($response->json('0.items.0.variants'))->pluck('id')->all(), + ); } public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void @@ -133,7 +132,7 @@ class CatalogControllerTest extends TestCase ->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico'); } - public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void + public function test_it_excludes_out_of_stock_items(): void { $tenant = $this->createTenant('catalog-available-variants'); $group = $this->createGroup( @@ -161,15 +160,10 @@ class CatalogControllerTest extends TestCase $this->getJson("/api/tenants/{$tenant->codigo}/catalog") ->assertOk() - ->assertJsonCount(2, '0.items') - ->assertJsonPath('0.items.0.nombre', 'Unavailable') - ->assertJsonPath('0.items.0.maximum_addable_quantity', 0) - ->assertJsonPath( - '0.items.0.unavailable_message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath('0.items.1.nombre', 'Available') - ->assertJsonPath('0.items.1.unavailable_message', null); + ->assertJsonCount(1, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Available') + ->assertJsonPath('0.items.0.unavailable_message', null) + ->assertJsonMissing(['nombre' => 'Unavailable']); } public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 816cd99..5d8d3a8 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase $this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); } - public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void + public function test_it_omits_unavailable_variants_and_selects_the_first_available_one(): void { Storage::fake('s3'); $tenant = $this->createTenant('detail-default'); @@ -69,14 +69,8 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonCount(2, 'data.variants') - ->assertJsonPath('data.variants.0.id', $firstVariant->id) - ->assertJsonPath('data.variants.0.maximum_addable_quantity', 0) - ->assertJsonPath( - 'data.variants.0.unavailable_message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath('data.variants.1.id', $secondVariant->id) + ->assertJsonCount(1, 'data.variants') + ->assertJsonPath('data.variants.0.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) ->assertJsonMissingPath('data.selected_variant.stock_tecnico') @@ -84,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertJsonMissingPath('data.stock_tecnico') ->assertJsonMissingPath('data.images'); + $this->assertNotContains( + $firstVariant->id, + collect($response->json('data.variants'))->pluck('id')->all(), + ); $this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0')); @@ -93,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase )->assertNotFound(); } + public function test_it_does_not_return_an_out_of_stock_item(): void + { + $tenant = $this->createTenant('detail-out-of-stock'); + $inventory = Inventory::query()->create([ + 'real_stock' => 5, + 'reserved_stock' => 5, + ]); + $item = $this->createItem($tenant, 'Sold out item', $inventory); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") + ->assertNotFound(); + } + public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void { Storage::fake('s3'); diff --git a/tests/Feature/Catalog/CatalogSearchTest.php b/tests/Feature/Catalog/CatalogSearchTest.php index 7fd4b07..ba07c2f 100644 --- a/tests/Feature/Catalog/CatalogSearchTest.php +++ b/tests/Feature/Catalog/CatalogSearchTest.php @@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase $this->createCatalogItem($tenant, "Running {$number}"); } $exactMatch = $this->createCatalogItem($tenant, 'Running'); + $outOfStock = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'running-sold-out', + 'nombre' => 'Running sold out', + 'descripcion' => 'Running sold out description', + 'precio' => 100, + ]); + $outOfStock->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id, + ]); + CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id, + 'slug' => 'running-direct-sold-out', + 'nombre' => 'Running direct sold out', + 'descripcion' => 'Running direct sold out description', + 'precio' => 100, + ]); $this->createCatalogItem($tenant, 'Unrelated'); $this->createCatalogItem($otherTenant, 'Running foreign'); @@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase ->assertJsonPath('meta.total', 6) ->assertJsonCount(4, 'data') ->assertJsonPath('data.0.id', $exactMatch->id) + ->assertJsonMissing(['nombre' => 'Running sold out']) + ->assertJsonMissing(['nombre' => 'Running direct sold out']) ->assertJsonMissing(['nombre' => 'Running foreign']) ->assertJsonMissing(['nombre' => 'Unrelated']); } diff --git a/tests/Feature/Catalog/CategoryDetailTest.php b/tests/Feature/Catalog/CategoryDetailTest.php index d7d6784..0b6ab2a 100644 --- a/tests/Feature/Catalog/CategoryDetailTest.php +++ b/tests/Feature/Catalog/CategoryDetailTest.php @@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase $this->createCatalogItem($tenant, $category, 'Remera C'); $firstItem = $this->createCatalogItem($tenant, $category, 'Remera A'); $secondItem = $this->createCatalogItem($tenant, $category, 'Remera B'); + $outOfStock = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'remera-agotada', + 'nombre' => 'Remera agotada', + 'descripcion' => 'Sin stock', + 'precio' => 100, + ]); + $outOfStock->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id, + ]); $this->createCatalogItem($tenant, $otherCategory, 'Pantalón'); $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}") @@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $firstItem->id) ->assertJsonPath('data.1.id', $secondItem->id) + ->assertJsonMissing(['nombre' => 'Remera agotada']) ->assertJsonMissing(['nombre' => 'Pantalón']); }