feat(catalog): exclude out-of-stock items and update variant visibility logic
This commit is contained in:
@@ -172,17 +172,29 @@ class CatalogItem extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
/** @param Builder<CatalogItem> $query */
|
||||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
public function scopeWhereAvailable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('variants')
|
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
->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')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
$availableStock,
|
$availableStock,
|
||||||
$remainingUserQuota,
|
$remainingUserQuota,
|
||||||
),
|
),
|
||||||
'variants' => $catalogItem->variants
|
'variants' => $catalogItem->visibleVariants()
|
||||||
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||||
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
@@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||||
{
|
{
|
||||||
$attachment = $catalogItem->attachments->first()
|
$attachment = $catalogItem->attachments->first()
|
||||||
?? $catalogItem->variants
|
?? $catalogItem->visibleVariants()
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$availableStock = $this->availableStock();
|
$availableStock = $this->availableStock();
|
||||||
|
$visibleVariants = $this->visibleVariants();
|
||||||
$attachment = $this->attachments->first()
|
$attachment = $this->attachments->first()
|
||||||
?? $this->variants
|
?? $visibleVariants
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
||||||
'unavailable_message' => $this->unavailableMessage($availableStock),
|
'unavailable_message' => $this->unavailableMessage($availableStock),
|
||||||
'variants' => $this->variants
|
'variants' => $visibleVariants
|
||||||
->map(function (Variant $variant): array {
|
->map(function (Variant $variant): array {
|
||||||
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$visibleVariants = $catalogItem->visibleVariants();
|
$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
|
$selectedVariant = $variantId === null
|
||||||
? $visibleVariants->first()
|
? $visibleVariants->first()
|
||||||
: $visibleVariants->firstWhere('id', $variantId);
|
: $visibleVariants->firstWhere('id', $variantId);
|
||||||
@@ -231,6 +238,7 @@ class CatalogService
|
|||||||
|
|
||||||
$paginator = CatalogItem::query()
|
$paginator = CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereAvailable()
|
||||||
->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])
|
||||||
@@ -280,6 +288,7 @@ 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)
|
||||||
|
->whereAvailable()
|
||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class FeaturedGroupService
|
|||||||
{
|
{
|
||||||
$query = CatalogItem::query()
|
$query = CatalogItem::query()
|
||||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->whereAvailable()
|
||||||
->where(function (Builder $query): void {
|
->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('category')
|
->whereDoesntHave('category')
|
||||||
|
|||||||
@@ -73,19 +73,18 @@ 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(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.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)
|
||||||
->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')
|
||||||
->assertJsonCount(0, '1.items.data.0.variants');
|
->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
|
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');
|
->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');
|
$tenant = $this->createTenant('catalog-available-variants');
|
||||||
$group = $this->createGroup(
|
$group = $this->createGroup(
|
||||||
@@ -161,15 +160,10 @@ class CatalogControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(2, '0.items')
|
->assertJsonCount(1, '0.items')
|
||||||
->assertJsonPath('0.items.0.nombre', 'Unavailable')
|
->assertJsonPath('0.items.0.nombre', 'Available')
|
||||||
->assertJsonPath('0.items.0.maximum_addable_quantity', 0)
|
->assertJsonPath('0.items.0.unavailable_message', null)
|
||||||
->assertJsonPath(
|
->assertJsonMissing(['nombre' => 'Unavailable']);
|
||||||
'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
|
||||||
|
|||||||
@@ -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_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');
|
Storage::fake('s3');
|
||||||
$tenant = $this->createTenant('detail-default');
|
$tenant = $this->createTenant('detail-default');
|
||||||
@@ -69,14 +69,8 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(2, 'data.variants')
|
->assertJsonCount(1, 'data.variants')
|
||||||
->assertJsonPath('data.variants.0.id', $firstVariant->id)
|
->assertJsonPath('data.variants.0.id', $secondVariant->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')
|
||||||
@@ -84,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||||||
$response
|
$response
|
||||||
->assertJsonMissingPath('data.stock_tecnico')
|
->assertJsonMissingPath('data.stock_tecnico')
|
||||||
->assertJsonMissingPath('data.images');
|
->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->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
|
||||||
$this->assertStringNotContainsString($firstImage->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'));
|
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
|
||||||
@@ -93,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase
|
|||||||
)->assertNotFound();
|
)->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
|
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
|
||||||
{
|
{
|
||||||
Storage::fake('s3');
|
Storage::fake('s3');
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase
|
|||||||
$this->createCatalogItem($tenant, "Running {$number}");
|
$this->createCatalogItem($tenant, "Running {$number}");
|
||||||
}
|
}
|
||||||
$exactMatch = $this->createCatalogItem($tenant, 'Running');
|
$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($tenant, 'Unrelated');
|
||||||
$this->createCatalogItem($otherTenant, 'Running foreign');
|
$this->createCatalogItem($otherTenant, 'Running foreign');
|
||||||
|
|
||||||
@@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase
|
|||||||
->assertJsonPath('meta.total', 6)
|
->assertJsonPath('meta.total', 6)
|
||||||
->assertJsonCount(4, 'data')
|
->assertJsonCount(4, 'data')
|
||||||
->assertJsonPath('data.0.id', $exactMatch->id)
|
->assertJsonPath('data.0.id', $exactMatch->id)
|
||||||
|
->assertJsonMissing(['nombre' => 'Running sold out'])
|
||||||
|
->assertJsonMissing(['nombre' => 'Running direct sold out'])
|
||||||
->assertJsonMissing(['nombre' => 'Running foreign'])
|
->assertJsonMissing(['nombre' => 'Running foreign'])
|
||||||
->assertJsonMissing(['nombre' => 'Unrelated']);
|
->assertJsonMissing(['nombre' => 'Unrelated']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase
|
|||||||
$this->createCatalogItem($tenant, $category, 'Remera C');
|
$this->createCatalogItem($tenant, $category, 'Remera C');
|
||||||
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
|
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
|
||||||
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
|
$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->createCatalogItem($tenant, $otherCategory, 'Pantalón');
|
||||||
|
|
||||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||||
@@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase
|
|||||||
->assertJsonCount(2, 'data')
|
->assertJsonCount(2, 'data')
|
||||||
->assertJsonPath('data.0.id', $firstItem->id)
|
->assertJsonPath('data.0.id', $firstItem->id)
|
||||||
->assertJsonPath('data.1.id', $secondItem->id)
|
->assertJsonPath('data.1.id', $secondItem->id)
|
||||||
|
->assertJsonMissing(['nombre' => 'Remera agotada'])
|
||||||
->assertJsonMissing(['nombre' => 'Pantalón']);
|
->assertJsonMissing(['nombre' => 'Pantalón']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user