diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 1951d71..4a02a78 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -20,7 +20,6 @@ class CatalogFeaturedItemResource extends JsonResource $catalogItem = $this->resource; /** @var FeaturedGroup $featuredGroup */ $featuredGroup = $catalogItem->getRelation('featuredGroup'); - $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) { return $this->columnWithImageData($catalogItem); @@ -30,6 +29,9 @@ class CatalogFeaturedItemResource extends JsonResource return $this->ticketSelectorData($catalogItem); } + $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); + $availableStock = $catalogItem->availableStock(); + $data = [ 'id' => $catalogItem->id, 'type' => $catalogItem->type->value, @@ -37,26 +39,38 @@ class CatalogFeaturedItemResource extends JsonResource 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, 'maximum_addable_quantity' => $this->maximumAddable( - $catalogItem->availableStock(), + $availableStock, $remainingUserQuota, ), - 'variants' => $catalogItem->visibleVariants() - ->map(fn (Variant $variant): array => [ - 'id' => $variant->id, - 'event_date_id' => $variant->event_date_id, - 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), - 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), - 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), - 'descripcion' => $variant->getDescription(), - 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'maximum_addable_quantity' => $this->maximumAddable( - $catalogItem->inventory_policy === InventoryPolicy::Unlimited - ? null - : $variant->inventory->availableStock(), - $remainingUserQuota, - ), - 'values' => $variant->selectorOptions($catalogItem->itemAttributes), - ]) + 'unavailable_message' => $this->unavailableMessage( + $availableStock, + $remainingUserQuota, + ), + 'variants' => $catalogItem->variants + ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array { + $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $variant->event_date_id, + 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), + 'maximum_addable_quantity' => $this->maximumAddable( + $variantStock, + $remainingUserQuota, + ), + 'unavailable_message' => $this->unavailableMessage( + $variantStock, + $remainingUserQuota, + ), + 'values' => $variant->selectorOptions($catalogItem->itemAttributes), + ]; + }) ->values(), ]; @@ -66,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource /** @return array */ private function ticketSelectorData(CatalogItem $catalogItem): array { + $availableStock = $catalogItem->availableStock(); + $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); + return [ 'id' => $catalogItem->id, 'type' => $catalogItem->type->value, @@ -73,18 +90,25 @@ class CatalogFeaturedItemResource extends JsonResource 'descripcion' => $catalogItem->descripcion, 'precio' => $catalogItem->precio, 'image' => $this->firstImageUrl($catalogItem), + 'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota), + 'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota), ]; } /** @return array */ private function columnWithImageData(CatalogItem $catalogItem): array { + $availableStock = $catalogItem->availableStock(); + $remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota'); + return [ 'id' => $catalogItem->id, 'type' => $catalogItem->type->value, 'nombre' => $catalogItem->nombre, 'precio' => $catalogItem->precio, 'image' => $this->firstImageUrl($catalogItem), + 'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota), + 'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota), ]; } @@ -103,4 +127,10 @@ class CatalogFeaturedItemResource extends JsonResource return app(CatalogItemAllowanceService::class) ->maximumAddableQuantity($stock, $remainingUserQuota); } + + private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string + { + return app(CatalogItemAllowanceService::class) + ->unavailableMessage($stock, $remainingUserQuota); + } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 7f51d93..d0c1090 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -42,11 +42,15 @@ class CatalogItemDetailResource extends JsonResource $selectedVariant === null, fn () => $this->maximumAddable($this->availableStock()), ), + 'unavailable_message' => $this->when( + $selectedVariant === null, + fn () => $this->unavailableMessage($this->availableStock()), + ), 'images' => $this->when( $selectedVariant === null, fn () => $this->imageUrls($this->attachments), ), - 'variants' => $this->visibleVariants() + 'variants' => $this->variants ->map(fn (Variant $variant): array => $this->variantData($variant)) ->values(), 'selected_variant' => $this->when( @@ -159,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource { $values = $variant->selectionOptions($this->itemAttributes); $eventDates = $variant->selectedEventDates(); + $variantStock = $this->variantStock($variant); return [ 'id' => $variant->id, @@ -168,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource 'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)), + 'maximum_addable_quantity' => $this->maximumAddable($variantStock), + 'unavailable_message' => $this->unavailableMessage($variantStock), 'values' => $values, ]; } @@ -195,4 +201,12 @@ class CatalogItemDetailResource extends JsonResource $this->getAttribute('remaining_user_quota'), ); } + + private function unavailableMessage(?int $stock): ?string + { + return app(CatalogItemAllowanceService::class)->unavailableMessage( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 14d1103..3d1c2d8 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -15,6 +15,7 @@ class CatalogSearchItemResource extends JsonResource /** @return array */ public function toArray(Request $request): array { + $availableStock = $this->availableStock(); $attachment = $this->attachments->first() ?? $this->variants ->flatMap(fn (Variant $variant) => $variant->attachments) @@ -27,23 +28,27 @@ class CatalogSearchItemResource extends JsonResource 'descripcion' => $this->descripcion, 'precio' => $this->precio, 'image' => $attachment?->getTemporaryUrl(1440), - 'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()), - 'variants' => $this->visibleVariants() - ->map(fn (Variant $variant): array => [ - 'id' => $variant->id, - 'event_date_id' => $variant->event_date_id, - 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), - 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), - 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), - 'descripcion' => $variant->getDescription(), - 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'maximum_addable_quantity' => $this->maximumAddable( - $this->inventory_policy === InventoryPolicy::Unlimited - ? null - : $variant->inventory?->availableStock(), - ), - 'values' => $variant->selectorOptions($this->itemAttributes), - ]) + 'maximum_addable_quantity' => $this->maximumAddable($availableStock), + 'unavailable_message' => $this->unavailableMessage($availableStock), + 'variants' => $this->variants + ->map(function (Variant $variant): array { + $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory?->availableStock(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $variant->event_date_id, + 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), + 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), + 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), + 'descripcion' => $variant->getDescription(), + 'precio' => number_format($variant->getPrice(), 2, '.', ''), + 'maximum_addable_quantity' => $this->maximumAddable($variantStock), + 'unavailable_message' => $this->unavailableMessage($variantStock), + 'values' => $variant->selectorOptions($this->itemAttributes), + ]; + }) ->values(), ]; } @@ -55,4 +60,12 @@ class CatalogSearchItemResource extends JsonResource $this->getAttribute('remaining_user_quota'), ); } + + private function unavailableMessage(?int $stock): ?string + { + return app(CatalogItemAllowanceService::class)->unavailableMessage( + $stock, + $this->getAttribute('remaining_user_quota'), + ); + } } diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php index 6070c55..26726f2 100644 --- a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -8,6 +8,10 @@ use Illuminate\Support\Collection; class CatalogItemAllowanceService { + private const USER_QUOTA_REACHED_MESSAGE = 'Alcanzaste el cupo máximo permitido para este producto.'; + + private const OUT_OF_STOCK_MESSAGE = 'Este producto no tiene stock disponible.'; + public function __construct( private readonly UserPurchaseLimitService $purchaseLimits, ) {} @@ -37,4 +41,17 @@ class CatalogItemAllowanceService return min($availableStock, $remainingUserQuota); } + + public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string + { + if ($remainingUserQuota !== null && $remainingUserQuota <= 0) { + return self::USER_QUOTA_REACHED_MESSAGE; + } + + if ($availableStock !== null && $availableStock <= 0) { + return self::OUT_OF_STOCK_MESSAGE; + } + + return null; + } } diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 5d858bf..d5b4f1b 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -231,7 +231,6 @@ class CatalogService $paginator = CatalogItem::query() ->where('tenant_code', $tenant->codigo) - ->whereVariantsAvailable() ->where(function (Builder $query) use ($containsPattern): void { $query ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) @@ -281,7 +280,6 @@ class CatalogService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) - ->whereVariantsAvailable() ->with([ 'attachments', 'inventory', diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 7f9258e..f01b679 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,7 +49,6 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) - ->whereVariantsAvailable() ->with([ 'inventory', 'attachments', diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 0b5358b..63f1051 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -73,10 +73,15 @@ 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(2, '0.items.0.variants') + ->assertJsonCount(3, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) - ->assertJsonMissing(['id' => $unavailableVariant->id]) + ->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') @@ -94,7 +99,7 @@ class CatalogControllerTest extends TestCase ); $user = User::factory()->create(); $item = $this->createItem($tenant, 'Limited variants'); - $item->update(['max_units_per_user' => 5]); + $item->update(['max_units_per_user' => 3]); $firstVariant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, ]); @@ -114,13 +119,21 @@ class CatalogControllerTest extends TestCase $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/{$tenant->codigo}/catalog") ->assertOk() - ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2) - ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2) + ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0) + ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 0) + ->assertJsonPath( + '0.items.0.variants.0.unavailable_message', + 'Alcanzaste el cupo máximo permitido para este producto.', + ) + ->assertJsonPath( + '0.items.0.variants.1.unavailable_message', + 'Alcanzaste el cupo máximo permitido para este producto.', + ) ->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico') ->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico'); } - public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void + public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void { $tenant = $this->createTenant('catalog-available-variants'); $group = $this->createGroup( @@ -148,9 +161,15 @@ class CatalogControllerTest extends TestCase $this->getJson("/api/tenants/{$tenant->codigo}/catalog") ->assertOk() - ->assertJsonCount(1, '0.items') - ->assertJsonPath('0.items.0.nombre', 'Available') - ->assertJsonMissing(['nombre' => 'Unavailable']); + ->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); } 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 3f8a06a..816cd99 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_filters_unavailable_variants_and_selects_the_first_available_one(): void + public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void { Storage::fake('s3'); $tenant = $this->createTenant('detail-default'); @@ -69,8 +69,14 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonCount(1, 'data.variants') - ->assertJsonPath('data.variants.0.id', $secondVariant->id) + ->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) ->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) ->assertJsonMissingPath('data.selected_variant.stock_tecnico') diff --git a/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php b/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php new file mode 100644 index 0000000..4d882aa --- /dev/null +++ b/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php @@ -0,0 +1,43 @@ +service = (new ReflectionClass(CatalogItemAllowanceService::class)) + ->newInstanceWithoutConstructor(); + } + + public function test_it_returns_the_user_quota_message_with_priority_over_stock(): void + { + $this->assertSame( + 'Alcanzaste el cupo máximo permitido para este producto.', + $this->service->unavailableMessage(0, 0), + ); + } + + public function test_it_returns_the_out_of_stock_message(): void + { + $this->assertSame( + 'Este producto no tiene stock disponible.', + $this->service->unavailableMessage(0, null), + ); + } + + public function test_it_returns_no_message_when_the_item_is_available(): void + { + $this->assertNull($this->service->unavailableMessage(1, null)); + $this->assertNull($this->service->unavailableMessage(null, 1)); + $this->assertNull($this->service->unavailableMessage(null, null)); + } +}