diff --git a/app/Domains/Catalog/Enums/AvailabilitySubject.php b/app/Domains/Catalog/Enums/AvailabilitySubject.php new file mode 100644 index 0000000..b54cd1b --- /dev/null +++ b/app/Domains/Catalog/Enums/AvailabilitySubject.php @@ -0,0 +1,9 @@ +inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory->availableStock(); + $availability = $this->availability( + $variantStock, + $remainingUserQuota, + AvailabilitySubject::Variant, + ); return [ 'id' => $variant->id, @@ -56,14 +62,11 @@ class CatalogFeaturedItemResource extends JsonResource 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'availability' => $this->availability( - $variantStock, - $remainingUserQuota, - 'variant', - ), + 'availability' => $availability, 'values' => $variant->selectorOptions($catalogItem->itemAttributes), ]; }) + ->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display']) ->values(), ]; @@ -117,9 +120,9 @@ class CatalogFeaturedItemResource extends JsonResource private function availability( ?int $stock, ?int $remainingUserQuota, - string $scope = 'product', + AvailabilitySubject $subject = AvailabilitySubject::Product, ): array { return app(CatalogItemAllowanceService::class) - ->availability($stock, $remainingUserQuota, $scope); + ->availability($stock, $remainingUserQuota, $subject); } } diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 18b0e9b..1fdb1de 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ItemAttribute; @@ -45,6 +46,7 @@ class CatalogItemDetailResource extends JsonResource ), 'variants' => $this->variants ->map(fn (Variant $variant): array => $this->variantData($variant)) + ->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display']) ->values(), 'selected_variant' => $this->when( $selectedVariant !== null, @@ -157,6 +159,10 @@ class CatalogItemDetailResource extends JsonResource $values = $variant->selectionOptions($this->itemAttributes); $eventDates = $variant->selectedEventDates(); $variantStock = $this->variantStock($variant); + $availability = $this->availability( + $variantStock, + AvailabilitySubject::Variant, + ); return [ 'id' => $variant->id, @@ -166,7 +172,7 @@ 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, '.', ''), - 'availability' => $this->availability($variantStock, 'variant'), + 'availability' => $availability, 'values' => $values, ]; } @@ -187,12 +193,14 @@ class CatalogItemDetailResource extends JsonResource } /** @return array */ - private function availability(?int $stock, string $scope = 'product'): array - { + private function availability( + ?int $stock, + AvailabilitySubject $subject = AvailabilitySubject::Product, + ): array { return app(CatalogItemAllowanceService::class)->availability( $stock, $this->getAttribute('remaining_user_quota'), - $scope, + $subject, ); } } diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index da1d726..7b23bd4 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; @@ -34,6 +35,10 @@ class CatalogSearchItemResource extends JsonResource $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited ? null : $variant->inventory?->availableStock(); + $availability = $this->availability( + $variantStock, + AvailabilitySubject::Variant, + ); return [ 'id' => $variant->id, @@ -43,21 +48,24 @@ class CatalogSearchItemResource extends JsonResource 'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), 'descripcion' => $variant->getDescription(), 'precio' => number_format($variant->getPrice(), 2, '.', ''), - 'availability' => $this->availability($variantStock, 'variant'), + 'availability' => $availability, 'values' => $variant->selectorOptions($this->itemAttributes), ]; }) + ->filter(fn (array $variant): bool => $variant['availability']['capabilities']['display']) ->values(), ]; } /** @return array */ - private function availability(?int $stock, string $scope = 'product'): array - { + private function availability( + ?int $stock, + AvailabilitySubject $subject = AvailabilitySubject::Product, + ): array { return app(CatalogItemAllowanceService::class)->availability( $stock, $this->getAttribute('remaining_user_quota'), - $scope, + $subject, ); } } diff --git a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php index de46f3d..f5575c3 100644 --- a/app/Domains/Catalog/Services/CatalogItemAllowanceService.php +++ b/app/Domains/Catalog/Services/CatalogItemAllowanceService.php @@ -2,8 +2,10 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Purchase\Services\UserPurchaseLimitService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class CatalogItemAllowanceService @@ -42,17 +44,42 @@ class CatalogItemAllowanceService return min($availableStock, $remainingUserQuota); } + /** @param Builder $query */ + public function applyProductVisibilityPolicy(Builder $query): Builder + { + return $this->isVisible( + 0, + null, + AvailabilitySubject::Product, + ) + ? $query + : $query->whereAvailableInCatalog(); + } + + public function isVisible( + ?int $availableStock, + ?int $remainingUserQuota, + AvailabilitySubject $subject, + ): bool { + return $this->availability( + $availableStock, + $remainingUserQuota, + $subject, + )['capabilities']['display']; + } + /** * @return array{ + * subject: string, * maximum_quantity: int|null, - * restrictions: list, - * capabilities: array{select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool} + * restrictions: list, + * capabilities: array{display: bool, select_variant: bool, change_quantity: bool, add_to_cart: bool, buy_now: bool} * } */ public function availability( ?int $availableStock, ?int $remainingUserQuota, - string $scope = 'product', + AvailabilitySubject $subject = AvailabilitySubject::Product, ): array { $restrictions = []; @@ -60,7 +87,6 @@ class CatalogItemAllowanceService $restrictions[] = [ 'code' => 'user_quota_reached', 'message' => self::USER_QUOTA_REACHED_MESSAGE, - 'scope' => $scope, ]; } @@ -68,11 +94,11 @@ class CatalogItemAllowanceService $restrictions[] = [ 'code' => 'out_of_stock', 'message' => self::OUT_OF_STOCK_MESSAGE, - 'scope' => $scope, ]; } $capabilities = [ + 'display' => true, 'select_variant' => true, 'change_quantity' => true, 'add_to_cart' => true, @@ -81,7 +107,10 @@ class CatalogItemAllowanceService foreach ($restrictions as $restriction) { /** @var array $policy */ - $policy = config("catalog.availability_policies.{$restriction['code']}", []); + $policy = config( + "catalog.availability_policies.{$restriction['code']}.{$subject->value}", + [], + ); foreach ($capabilities as $capability => $allowed) { $capabilities[$capability] = $allowed && ($policy[$capability] ?? true); @@ -89,6 +118,7 @@ class CatalogItemAllowanceService } return [ + 'subject' => $subject->value, 'maximum_quantity' => $this->maximumAddableQuantity( $availableStock, $remainingUserQuota, @@ -107,7 +137,6 @@ class CatalogItemAllowanceService $availability['restrictions'][] = [ 'code' => 'requested_quantity_exceeds_user_quota', 'message' => $message, - 'scope' => 'product', ]; } diff --git a/config/catalog.php b/config/catalog.php index f174c28..831f22a 100644 --- a/config/catalog.php +++ b/config/catalog.php @@ -4,16 +4,36 @@ return [ 'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30), 'availability_policies' => [ 'user_quota_reached' => [ - 'select_variant' => false, - 'change_quantity' => false, - 'add_to_cart' => false, - 'buy_now' => false, + 'product' => [ + 'display' => true, + 'select_variant' => false, + 'change_quantity' => false, + 'add_to_cart' => false, + 'buy_now' => false, + ], + 'variant' => [ + 'display' => true, + 'select_variant' => false, + 'change_quantity' => false, + 'add_to_cart' => false, + 'buy_now' => false, + ], ], 'out_of_stock' => [ - 'select_variant' => false, - 'change_quantity' => false, - 'add_to_cart' => false, - 'buy_now' => false, + 'product' => [ + 'display' => false, + 'select_variant' => false, + 'change_quantity' => false, + 'add_to_cart' => false, + 'buy_now' => false, + ], + 'variant' => [ + 'display' => false, + 'select_variant' => false, + 'change_quantity' => false, + 'add_to_cart' => false, + 'buy_now' => false, + ], ], ], ]; diff --git a/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php b/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php index 104966f..2342261 100644 --- a/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php +++ b/tests/Unit/Catalog/CatalogItemAllowanceServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Catalog; +use App\Domains\Catalog\Enums\AvailabilitySubject; use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Purchase\Services\UserPurchaseLimitService; use Mockery; @@ -15,15 +16,16 @@ class CatalogItemAllowanceServiceTest extends TestCase Mockery::mock(UserPurchaseLimitService::class), ); - $availability = $service->availability(0, 0, 'variant'); + $availability = $service->availability(0, 0, AvailabilitySubject::Variant); + $this->assertSame('variant', $availability['subject']); $this->assertSame(0, $availability['maximum_quantity']); $this->assertSame( ['user_quota_reached', 'out_of_stock'], array_column($availability['restrictions'], 'code'), ); - $this->assertSame(['variant', 'variant'], array_column($availability['restrictions'], 'scope')); $this->assertSame([ + 'display' => false, 'select_variant' => false, 'change_quantity' => false, 'add_to_cart' => false, @@ -39,11 +41,25 @@ class CatalogItemAllowanceServiceTest extends TestCase $availability = $service->availability(5, 3); + $this->assertSame('product', $availability['subject']); $this->assertSame(3, $availability['maximum_quantity']); $this->assertSame([], $availability['restrictions']); $this->assertNotContains(false, $availability['capabilities'], true); } + public function test_reaching_the_user_quota_keeps_the_product_visible(): void + { + $service = new CatalogItemAllowanceService( + Mockery::mock(UserPurchaseLimitService::class), + ); + + $availability = $service->availability(5, 0); + + $this->assertTrue($availability['capabilities']['display']); + $this->assertFalse($availability['capabilities']['add_to_cart']); + $this->assertSame('user_quota_reached', $availability['restrictions'][0]['code']); + } + public function test_it_describes_a_requested_quantity_that_exceeds_the_remaining_quota(): void { $service = new CatalogItemAllowanceService( @@ -57,6 +73,7 @@ class CatalogItemAllowanceServiceTest extends TestCase 'requested_quantity_exceeds_user_quota', $availability['restrictions'][0]['code'], ); + $this->assertTrue($availability['capabilities']['display']); $this->assertTrue($availability['capabilities']['change_quantity']); } }