diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index e949d00..c25f22b 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -3,6 +3,7 @@ namespace App\Domains\Catalog\Controllers; use App\Domains\Cart\Services\CartService; +use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; @@ -123,6 +124,11 @@ class CatalogController extends Controller $variantId === null ? null : (int) $variantId, ); $allowances->attach(collect([$item]), $this->userId($request)); + abort_unless($allowances->isVisible( + $item->availableStock(), + $item->getAttribute('remaining_user_quota'), + AvailabilitySubject::Product, + ), 404); return CatalogItemDetailResource::make($item); } diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 2828f65..b7d6a68 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -186,6 +186,59 @@ class CatalogItem extends Model }); } + /** @param Builder $query */ + public function scopeWhereAvailableInCatalog(Builder $query): Builder + { + return $query->where(function (Builder $query): void { + $query + ->where(function (Builder $standardQuery): void { + $standardQuery + ->where('catalog_items.type', CatalogItemType::Standard->value) + ->where(function (Builder $inventoryQuery): void { + $inventoryQuery + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->orWhereHas( + 'inventory', + fn (Builder $query): Builder => $query + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'), + ) + ->orWhereHas( + 'variants.inventory', + fn (Builder $query): Builder => $query + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'), + ); + }); + }) + ->orWhere(function (Builder $bundleQuery): void { + $bundleQuery + ->where('catalog_items.type', CatalogItemType::Bundle->value) + ->whereRaw(<<<'SQL' + NOT EXISTS ( + SELECT 1 + FROM bundle_components AS availability_components + INNER JOIN catalog_items AS availability_items + ON availability_items.id = availability_components.component_catalog_item_id + LEFT JOIN variantes AS availability_variants + ON availability_variants.id = availability_components.component_variant_id + INNER JOIN inventories AS availability_inventories + ON availability_inventories.id = COALESCE( + availability_variants.inventory_id, + availability_items.inventory_id + ) + WHERE availability_components.bundle_catalog_item_id = catalog_items.id + AND availability_items.inventory_policy = ? + GROUP BY availability_inventories.id, + availability_inventories.real_stock, + availability_inventories.reserved_stock + HAVING availability_inventories.real_stock + - availability_inventories.reserved_stock + < SUM(availability_components.quantity) + ) + SQL, [InventoryPolicy::Tracked->value]); + }); + }); + } + /** @return Collection */ public function visibleVariants(?int $includedVariantId = null): Collection { diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index d5b4f1b..0546452 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -22,7 +22,10 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CatalogService { - public function __construct(protected AttachmentService $attachmentService) {} + public function __construct( + protected AttachmentService $attachmentService, + private readonly CatalogItemAllowanceService $allowances, + ) {} /** * @param array $data @@ -229,7 +232,7 @@ class CatalogService $containsPattern = "%{$normalizedTerm}%"; $startsWithPattern = "{$normalizedTerm}%"; - $paginator = CatalogItem::query() + $query = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where(function (Builder $query) use ($containsPattern): void { $query @@ -257,7 +260,10 @@ class CatalogService 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', - ]) + ]); + + $paginator = $this->allowances + ->applyProductVisibilityPolicy($query) ->orderByRaw( 'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END', [$normalizedTerm, $startsWithPattern], @@ -277,7 +283,7 @@ class CatalogService int $perPage, int $page, ): LengthAwarePaginator { - return CatalogItem::query() + $query = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) ->with([ @@ -291,7 +297,10 @@ class CatalogService 'variants.definitions.itemAttribute.attribute.options', 'bundleComponents.catalogItem', 'bundleComponents.variant.catalogItem', - ]) + ]); + + return $this->allowances + ->applyProductVisibilityPolicy($query) ->orderBy('nombre') ->paginate(perPage: $perPage, pageName: 'page', page: $page); } diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 0302a1f..7203cf4 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -71,6 +71,8 @@ class FeaturedGroupService 'bundleComponents.variant.catalogItem', ]); + $query = $this->allowances->applyProductVisibilityPolicy($query); + return match ($featuredGroup->source_type) { FeaturedGroupSource::Manual => $query ->select('catalog_items.*') diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 847bd86..51da7de 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -41,7 +41,7 @@ class CatalogControllerTest extends TestCase $directItem = $this->createItem($tenant, 'Direct', $directInventory); $row->featuredItems()->create(['catalog_item_id' => $directItem->id]); - $variantItem = $this->createItem($tenant, 'Variants'); + $variantItem = $this->createItem($tenant, 'Variants', withoutInventory: true); $firstInventory = Inventory::query()->create([ 'real_stock' => 5, 'reserved_stock' => 1, @@ -56,7 +56,7 @@ class CatalogControllerTest extends TestCase ]); $variantItem->variants()->create(['inventory_id' => $firstInventory->id]); $variantItem->variants()->create(['inventory_id' => $secondInventory->id]); - $unavailableVariant = $variantItem->variants()->create([ + $variantItem->variants()->create([ 'inventory_id' => $unavailableInventory->id, ]); $cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]); @@ -72,24 +72,12 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.nombre', 'Variants') ->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.precio', '100.00') + ->assertJsonPath('0.items.0.availability.subject', 'product') ->assertJsonPath('0.items.0.availability.maximum_quantity', 7) - ->assertJsonCount(3, '0.items.0.variants') + ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 4) + ->assertJsonPath('0.items.0.variants.0.availability.subject', 'variant') ->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 3) - ->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id) - ->assertJsonPath('0.items.0.variants.2.availability.maximum_quantity', 0) - ->assertJsonPath( - '0.items.0.variants.2.availability.restrictions.0.message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath( - '0.items.0.variants.2.availability.capabilities.select_variant', - false, - ) - ->assertJsonPath( - '0.items.0.variants.2.availability.capabilities.add_to_cart', - false, - ) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.availability.maximum_quantity', 8) ->assertJsonMissingPath('1.items.data.0.stock_tecnico') @@ -106,7 +94,7 @@ class CatalogControllerTest extends TestCase groupLayout: GroupLayout::Simple, ); $user = User::factory()->create(); - $item = $this->createItem($tenant, 'Limited variants'); + $item = $this->createItem($tenant, 'Limited variants', withoutInventory: true); $item->update(['max_units_per_user' => 3]); $firstVariant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, @@ -145,7 +133,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_hides_out_of_stock_items_before_building_the_group_response(): void { $tenant = $this->createTenant('catalog-available-variants'); $group = $this->createGroup( @@ -155,7 +143,7 @@ class CatalogControllerTest extends TestCase groupLayout: GroupLayout::SimpleVertical, ); - $unavailableItem = $this->createItem($tenant, 'Unavailable'); + $unavailableItem = $this->createItem($tenant, 'Unavailable', withoutInventory: true); $unavailableInventory = Inventory::query()->create([ 'real_stock' => 4, 'reserved_stock' => 4, @@ -163,7 +151,7 @@ class CatalogControllerTest extends TestCase $unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]); $group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]); - $availableItem = $this->createItem($tenant, 'Available'); + $availableItem = $this->createItem($tenant, 'Available', withoutInventory: true); $availableInventory = Inventory::query()->create([ 'real_stock' => 4, 'reserved_stock' => 3, @@ -173,15 +161,9 @@ 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.availability.maximum_quantity', 0) - ->assertJsonPath( - '0.items.0.availability.restrictions.0.message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath('0.items.1.nombre', 'Available') - ->assertJsonCount(0, '0.items.1.availability.restrictions'); + ->assertJsonCount(1, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Available') + ->assertJsonCount(0, '0.items.0.availability.restrictions'); } public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void @@ -198,8 +180,8 @@ class CatalogControllerTest extends TestCase 'order' => 0, ]); - $variantItem = $this->createItem($tenant, 'Variant image'); - $variantInventory = Inventory::query()->create(); + $variantItem = $this->createItem($tenant, 'Variant image', withoutInventory: true); + $variantInventory = Inventory::query()->create(['real_stock' => 1]); $variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]); $variantImage = $this->createAttachment('variant'); $variant->attachments()->attach($variantImage, ['orden' => 0]); @@ -446,7 +428,12 @@ class CatalogControllerTest extends TestCase Tenant $tenant, string $name, ?Inventory $inventory = null, + bool $withoutInventory = false, ): CatalogItem { + $inventory ??= $withoutInventory + ? null + : Inventory::query()->create(['real_stock' => 10]); + return CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'inventory_id' => $inventory?->id, diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 40ca24e..385d365 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -48,7 +48,18 @@ 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_does_not_return_an_out_of_stock_product_detail(): void + { + $tenant = $this->createTenant('detail-hidden'); + $inventory = Inventory::query()->create(['real_stock' => 0]); + $item = $this->createItem($tenant, 'Hidden item', $inventory); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}", + )->assertNotFound(); + } + + public function test_it_hides_unavailable_variants_and_selects_the_first_available_one(): void { Storage::fake('s3'); $tenant = $this->createTenant('detail-default'); @@ -69,14 +80,9 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonCount(2, 'data.variants') - ->assertJsonPath('data.variants.0.id', $firstVariant->id) - ->assertJsonPath('data.variants.0.availability.maximum_quantity', 0) - ->assertJsonPath( - 'data.variants.0.availability.restrictions.0.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.variants.0.availability.subject', 'variant') ->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.availability.maximum_quantity', 6) ->assertJsonMissingPath('data.selected_variant.stock_tecnico') diff --git a/tests/Feature/Catalog/CatalogSearchTest.php b/tests/Feature/Catalog/CatalogSearchTest.php index 7fd4b07..e5fdc5c 100644 --- a/tests/Feature/Catalog/CatalogSearchTest.php +++ b/tests/Feature/Catalog/CatalogSearchTest.php @@ -62,6 +62,8 @@ class CatalogSearchTest extends TestCase $this->createCatalogItem($tenant, "Running {$number}"); } $exactMatch = $this->createCatalogItem($tenant, 'Running'); + $outOfStockMatch = $this->createCatalogItem($tenant, 'Running unavailable'); + $outOfStockMatch->inventory->update(['real_stock' => 0]); $this->createCatalogItem($tenant, 'Unrelated'); $this->createCatalogItem($otherTenant, 'Running foreign'); @@ -76,6 +78,7 @@ class CatalogSearchTest extends TestCase ->assertJsonPath('meta.total', 6) ->assertJsonCount(4, 'data') ->assertJsonPath('data.0.id', $exactMatch->id) + ->assertJsonMissing(['nombre' => 'Running unavailable']) ->assertJsonMissing(['nombre' => 'Running foreign']) ->assertJsonMissing(['nombre' => 'Unrelated']); }