feat(catalog): expose product unavailability messages

This commit is contained in:
2026-08-21 16:47:15 -03:00
parent e8bcaa2026
commit 1dd2366957
9 changed files with 192 additions and 53 deletions

View File

@@ -20,7 +20,6 @@ class CatalogFeaturedItemResource extends JsonResource
$catalogItem = $this->resource; $catalogItem = $this->resource;
/** @var FeaturedGroup $featuredGroup */ /** @var FeaturedGroup $featuredGroup */
$featuredGroup = $catalogItem->getRelation('featuredGroup'); $featuredGroup = $catalogItem->getRelation('featuredGroup');
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) { if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
return $this->columnWithImageData($catalogItem); return $this->columnWithImageData($catalogItem);
@@ -30,6 +29,9 @@ class CatalogFeaturedItemResource extends JsonResource
return $this->ticketSelectorData($catalogItem); return $this->ticketSelectorData($catalogItem);
} }
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
$availableStock = $catalogItem->availableStock();
$data = [ $data = [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
@@ -37,26 +39,38 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion, 'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'maximum_addable_quantity' => $this->maximumAddable( 'maximum_addable_quantity' => $this->maximumAddable(
$catalogItem->availableStock(), $availableStock,
$remainingUserQuota, $remainingUserQuota,
), ),
'variants' => $catalogItem->visibleVariants() 'unavailable_message' => $this->unavailableMessage(
->map(fn (Variant $variant): array => [ $availableStock,
'id' => $variant->id, $remainingUserQuota,
'event_date_id' => $variant->event_date_id, ),
'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'variants' => $catalogItem->variants
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
'descripcion' => $variant->getDescription(), ? null
'precio' => number_format($variant->getPrice(), 2, '.', ''), : $variant->inventory->availableStock();
'maximum_addable_quantity' => $this->maximumAddable(
$catalogItem->inventory_policy === InventoryPolicy::Unlimited return [
? null 'id' => $variant->id,
: $variant->inventory->availableStock(), 'event_date_id' => $variant->event_date_id,
$remainingUserQuota, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
), 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes), '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(), ->values(),
]; ];
@@ -66,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function ticketSelectorData(CatalogItem $catalogItem): array private function ticketSelectorData(CatalogItem $catalogItem): array
{ {
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [ return [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
@@ -73,18 +90,25 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion, 'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem), 'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
]; ];
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function columnWithImageData(CatalogItem $catalogItem): array private function columnWithImageData(CatalogItem $catalogItem): array
{ {
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [ return [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem), '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) return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota); ->maximumAddableQuantity($stock, $remainingUserQuota);
} }
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
}
} }

View File

@@ -42,11 +42,15 @@ class CatalogItemDetailResource extends JsonResource
$selectedVariant === null, $selectedVariant === null,
fn () => $this->maximumAddable($this->availableStock()), fn () => $this->maximumAddable($this->availableStock()),
), ),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
),
'images' => $this->when( 'images' => $this->when(
$selectedVariant === null, $selectedVariant === null,
fn () => $this->imageUrls($this->attachments), fn () => $this->imageUrls($this->attachments),
), ),
'variants' => $this->visibleVariants() 'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant)) ->map(fn (Variant $variant): array => $this->variantData($variant))
->values(), ->values(),
'selected_variant' => $this->when( 'selected_variant' => $this->when(
@@ -159,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource
{ {
$values = $variant->selectionOptions($this->itemAttributes); $values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates(); $eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
return [ return [
'id' => $variant->id, '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(), 'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(), 'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''), '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, 'values' => $values,
]; ];
} }
@@ -195,4 +201,12 @@ class CatalogItemDetailResource extends JsonResource
$this->getAttribute('remaining_user_quota'), $this->getAttribute('remaining_user_quota'),
); );
} }
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
} }

View File

@@ -15,6 +15,7 @@ class CatalogSearchItemResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$availableStock = $this->availableStock();
$attachment = $this->attachments->first() $attachment = $this->attachments->first()
?? $this->variants ?? $this->variants
->flatMap(fn (Variant $variant) => $variant->attachments) ->flatMap(fn (Variant $variant) => $variant->attachments)
@@ -27,23 +28,27 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion, 'descripcion' => $this->descripcion,
'precio' => $this->precio, 'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440), 'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()), 'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'variants' => $this->visibleVariants() 'unavailable_message' => $this->unavailableMessage($availableStock),
->map(fn (Variant $variant): array => [ 'variants' => $this->variants
'id' => $variant->id, ->map(function (Variant $variant): array {
'event_date_id' => $variant->event_date_id, $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
'event_date' => $variant->eventDate?->date?->format('Y-m-d'), ? null
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), : $variant->inventory?->availableStock();
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(), return [
'precio' => number_format($variant->getPrice(), 2, '.', ''), 'id' => $variant->id,
'maximum_addable_quantity' => $this->maximumAddable( 'event_date_id' => $variant->event_date_id,
$this->inventory_policy === InventoryPolicy::Unlimited 'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
? null 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
: $variant->inventory?->availableStock(), 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
), 'descripcion' => $variant->getDescription(),
'values' => $variant->selectorOptions($this->itemAttributes), 'precio' => number_format($variant->getPrice(), 2, '.', ''),
]) 'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->values(), ->values(),
]; ];
} }
@@ -55,4 +60,12 @@ class CatalogSearchItemResource extends JsonResource
$this->getAttribute('remaining_user_quota'), $this->getAttribute('remaining_user_quota'),
); );
} }
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
} }

View File

@@ -8,6 +8,10 @@ use Illuminate\Support\Collection;
class CatalogItemAllowanceService 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( public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits, private readonly UserPurchaseLimitService $purchaseLimits,
) {} ) {}
@@ -37,4 +41,17 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota); 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;
}
} }

View File

@@ -231,7 +231,6 @@ class CatalogService
$paginator = CatalogItem::query() $paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->where(function (Builder $query) use ($containsPattern): void { ->where(function (Builder $query) use ($containsPattern): void {
$query $query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -281,7 +280,6 @@ class CatalogService
return CatalogItem::query() return CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id) ->where('category_id', $category->id)
->whereVariantsAvailable()
->with([ ->with([
'attachments', 'attachments',
'inventory', 'inventory',

View File

@@ -49,7 +49,6 @@ class FeaturedGroupService
{ {
$query = CatalogItem::query() $query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code) ->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereVariantsAvailable()
->with([ ->with([
'inventory', 'inventory',
'attachments', 'attachments',

View File

@@ -73,10 +73,15 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.descripcion', 'Variants description')
->assertJsonPath('0.items.0.precio', '100.00') ->assertJsonPath('0.items.0.precio', '100.00')
->assertJsonPath('0.items.0.maximum_addable_quantity', 7) ->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.0.maximum_addable_quantity', 4)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) ->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.title', 'Row')
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
->assertJsonMissingPath('1.items.data.0.stock_tecnico') ->assertJsonMissingPath('1.items.data.0.stock_tecnico')
@@ -94,7 +99,7 @@ class CatalogControllerTest extends TestCase
); );
$user = User::factory()->create(); $user = User::factory()->create();
$item = $this->createItem($tenant, 'Limited variants'); $item = $this->createItem($tenant, 'Limited variants');
$item->update(['max_units_per_user' => 5]); $item->update(['max_units_per_user' => 3]);
$firstVariant = $item->variants()->create([ $firstVariant = $item->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
]); ]);
@@ -114,13 +119,21 @@ class CatalogControllerTest extends TestCase
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/catalog") ->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk() ->assertOk()
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2) ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2) ->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.0.stock_tecnico')
->assertJsonMissingPath('0.items.0.variants.1.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'); $tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup( $group = $this->createGroup(
@@ -148,9 +161,15 @@ class CatalogControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog") $this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk() ->assertOk()
->assertJsonCount(1, '0.items') ->assertJsonCount(2, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available') ->assertJsonPath('0.items.0.nombre', 'Unavailable')
->assertJsonMissing(['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 public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void

View File

@@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); $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'); Storage::fake('s3');
$tenant = $this->createTenant('detail-default'); $tenant = $this->createTenant('detail-default');
@@ -69,8 +69,14 @@ class CatalogItemDetailControllerTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.variants') ->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.id', $secondVariant->id) ->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.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6)
->assertJsonMissingPath('data.selected_variant.stock_tecnico') ->assertJsonMissingPath('data.selected_variant.stock_tecnico')

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use ReflectionClass;
use Tests\TestCase;
class CatalogItemAllowanceServiceTest extends TestCase
{
private CatalogItemAllowanceService $service;
protected function setUp(): void
{
parent::setUp();
$this->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));
}
}